Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
27 changes: 22 additions & 5 deletions pathvalidate/_filename.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,11 +83,7 @@ def sanitize(self, value: PathType, replacement_text: str = "") -> PathType:
self._validator.validate(sanitized_filename)
except ValidationError as e:
if e.reason == ErrorReason.RESERVED_NAME:
replacement_word = self._reserved_name_handler(e)
if e.reserved_name != replacement_word:
sanitized_filename = re.sub(
re.escape(e.reserved_name), replacement_word, sanitized_filename
)
sanitized_filename = self.__replace_reserved_name(sanitized_filename, e)
elif e.reason == ErrorReason.INVALID_CHARACTER and self._is_windows(
include_universal=True
):
Expand All @@ -98,6 +94,16 @@ def sanitize(self, value: PathType, replacement_text: str = "") -> PathType:
sanitized_filename = sanitized_filename.rstrip(" ")
if sanitized_filename not in (".", ".."):
sanitized_filename = sanitized_filename.rstrip(" .")

# Trimming can expose a reserved name. Keep the first validation above
# so that custom reserved names containing edge spaces still match.
try:
self._validator.validate(sanitized_filename)
except ValidationError as trimmed_error:
if trimmed_error.reason == ErrorReason.RESERVED_NAME:
sanitized_filename = self.__replace_reserved_name(
sanitized_filename, trimmed_error
)
elif e.reason == ErrorReason.NULL_NAME:
sanitized_filename = self._null_value_handler(e)

Expand All @@ -116,6 +122,13 @@ def sanitize(self, value: PathType, replacement_text: str = "") -> PathType:

return sanitized_filename # type: ignore

def __replace_reserved_name(self, filename: str, error: ValidationError) -> str:
replacement_word = self._reserved_name_handler(error)
if error.reserved_name == replacement_word:
return filename

return re.sub(re.escape(error.reserved_name), replacement_word, filename)

def _get_sanitize_regexp(self) -> Pattern[str]:
if self._is_windows(include_universal=True):
return _RE_INVALID_WIN_FILENAME
Expand Down Expand Up @@ -432,6 +445,10 @@ def sanitize_filename(
Defaults to :py:func:`.handler.NullValueHandler.return_null_string` that just return ``""``.
reserved_name_handler:
Function called when a value after sanitization is a reserved name.
On Windows and universal platforms, names exposed by removing leading
spaces or trailing spaces/periods are also checked. Custom reserved
names are checked before this removal to preserve matches containing
edge spaces.
You can specify predefined handlers:

- :py:meth:`~.handler.ReservedNameHandler.add_leading_underscore`
Expand Down
3 changes: 3 additions & 0 deletions pathvalidate/_filepath.py
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,9 @@ def sanitize_filepath(
Defaults to :py:func:`.handler.NullValueHandler.return_null_string` that just return ``""``.
reserved_name_handler:
Function called when a value after sanitization is one of the reserved names.
Each component is also checked for reserved names exposed by removing
Windows/universal leading spaces or trailing spaces/periods. Custom
reserved names containing edge spaces are checked before this removal.
You can specify predefined handlers:

- :py:meth:`~.handler.ReservedNameHandler.add_leading_underscore`
Expand Down
66 changes: 64 additions & 2 deletions test/test_filename.py
Original file line number Diff line number Diff line change
Expand Up @@ -639,12 +639,61 @@ def test_normal_reserved_name(self, value, test_platform, expected):
assert filename == expected
assert is_valid_filename(filename, platform=test_platform)

@pytest.mark.parametrize("platform", ["windows", "universal"])
@pytest.mark.parametrize("pathtype", [str, Path])
@pytest.mark.parametrize(
["value", "expected"],
[
[" AUX", "AUX_"],
[" NUL.txt", "NUL_.txt"],
["AUX ", "AUX_"],
[" AUX. ", "AUX_"],
[" com1.tar.gz ", "com1_.tar.gz"],
],
)
def test_normal_reserved_name_after_trim(self, platform, pathtype, value, expected):
filename = sanitize_filename(pathtype(value), platform=platform)
assert filename == pathtype(expected)
assert isinstance(filename, pathtype)
assert is_valid_filename(filename, platform=platform)
assert (
sanitize_filename(pathtype(value), platform=platform, validate_after_sanitize=True)
== filename
)

@pytest.mark.parametrize("platform", ["windows", "universal"])
@pytest.mark.parametrize(
["value", "reserved_name"],
[[" CUSTOM", " CUSTOM"], ["CUSTOM ", "CUSTOM "], [" CUSTOM ", "CUSTOM"]],
)
def test_normal_custom_reserved_name_with_edge_spaces(self, platform, value, reserved_name):
calls = []

def handler(error):
calls.append(error.reserved_name)
return "CUSTOM_"

assert (
sanitize_filename(
value,
platform=platform,
additional_reserved_names=[reserved_name],
reserved_name_handler=handler,
validate_after_sanitize=True,
)
== "CUSTOM_"
)
assert calls == [reserved_name]

@pytest.mark.parametrize(
["value", "reserved_name_handler", "expected"],
[
["CON", ReservedNameHandler.add_trailing_underscore, "CON_"],
["CON", ReservedNameHandler.add_leading_underscore, "_CON"],
["CON", ReservedNameHandler.as_is, "CON"],
[" CON", ReservedNameHandler.add_trailing_underscore, "CON_"],
[" CON", ReservedNameHandler.add_leading_underscore, "_CON"],
[" CON", ReservedNameHandler.as_is, "CON"],
],
)
def test_normal_reserved_name_handler(self, value, reserved_name_handler, expected):
Expand Down Expand Up @@ -685,12 +734,24 @@ def always_add_trailing_underscore(e: ValidationError) -> str:
== expected
)

def test_exception_reserved_name_handler(self):
@pytest.mark.parametrize("value", ["CON", " CON"])
def test_exception_reserved_name_handler(self, value):
for platform in ["windows", "universal"]:
with pytest.raises(ValidationError) as e:
sanitize_filename("CON", platform=platform, reserved_name_handler=raise_error)
sanitize_filename(value, platform=platform, reserved_name_handler=raise_error)
assert e.value.reason == ErrorReason.RESERVED_NAME

@pytest.mark.parametrize("platform", ["windows", "universal"])
def test_exception_reserved_name_after_trim_as_is(self, platform):
with pytest.raises(ValidationError) as e:
sanitize_filename(
" CON",
platform=platform,
reserved_name_handler=ReservedNameHandler.as_is,
validate_after_sanitize=True,
)
assert e.value.reason == ErrorReason.INVALID_AFTER_SANITIZE

@pytest.mark.parametrize(
["value", "arn", "expected"],
[
Expand Down Expand Up @@ -739,6 +800,7 @@ def test_normal_check_reserved(self, value, check_reserved, expected):
["windows", ".. ", ".."],
["linux", "period.", "period."],
["linux", "space ", "space "],
["linux", " AUX", " AUX"],
["linux", "space_and_period. ", "space_and_period. "],
["linux", "...", "..."],
["universal", "period.", "period"],
Expand Down
55 changes: 41 additions & 14 deletions test/test_filepath.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@

import platform as m_platform
import random
import sys
from collections import OrderedDict
from itertools import chain, product
from pathlib import Path
Expand Down Expand Up @@ -705,6 +704,8 @@ def test_normal_reserved_name(self, value, test_platform, expected):
["CON", ReservedNameHandler.add_leading_underscore, "_CON"],
["hoge/CON", ReservedNameHandler.add_leading_underscore, "hoge\\_CON"],
["CON", ReservedNameHandler.as_is, "CON"],
["hoge/ CON", ReservedNameHandler.add_leading_underscore, "hoge\\_CON"],
["hoge/ CON", ReservedNameHandler.as_is, "hoge\\CON"],
],
)
def test_normal_reserved_name_handler(self, value, reserved_name_handler, expected):
Expand Down Expand Up @@ -741,10 +742,11 @@ def always_add_trailing_underscore(e: ValidationError) -> str:
== expected
)

def test_exception_reserved_name_handler(self):
@pytest.mark.parametrize("value", ["CON", "parent/ CON"])
def test_exception_reserved_name_handler(self, value):
for platform in ["windows", "universal"]:
with pytest.raises(ValidationError) as e:
sanitize_filepath("CON", platform=platform, reserved_name_handler=raise_error)
sanitize_filepath(value, platform=platform, reserved_name_handler=raise_error)
assert e.value.reason == ErrorReason.RESERVED_NAME

@pytest.mark.parametrize(
Expand Down Expand Up @@ -776,6 +778,25 @@ def test_normal_additional_reserved_names(self, value, arn, expected):
== expected
)

@pytest.mark.parametrize("platform", ["windows", "universal"])
@pytest.mark.parametrize("reserved_name", [" CUSTOM", "CUSTOM"])
def test_normal_custom_reserved_component_with_edge_spaces(self, platform, reserved_name):
calls = []

def handler(error):
calls.append(error.reserved_name)
return "CUSTOM_"

separator = "\\" if platform == "windows" else "/"
assert sanitize_filepath(
"parent/ CUSTOM",
platform=platform,
additional_reserved_names=[reserved_name],
reserved_name_handler=handler,
validate_after_sanitize=True,
) == separator.join(["parent", "CUSTOM_"])
assert calls == [reserved_name]

@pytest.mark.parametrize(
["value", "replace_text", "expected"],
[
Expand Down Expand Up @@ -926,22 +947,28 @@ def test_auto_platform_linux(self, value, expected):
sanitize_filepath(value, **kwargs)
assert e.value.reason == ErrorReason.MALFORMED_ABS_PATH

@pytest.mark.parametrize("platform", ["windows", "universal"])
@pytest.mark.parametrize("pathtype", [str, Path])
@pytest.mark.parametrize(
["platform", "value"],
["value", "expected"],
[
["windows", "CON \r"],
["CON \r", "CON_"],
[" AUX", "AUX_"],
[" NUL.txt", "NUL_.txt"],
["parent/ AUX/ NUL.txt", "parent/AUX_/NUL_.txt"],
],
)
def test_exception_invalid_after_sanitize(self, platform, value):
print(
"'{}'".format(
sanitize_filepath(value, platform=platform, validate_after_sanitize=False)
),
file=sys.stderr,
def test_normal_reserved_components_after_trim(self, platform, pathtype, value, expected):
if platform == "windows":
expected = expected.replace("/", "\\")
result = sanitize_filepath(pathtype(value), platform=platform)
assert result == pathtype(expected)
assert isinstance(result, pathtype)
assert is_valid_filepath(result, platform=platform)
assert (
sanitize_filepath(pathtype(value), platform=platform, validate_after_sanitize=True)
== result
)
with pytest.raises(ValidationError) as e:
sanitize_filepath(value, platform=platform, validate_after_sanitize=True)
assert e.value.reason == ErrorReason.INVALID_AFTER_SANITIZE

@pytest.mark.parametrize(
["value", "expected"],
Expand Down