Skip to content

feat: filenames - #1113

Draft
henryiii wants to merge 53 commits into
pypa:mainfrom
henryiii:henryiii/feat/filenames
Draft

henryiii wants to merge 53 commits into
pypa:mainfrom
henryiii:henryiii/feat/filenames

Conversation

@henryiii

@henryiii henryiii commented Mar 10, 2026 •

Copy link
Copy Markdown
Contributor

This builds on #409 and #1009 to add a packaging.filenames module.

Compared to #1009, this:

  • Avoids inheritance and removes the Filename.from_filename method, which isn't usable without manual checking anyway due to not knowing which type is produced.
  • strict= is a (required) argument of .from_filename now. Most logic is there.
  • Classic functions use this new infrastructure
  • Utility functions related to names are moved, but re-exported for back-compat
  • Docs added
  • underscore=True option added to canonicalize_name, better perf than just replacing - with _ afterwards

Decisions to be made or validated:

  • With strict=True, maybe we should require sorted tag sets? (I think so, based on todo in [DRAFT] Add packaging.filenames API #1009)
  • I made .name the traditionally canonical name, rather than the underscore one, I think that's best?
  • The classic (strict=False) method checks for __ for some reason. It's the only one it doesn't normalize. Maybe that should be pulled out to just the classic function?
  • We don't need one that strips final 0's from versions, right?

Todos:

  • Enforce compressed tag set ordering with strict=True
  • General alignment w/ error messages vs. what PyPI uses
  • Fill out tests to handle 100% coverage

Closes:

AI usage disclaimer: I used copilot in VSCode to help fill out the coverage in 997c5a8, and some help with docs before that.

@henryiii
henryiii force-pushed the henryiii/feat/filenames branch 2 times, most recently from 8d36258 to 0e8b7b5 Compare March 10, 2026 19:02
Comment thread src/packaging/filenames.py Outdated
@henryiii
henryiii force-pushed the henryiii/feat/filenames branch from 361d730 to 03ac2ae Compare March 12, 2026 13:02
@henryiii

Copy link
Copy Markdown
Contributor Author

@di, don't know how to do the "General alignment w/ error messages vs. what PyPI uses" step.

@di

di commented Mar 27, 2026

Copy link
Copy Markdown
Member

@di, don't know how to do the "General alignment w/ error messages vs. what PyPI uses" step.

So this was basically, packaging has one set of exception messages for when certain errors happen here, PyPI has a different, but similar set. Ideally we'd just be able to catch these errors and relay the message that originates from packaging, but they are not 1:1, so I was hoping to align them to simplify them.

@henryiii

henryiii commented Apr 7, 2026

Copy link
Copy Markdown
Contributor Author

I like the idea of waiting till Variants are ready to add this, so someone using the new API is also aware of variants. #1148.

e2thenegpii and others added 16 commits September 22, 2026 12:31
Signed-off-by: Henry Schreiner <henryfs@princeton.edu>

docs: cleanup

Signed-off-by: Henry Schreiner <henryfs@princeton.edu>
Signed-off-by: Henry Schreiner <henryfs@princeton.edu>
Signed-off-by: Henry Schreiner <henryfs@princeton.edu>
Signed-off-by: Henry Schreiner <henryfs@princeton.edu>
Signed-off-by: Henry Schreiner <henryfs@princeton.edu>
Signed-off-by: Henry Schreiner <henryfs@princeton.edu>
Signed-off-by: Henry Schreiner <henryfs@princeton.edu>
Signed-off-by: Henry Schreiner <henryfs@princeton.edu>
Signed-off-by: Henry Schreiner <henryfs@princeton.edu>
Signed-off-by: Henry Schreiner <henryfs@princeton.edu>
Signed-off-by: Henry Schreiner <henryfs@princeton.edu>
Signed-off-by: Henry Schreiner <henryfs@princeton.edu>
Signed-off-by: Henry Schreiner <henryschreineriii@gmail.com>
Carry the validate_order parameter, InvalidTag handling, the non-empty
project name checks, the tighter is_normalized_name regex, and the
version notes from utils.py into filenames.py after the rebase.

Assisted-by: ClaudeCode:claude-fable-5-1
ClaudeCode:claude-fable-5-1
@henryiii
henryiii force-pushed the henryiii/feat/filenames branch from 6625658 to 2df2e53 Compare September 22, 2026 16:35
The constructors now check the name, version, build tag, and variant
label, so to_filename() can no longer produce a filename that does not
parse. Non-strict from_filename still accepts legacy names.

__replace__ (used by copy.replace) checks only the replaced parts.

Assisted-by: ClaudeCode:claude-opus-5-5
The result was typed as NormalizedName but was not one. The filename
code now replaces the hyphens itself.

Assisted-by: ClaudeCode:claude-opus-5-5
Signed-off-by: Henry Schreiner <henryfs@princeton.edu>
The original strings were only partly kept, and equality already
ignored them. Remove original_name and original_version, and accept a
Version in the constructor and in __replace__.

Assisted-by: ClaudeCode:claude-opus-5-5
The base class was only used to share code. The classmethod helpers are
now module-level functions, and small helpers are inlined.

Assisted-by: ClaudeCode:claude-opus-5-5
Use copy.copy in __replace__, simplify the wheel name regex, and return
Self from from_filename. Merge overlapping tests into parametrized ones.

Assisted-by: ClaudeCode:claude-opus-5-5
Replace the strict and validate_order parameters of from_filename with
validate_wheel_filename, validate_sdist_filename, and
validate_ordered_tags. Parsing is now always lenient.

Assisted-by: ClaudeCode:claude-opus-5-5
ClaudeCode:claude-opus-5-5
Replace validate_ordered_tags with a distinct exception class per
normalization check. validate_wheel_filename and validate_sdist_filename
raise invalid filenames directly and collect normalization errors into an
ExceptionGroup, so callers can select checks with except*.

Assisted-by: ClaudeCode:claude-opus-5-5
Split wheel and sdist filenames once and build objects from the parts
via a private classmethod. Move the no-variant policy into the splitter
so parse_wheel_filename no longer reconstructs it. Share a _set helper
between __init__ and __replace__, and reuse _check_name in the
normalization check.

Assisted-by: ClaudeCode:claude-fable-5-1
The tags argument is now required and comes before build_tag. An empty
or non-compressible tag set is rejected at construction.

Assisted-by: ClaudeCode:claude-opus-5-5
Assisted-by: ClaudeCode:claude-opus-5-5
Assisted-by: ClaudeCode:claude-opus-5-5
Assisted-by: ClaudeCode:claude-opus-5-5
Assisted-by: ClaudeCode:claude-opus-5-5
Assisted-by: ClaudeCode:claude-opus-5-5
Assisted-by: ClaudeCode:claude-opus-5-5
Assisted-by: ClaudeCode:claude-opus-5-5
Assisted-by: ClaudeCode:claude-opus-5-5
Assisted-by: ClaudeCode:claude-opus-5-5
Signed-off-by: Henry Schreiner <henryfs@princeton.edu>
Names that parsing accepts but that are not valid project names are now
reported as NonNormalizedName in the ExceptionGroup, and not raised as
InvalidFilename.

Assisted-by: ClaudeCode:claude-opus-5-5
Parse, validate, and the constructor now share one build tag regex, so
suffixes with spaces, newlines, or non-ASCII characters are rejected
everywhere, and a non-int build number is rejected in the constructor.
Add InvalidProjectName for names that parse but are not valid project
names, so NonNormalizedName only covers valid names. Parse tags once in
validate_wheel_filename, use type(self) in __setstate__, and document
the six-part filename ambiguity rule.

Assisted-by: ClaudeCode:claude-fable-5-1
copy.copy fell through to __getstate__ and __setstate__, so every
__replace__ call serialized and re-parsed the filename. The instances are
immutable, so __copy__ and __deepcopy__ return self, and __replace__
copies the slots directly.

Assisted-by: ClaudeCode:claude-fable-5-1
Split wheel filenames with maxsplit instead of splitting and joining all
parts, and return plain tuples instead of a NamedTuple. The classes wrap
tuple-returning parsers, so the legacy parse_*_filename functions no longer
build and unpack an object. The parsers live in utils, which removes the
function-local import (about 200 ns per call) and the circular import.

Assisted-by: ClaudeCode:claude-opus-5-5
…hods

Parsed filenames keep the original string, so validate() checks it
without parsing again. Instances that are constructed or made with
__replace__ have no original filename and always pass. Pickles keep the
original filename, so validation gives the same result after
unpickling.

Assisted-by: ClaudeCode:claude-opus-5-5
Also reuse the build_str and compressed_tags properties in validate.

Assisted-by: ClaudeCode:claude-opus-5-5
Add an original_filename property to WheelFilename and
SourceDistributionFilename. SourceDistributionFilename.validate() now
reports a .zip extension as InvalidSdistFilename inside the
ExceptionGroup, together with the other problems. Document all
validate() exception classes.

Assisted-by: ClaudeCode:claude-opus-5-5
Avoid the on_exit context manager, and check common normalized names,
versions, and tags without re-normalizing them.

Assisted-by: ClaudeCode:claude-opus-5-5
Also drop them from __match_args__.

Assisted-by: ClaudeCode:claude-opus-5-5
@henryiii

henryiii commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor Author

This is getting stable again, dropping my design points while working on it:

  • This isn't a dataclass; the dataclass version was quite a bit slower (10-30%)
  • I'm not fond of .validate() being called on an existing filename, but it solved several issues:
    • Keeping the filename (similar to the proposed Version change) means you can access the original file on disk without a seperate storage
    • Doing it this way saves a second parse; pretty much every other design, like a standalone validate_wheel_filename function, reparses
    • Keeping it out of the constructor means you can handle the errors and still access the normalized name without a second parse
  • parameter order: tags can't be optional (as it can't be empty), but build_tag is usually not given, so I've ordered it with build tags afterwards (vs. matchign the order in the wheel filename). I made build_tag and variant keyword only to reduce confusion.
  • Pickles are the original string, not as fast but very easy to keep stable. copy/deepcopy don't need to do anything since it's immutable.
  • Exception groups allow .validate() to not have any parameters, and instead the user can see what's wrong and skip stuff they don't care about (it's possible to rebuild the original less strict behavior for example)
  • I didn't set up a shared base class because the code savings wasn't worth it for just two classes, IMO. But it's mostly a matter of taste, I could be convinced to go with a shared base class.
  • We lose a little performance (7-8%), but I don't think it's avoidable, as the ambiguous parse from variant or build_tag for 6 element wheel names costs a little. I've tried to keep it minimal.

Performance:

🤖 AI text below 🤖

Each value is in µs per filename, averaged over two runs.

Operation 3.12 main 3.12 branch 3.14 main 3.14 branch
parse_wheel_filename 1.33 1.42 (+7%) 1.23 1.33 (+8%)
parse_sdist_filename 0.46 0.48 0.41 0.43
WheelFilename.from_filename — 1.45 — 1.37
WheelFilename.from_filename + validate() — 2.76 (was 3.77) — 2.63 (was 3.61)
WheelFilename.to_filename — 0.78 — 0.77
SourceDistributionFilename.from_filename — 0.51 — 0.47
SourceDistributionFilename.from_filename + validate() — 0.97 (was 1.70) — 0.85 (was 1.55)
SourceDistributionFilename.to_filename — 0.32 — 0.30

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

4 participants