diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9267b66..141ec18 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,7 @@ jobs: - uses: actions/setup-python@v5 with: - python-version: "3.12" + python-version: "3.13" cache: pip cache-dependency-path: | setup.py @@ -62,7 +62,7 @@ jobs: - uses: actions/setup-python@v5 with: - python-version: "3.12" + python-version: "3.13" cache: pip cache-dependency-path: | setup.py @@ -88,7 +88,7 @@ jobs: - uses: actions/setup-python@v5 with: - python-version: "3.12" + python-version: "3.13" cache: pip cache-dependency-path: | setup.py diff --git a/.github/workflows/on_push_default_branch.yml b/.github/workflows/on_push_default_branch.yml index d0dcf00..1fc76c8 100644 --- a/.github/workflows/on_push_default_branch.yml +++ b/.github/workflows/on_push_default_branch.yml @@ -27,7 +27,7 @@ jobs: - uses: actions/setup-python@v5 with: - python-version: "3.12" + python-version: "3.13" cache: pip cache-dependency-path: | setup.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 052d731..bdcb445 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,7 +21,7 @@ jobs: - uses: actions/setup-python@v5 with: - python-version: "3.12" + python-version: "3.13" cache: pip cache-dependency-path: | setup.py diff --git a/Makefile b/Makefile index 766c8ce..0231b82 100644 --- a/Makefile +++ b/Makefile @@ -9,6 +9,7 @@ LAST_UPDATE_YEAR := $(shell git log -1 --format=%cd --date=format:%Y) $(BIN_CHANGELOG_FROM_RELEASE): + mkdir -p $(BIN_DIR) GOBIN=$(BIN_DIR) go install github.com/rhysd/changelog-from-release/v3@latest .PHONY: build diff --git a/docs/pages/reference/index.rst b/docs/pages/reference/index.rst index 9a5cdff..6b11def 100644 --- a/docs/pages/reference/index.rst +++ b/docs/pages/reference/index.rst @@ -8,3 +8,4 @@ Reference function types handler + tips diff --git a/docs/pages/reference/tips.rst b/docs/pages/reference/tips.rst new file mode 100644 index 0000000..d51a246 --- /dev/null +++ b/docs/pages/reference/tips.rst @@ -0,0 +1,53 @@ +Tips +------------ + +Sanitize dot-files or dot-directories +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +When you process filenames or filepaths containing ``.`` or ``..`` with the ``sanitize_filename`` function or the ``sanitize_filepath`` function, by default, ``sanitize_filename`` does nothing, and ``sanitize_filepath`` normalizes the filepaths: + +.. code-block:: python + + print(sanitize_filename(".")) + print(sanitize_filepath("hoge/./foo")) + +.. code-block:: console + + . + hoge/foo + +If you would like to replace ``.`` and ``..`` like other reserved words, you need to specify the arguments as follows: + +.. code-block:: python + + from pathvalidate import sanitize_filepath, sanitize_filename + from pathvalidate.error import ValidationError + + + def always_add_trailing_underscore(e: ValidationError) -> str: + if e.reusable_name: + return e.reserved_name + + return f"{e.reserved_name}_" + + + print( + sanitize_filename( + ".", + reserved_name_handler=always_add_trailing_underscore, + additional_reserved_names=[".", ".."], + ) + ) + + print( + sanitize_filepath( + "hoge/./foo", + normalize=False, + reserved_name_handler=always_add_trailing_underscore, + additional_reserved_names=[".", ".."], + ) + ) + +.. code-block:: console + + ._ + hoge/._/foo diff --git a/pathvalidate/_base.py b/pathvalidate/_base.py index 7555204..c74e842 100644 --- a/pathvalidate/_base.py +++ b/pathvalidate/_base.py @@ -140,7 +140,7 @@ def is_valid(self, value: PathType) -> bool: return True def _is_reserved_keyword(self, value: str) -> bool: - return value in self.reserved_keywords + return value.upper() in self.reserved_keywords class AbstractSanitizer(BaseFile, metaclass=abc.ABCMeta): @@ -183,6 +183,7 @@ def sanitize(self, value: PathType, replacement_text: str = "") -> PathType: # class BaseValidator(AbstractValidator): __RE_ROOT_NAME: Final = re.compile(r"([^\.]+)") + __RE_REPEAD_DOT: Final = re.compile(r"^\.{3,}") @property def min_len(self) -> int: @@ -218,17 +219,16 @@ def _validate_reserved_keywords(self, name: str) -> None: return root_name = self.__extract_root_name(name) - base_name = os.path.basename(name).upper() - - if self._is_reserved_keyword(root_name.upper()) or self._is_reserved_keyword( - base_name.upper() - ): - raise ReservedNameError( - f"'{root_name}' is a reserved name", - reusable_name=False, - reserved_name=root_name, - platform=self.platform, - ) + base_name = os.path.basename(name) + + for name in (root_name, base_name): + if self._is_reserved_keyword(name): + raise ReservedNameError( + f"'{root_name}' is a reserved name", + reusable_name=False, + reserved_name=root_name, + platform=self.platform, + ) def _validate_max_len(self) -> None: if self.max_len < 1: @@ -239,6 +239,12 @@ def _validate_max_len(self) -> None: @classmethod def __extract_root_name(cls, path: str) -> str: + if path in (".", ".."): + return path + + if cls.__RE_REPEAD_DOT.search(path): + return path + match = cls.__RE_ROOT_NAME.match(os.path.basename(path)) if match is None: return "" diff --git a/pathvalidate/_filename.py b/pathvalidate/_filename.py index 988cc80..c2e8dc8 100644 --- a/pathvalidate/_filename.py +++ b/pathvalidate/_filename.py @@ -302,8 +302,8 @@ def validate_filename( Defaults to ``255``. fs_encoding: - Filesystem encoding that used to calculate the byte length of the filename. - If |None|, get the value from the execution environment. + Filesystem encoding that is used to calculate the byte length of the filename. + If |None|, get the encoding from the execution environment. check_reserved: If |True|, check the reserved names of the ``platform``. additional_reserved_names: @@ -414,8 +414,8 @@ def sanitize_filename( Truncate the name length if the ``filename`` length exceeds this value. Defaults to ``255``. fs_encoding: - Filesystem encoding that used to calculate the byte length of the filename. - If |None|, get the value from the execution environment. + Filesystem encoding that is used to calculate the byte length of the filename. + If |None|, get the encoding from the execution environment. check_reserved: [Deprecated] Use 'reserved_name_handler' instead. null_value_handler: diff --git a/pathvalidate/_filepath.py b/pathvalidate/_filepath.py index c7064c1..2e83d42 100644 --- a/pathvalidate/_filepath.py +++ b/pathvalidate/_filepath.py @@ -337,8 +337,8 @@ def validate_filepath( - ``Windows``: 260 - ``universal``: 260 fs_encoding (Optional[str], optional): - Filesystem encoding that used to calculate the byte length of the file path. - If |None|, get the value from the execution environment. + Filesystem encoding that is used to calculate the byte length of the file path. + If |None|, get the encoding from the execution environment. check_reserved (bool, optional): If |True|, check the reserved names of the ``platform``. Defaults to |True|. @@ -455,8 +455,8 @@ def sanitize_filepath( - ``Windows``: 260 - ``universal``: 260 fs_encoding: - Filesystem encoding that used to calculate the byte length of the file path. - If |None|, get the value from the execution environment. + Filesystem encoding that is used to calculate the byte length of the file path. + If |None|, get the encoding from the execution environment. check_reserved: [Deprecated] Use 'reserved_name_handler' instead. null_value_handler: diff --git a/test/test_filename.py b/test/test_filename.py index f5435c5..7a2126c 100644 --- a/test/test_filename.py +++ b/test/test_filename.py @@ -405,6 +405,8 @@ def test_reserved_name(self, value, platform, expected): ["Abc", ["abc"], False], ["ABC", ["abc"], False], ["abc.txt", ["abc.txt"], False], + [".", [".", ".."], False], + ["..", [".", ".."], False], ], ) def test_normal_additional_reserved_names(self, value, arn, expected): @@ -654,6 +656,35 @@ def test_normal_reserved_name_handler(self, value, reserved_name_handler, expect == expected ) + @pytest.mark.parametrize( + ["value", "test_platform", "arn", "expected"], + [ + [".", "windows", [".", ".."], "._"], + [".", "universal", [".", ".."], "._"], + ["..", "windows", [".", ".."], ".._"], + ["..", "universal", [".", ".."], ".._"], + ["...", "linux", [".", ".."], "..."], + ], + ) + def test_normal_custom_reserved_name_handler_for_dot_files( + self, value, test_platform, arn, expected + ): + def always_add_trailing_underscore(e: ValidationError) -> str: + if e.reusable_name: + return e.reserved_name + + return f"{e.reserved_name}_" + + assert ( + sanitize_filename( + value, + platform=test_platform, + reserved_name_handler=always_add_trailing_underscore, + additional_reserved_names=arn, + ) + == expected + ) + def test_exception_reserved_name_handler(self): for platform in ["windows", "universal"]: with pytest.raises(ValidationError) as e: @@ -676,7 +707,7 @@ def test_normal_additional_reserved_names(self, value, arn, expected): additional_reserved_names=arn, ) == expected - ) + ), platform @pytest.mark.parametrize( ["value", "check_reserved", "expected"], @@ -709,6 +740,7 @@ def test_normal_check_reserved(self, value, check_reserved, expected): ["linux", "period.", "period."], ["linux", "space ", "space "], ["linux", "space_and_period. ", "space_and_period. "], + ["linux", "...", "..."], ["universal", "period.", "period"], ["universal", "space ", "space"], ["universal", "space_and_period .", "space_and_period"], diff --git a/test/test_filepath.py b/test/test_filepath.py index 8d91157..ed35901 100644 --- a/test/test_filepath.py +++ b/test/test_filepath.py @@ -701,7 +701,9 @@ def test_normal_reserved_name(self, value, test_platform, expected): ["value", "reserved_name_handler", "expected"], [ ["CON", ReservedNameHandler.add_trailing_underscore, "CON_"], + ["hoge/CON", ReservedNameHandler.add_trailing_underscore, "hoge\\CON_"], ["CON", ReservedNameHandler.add_leading_underscore, "_CON"], + ["hoge/CON", ReservedNameHandler.add_leading_underscore, "hoge\\_CON"], ["CON", ReservedNameHandler.as_is, "CON"], ], ) @@ -713,6 +715,32 @@ def test_normal_reserved_name_handler(self, value, reserved_name_handler, expect == expected ) + @pytest.mark.parametrize( + ["value", "expected"], + [ + ["hoge/.", "hoge\\._"], + ["hoge/./foo", "hoge\\._\\foo"], + ["hoge/..", "hoge\\.._"], + ], + ) + def test_normal_custom_reserved_name_handler_for_dot_files(self, value, expected): + def always_add_trailing_underscore(e: ValidationError) -> str: + if e.reusable_name: + return e.reserved_name + + return f"{e.reserved_name}_" + + assert ( + sanitize_filepath( + value, + platform="windows", + reserved_name_handler=always_add_trailing_underscore, + additional_reserved_names=[".", ".."], + normalize=False, + ) + == expected + ) + def test_exception_reserved_name_handler(self): for platform in ["windows", "universal"]: with pytest.raises(ValidationError) as e: