If a Python upgrade changes PurePath("library.").stem from "library." to "library", check the suffix rules before adding a workaround. Python 3.14 treats a single trailing dot as a suffix. That changes lexical operations involving stem, suffix, and with_suffix(). It does not mean Python renamed a file, converted its format, or proved that an operating system accepts that filename.
Compare the exact input under both versions
Start with a small, read-only fixture. Use PurePosixPath to make the path flavour explicit and keep the comparison independent of the host filesystem. This program checks the documented version boundary for a trailing dot while confirming that ordinary compound extensions and a leading-dot name keep their expected results on the tested runtimes.
import sys
from pathlib import PurePosixPath
p = PurePosixPath("library.")
new_rule = sys.version_info >= (3, 14)
assert p.stem == ("library" if new_rule else "library.")
assert p.suffix == ("." if new_rule else "")
assert p.with_suffix("").name == ("library" if new_rule else "library.")
assert p.with_suffix(".txt").name == (
"library.txt" if new_rule else "library..txt"
)
assert PurePosixPath("archive.tar.gz").stem == "archive.tar"
assert PurePosixPath("archive.tar.gz").suffix == ".gz"
assert PurePosixPath(".env").stem == ".env"
assert PurePosixPath(".env").suffix == ""
print(sys.version.split()[0], p.stem, repr(p.suffix))The fixture passed on CPython 3.11.6, 3.13.2, and 3.14.3 on macOS. Those runs establish the stated lexical examples, not every patch release or every filename. A public CPython report raised the trailing-dot stem difference during the 3.14 transition. Its documentation concern has been addressed; do not describe the current documented rule as a runtime bug awaiting repair.
When investigating your application, log repr(path.name), repr(path.stem), and repr(path.suffix) before and after the upgrade. A screenshot of a filename can hide the trailing character. Verify the actual input string and the path flavour rather than assuming the visible label is the exact value being parsed.
Check with_suffix separately
A second change affects the suffix argument itself. Before Python 3.14, a single dot passed to with_suffix() raises ValueError. Under the new rule it is accepted. An empty string still requests removal of the recognized suffix; it is not the same argument as a dot.
import sys
from pathlib import PurePosixPath
p = PurePosixPath("name.txt")
if sys.version_info >= (3, 14):
assert p.with_suffix(".").name == "name."
else:
try:
p.with_suffix(".")
except ValueError:
print("Single-dot suffix rejected on this version")
else:
raise AssertionError("Expected ValueError before Python 3.14")
assert p.with_suffix("").name == "name"
assert p.with_suffix(".csv").name == "name.csv"
assert p.name == "name.txt"Path objects are immutable: the last assertion confirms that the original object remains unchanged. These operations produce another path value. They do not call rename() or write anything to disk. Replacing .txt with .csv also does not transform the contents into CSV.
Choose a filename policy before normalizing
There are several legitimate application contracts. A catalogue can preserve the supplied name exactly. An exporter can reject trailing dots with a clear validation error. A format controlled by your application can remove exactly one final dot before deriving an output label. Pick a policy based on the format you own; silently normalizing arbitrary customer paths can create collisions or break references.
The following functions operate on strings representing a single name. They reject an empty value and the special dot names. The normalization function removes exactly one trailing character; it does not promise that the resulting name is portable, unique, or safe to create on a filesystem.
from pathlib import PurePosixPath
def check_name(name):
if not name or name in {".", ".."} or "/" in name or "\\" in name:
raise ValueError("Expected an ordinary single filename")
return name
def reject_trailing_dot(name):
name = check_name(name)
if name.endswith("."):
raise ValueError("Trailing dot is unsupported by this export")
return name
def remove_one_trailing_dot(name):
name = check_name(name)
result = name[:-1] if name.endswith(".") else name
return check_name(result)
assert remove_one_trailing_dot("library.") == "library"
assert remove_one_trailing_dot("library..") == "library."
assert remove_one_trailing_dot(".env") == ".env"
assert PurePosixPath(remove_one_trailing_dot("library.")).with_suffix(
".txt"
).name == "library.txt"
for value in ["library.", "", ".", ".."]:
try:
reject_trailing_dot(value)
except ValueError:
pass
else:
raise AssertionError(value)This is deliberately a limited export policy, not complete filename security validation. It does not cover reserved names, control characters, target-volume rules, length limits, Unicode equivalence, or collisions. Removing a dot can make two different input names map to the same result. Inspect the complete proposed mapping before enabling a batch rename; this guide performs no rename operation.
Avoid using a broad strip operation when the contract says to remove one character. A name ending in two dots should be a deliberate test case. If you need cross-version compatibility, apply the explicit input policy first and then run the same regression fixture under every supported interpreter.
A compound extension is a separate concern
PurePosixPath("archive.tar.gz").with_suffix(".zip") produces archive.tar.zip: only the final suffix is replaced. It does not remove all suffixes or unpack the archive. A leading-dot name such as .env has no suffix in these tested examples. Do not infer your content type from suffix parsing alone, and do not combine compound-extension handling with trailing-dot cleanup unless the output format explicitly requires both.
For deployment checks, include an ordinary filename, a compound extension, a leading-dot filename, one trailing dot, and repeated trailing dots. Assert the exact output string or exact expected error. If the change only affects display labels, verify the display label; if it affects generated storage paths, separately inspect collision handling and the actual storage system before writing data.
Separate pure path calculations from real filesystem behavior
PureWindowsPath and PurePosixPath let you calculate names without accessing a filesystem. Their availability on macOS does not prove that Windows will create, preserve, or reopen a trailing-dot filename. Likewise, a successful pure-path test says nothing about an existing symlink, access permission, or file content. Keep lexical compatibility tests read-only, and test actual file operations only within an authorized disposable directory on the target platform when that behavior matters.
The migration decision is therefore specific: acknowledge the 3.14 suffix rule, choose your application's trailing-dot policy, and compare exact values on the versions you support. Do not wait for a documented behavior change to be reverted, and do not turn a string-level fix into an unreviewed batch rename.