Skip to content

docs(utils): properly document is_normalized_name vs canonicalize_name - #1291

Merged
henryiii merged 1 commit into
pypa:mainfrom
binggao1230:fix/is-normalized-name-edge-hyphens
Jul 20, 2026
Merged

henryiii merged 1 commit into
pypa:mainfrom
binggao1230:fix/is-normalized-name-edge-hyphens

Conversation

@binggao1230

Copy link
Copy Markdown
Contributor

Summary

is_normalized_name() is documented to return True iff canonicalize_name() would roundtrip to the same value. However, the previous regex-based implementation ([a-z0-9]+(?:-[a-z0-9]+)*) rejected names with leading or trailing hyphens — exactly the strings canonicalize_name() can produce for inputs like "_not_legal" or "a_".

>>> from packaging.utils import canonicalize_name, is_normalized_name
>>> n = canonicalize_name("_not_legal")   # -> "-not-legal"
>>> canonicalize_name(n) == n             # True (fixed point)
True
>>> is_normalized_name(n)                 # False — breaks the documented contract
False

The fix replaces the regex check with the definitive roundtrip check the docs describe.

Fixes #1231.


This pull request was prepared with the assistance of AI, under my direction and review.

@henryiii

Copy link
Copy Markdown
Contributor

Won't this have a performance impact? Checking something is cheaper than modifying a string, I'd expect? I'll trigger the CI and see if we have a perf test for this.

@binggao1230

binggao1230 commented Jun 25, 2026 •

Copy link
Copy Markdown
Contributor Author

Pushed eab1122 to address the lint failure from tests/test_utils.py formatting.

Local verification:

python -m pytest tests/test_utils.py -q
uvx nox -s lint -- --show-diff-on-failure

@binggao1230

Copy link
Copy Markdown
Contributor Author

Good point. I pushed b2988d8 to avoid calling canonicalize_name() from is_normalized_name().

The implementation now checks the same roundtrip conditions directly: already lowercase, no _, no ., and no repeated --. That keeps the leading/trailing hyphen behavior covered by the new tests, while avoiding the extra replace() calls and condensation loop for names that are already normalized.

Local verification:

  • python -m pytest tests/test_utils.py tests/test_requirements.py -q
  • uvx nox -s lint -- --show-diff-on-failure

Comment thread src/packaging/utils.py Outdated

@henryiii henryiii left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Though this worries me: the standard clearly indicate the current regex is correct, leading and trailing hyphens are not allowed - that's from https://peps.python.org/pep-0508/#names. The normalization spec was a different PEP: https://peps.python.org/pep-0503/#normalized-names and it does allow leading/trailing hyphens, though I think it's more likely that's an oversight. There are now consolidated into a single place:

https://packaging.python.org/en/latest/specifications/name-normalization (edited to be the correct link now!)

Where you can see the "contradicting" rules next to teach other. Packaging currently implements exactly those rules, though.

(And, in practice, it means you can't put any name in to normalize_name and get a normalized version - leading/trailing symbols break, though the stated guarantee that these are related is currently wrong)

Comment thread src/packaging/utils.py Outdated
@binggao1230

Copy link
Copy Markdown
Contributor Author

Good point — you're right, and I think it reframes this PR.

Since PEP 508 names must start and end with an alphanumeric, -foo/foo- aren't valid names to begin with. Over the domain of valid names, canonicalize_name never yields a leading/trailing hyphen, so the original [a-z0-9]+(?:-[a-z0-9]+)* (with re.ASCII) already matches exactly the set of canonicalize_name fixed points — and it rejects non-ASCII for free. The edge cases I added only differ for inputs that aren't valid names, where the roundtrip "guarantee" is moot anyway.

So I'd rather drop the behavior change: keep the original regex, and instead just fix the docstring, which currently states the roundtrip relationship without noting it only holds for valid names. Happy to reduce this PR to that (revert the regex + the edge-hyphen test, tighten the docstring), or to close it if you'd prefer to touch the docstring separately. Which would you like?

@henryiii

Copy link
Copy Markdown
Contributor

I think fixing the docstring is the right path forward for now. We should look into changing the standards first if we do want the round trip property.

…d names

is_normalized_name documented itself as 'canonicalize_name roundtrips to
the same value', but canonicalize_name does not enforce that a name starts
and ends with an alphanumeric, so a name like '-not-legal' roundtrips
without being a normalized (valid) name. Note the valid-name caveat and
add doctests showing the leading-hyphen case.
@binggao1230
binggao1230 force-pushed the fix/is-normalized-name-edge-hyphens branch from 04edeff to 2d7c2fa Compare July 10, 2026 15:50
@binggao1230

Copy link
Copy Markdown
Contributor Author

Done — reduced to the docstring change. 2d7c2fa rebases onto current main and drops the regex change and the tests I'd added, so is_normalized_name behaviour is untouched. The docstring now notes the roundtrip only characterizes normalized names for valid names, with doctests for the leading-hyphen case:

>>> canonicalize_name("_not_legal")
'-not-legal'
>>> is_normalized_name("-not-legal")  # roundtrips, but not a valid name
False

Diff is src/packaging/utils.py only (+12/-3); doctests and ruff pass.

@henryiii henryiii changed the title fix(utils): align is_normalized_name with canonicalize_name roundtrip contract docs(utils): properly document is_normalized_name vs canonicalize_name Jul 10, 2026
@henryiii
henryiii requested a review from brettcannon July 10, 2026 18:27
@brettcannon

Copy link
Copy Markdown
Member

Though this worries me: the standard clearly indicate the current regex is correct, leading and trailing hyphens are not allowed - that's from https://peps.python.org/pep-0508/#names. The normalization spec was a different PEP: https://peps.python.org/pep-0503/#normalized-names and it does allow leading/trailing hyphens, though I think it's more likely that's an oversight. There are now consolidated into a single place:

https://peps.python.org/pep-0508/#names

Where you can see the "contradicting" rules next to teach other. Packaging currently implements exactly those rules, though.

(And, in practice, it means you can't put any name in to normalize_name and get a normalized version - leading/trailing symbols break, though the stated guarantee that these are related is currently wrong)

What does packaging.python.org say, as that's the official spec.

@henryiii

Copy link
Copy Markdown
Contributor

Ahh, sorry, I copied and pasted incorrectly. It was supposed to be:

There are now consolidated into a single place:

https://packaging.python.org/en/latest/specifications/name-normalization

Instead I linked to https://peps.python.org/pep-0508/#names again, sorry! They sit next to each other there.

@henryiii
henryiii dismissed brettcannon’s stale review July 20, 2026 04:55

This is now docs only.

@henryiii
henryiii merged commit 9c6f09b into pypa:main Jul 20, 2026
69 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

is_normalized_name() rejects some names that canonicalize_name() leaves unchanged (leading/trailing hyphens)

3 participants