From 4b309eaea808a181638aedcf6ba0d10d4e041dd6 Mon Sep 17 00:00:00 2001 From: 72004 Date: Tue, 20 Oct 2026 01:19:56 +0500 Subject: [PATCH] Match relative patterns from the right in UPath.match UPath.match delegated to full_match, so a relative pattern had to cover the whole path. pathlib.PurePath.match matches relative patterns from the right, so UPath('s3://bucket/x/a/b').match('a/b') returned False where PurePosixPath('/x/a/b').match('a/b') returns True. --- upath/core.py | 17 ++++++++++++++++- upath/tests/cases.py | 10 ++++++++++ upath/tests/implementations/test_data.py | 5 +++++ 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/upath/core.py b/upath/core.py index 90ebac60..5c25be62 100644 --- a/upath/core.py +++ b/upath/core.py @@ -10,6 +10,7 @@ from collections.abc import Mapping from collections.abc import Sequence from copy import copy +from fnmatch import fnmatchcase from pathlib import PurePath from types import MappingProxyType from typing import IO @@ -2208,7 +2209,21 @@ def match( UserWarning, stacklevel=2, ) - return self.full_match(path_pattern.replace("**", "*")) + path_pattern = path_pattern.replace("**", "*") + if self.full_match(path_pattern): + return True + # Like pathlib, a relative pattern is matched from the right, so + # UPath("s3://bucket/a/b").match("b") is True. + if path_pattern.startswith("/") or "://" in path_pattern: + return False + pattern_parts = path_pattern.rstrip("/").split("/") + path_parts = self.parts[1:] if self.anchor else self.parts + if len(pattern_parts) > len(path_parts): + return False + return all( + fnmatchcase(part, pat) + for part, pat in zip(reversed(path_parts), reversed(pattern_parts)) + ) @classmethod def __get_pydantic_core_schema__( diff --git a/upath/tests/cases.py b/upath/tests/cases.py index 22587fa9..62632d99 100644 --- a/upath/tests/cases.py +++ b/upath/tests/cases.py @@ -209,6 +209,16 @@ def test_full_match(self): assert p.full_match("**/*.txt") is True assert p.full_match("*.doesnotexist") is False + def test_match(self): + p = self.path / "folder" / "file.txt" + # relative patterns match from the right, like pathlib + assert p.match("file.txt") is True + assert p.match("*.txt") is True + assert p.match("folder/*.txt") is True + assert p.match("*/file.txt") is True + assert p.match("other/*.txt") is False + assert p.match("*.doesnotexist") is False + def test_trailing_slash_joinpath_is_identical(self): # setup cls = type(self.path) diff --git a/upath/tests/implementations/test_data.py b/upath/tests/implementations/test_data.py index d478559f..a3ab0124 100644 --- a/upath/tests/implementations/test_data.py +++ b/upath/tests/implementations/test_data.py @@ -117,6 +117,11 @@ def test_full_match(self): assert self.path.full_match("*") assert not self.path.full_match("xxx") + @overrides_base + def test_match(self): + assert self.path.match("*") + assert not self.path.match("xxx") + @overrides_base def test_trailing_slash_joinpath_is_identical(self): # DataPath has no slashes, and is not joinable