Skip to content

Commit ca21ffb

Browse files
authored
Merge pull request #165 from Erotemic/feat/relative-path-walk-up
add: support for new features in 3.12 pathlib
2 parents 51136ce + 2ad814a commit ca21ffb

File tree

5 files changed

+158
-12
lines changed

5 files changed

+158
-12
lines changed

.github/workflows/tests.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ jobs:
195195
arch: auto
196196
- python-version: '3.7'
197197
install-extras: tests,optional
198-
os: ubuntu-latest
198+
os: ubuntu-22.04
199199
arch: auto
200200
- python-version: '3.8'
201201
install-extras: tests,optional
@@ -442,7 +442,7 @@ jobs:
442442
$GPG_EXECUTABLE --list-keys || echo "first invocation of gpg creates directories and returns 1"
443443
$GPG_EXECUTABLE --list-keys
444444
VERSION=$(python -c "import setup; print(setup.VERSION)")
445-
pip install twine
445+
pip install twine packaging -U
446446
pip install urllib3 requests[security] twine
447447
GPG_KEYID=$(cat dev/public_gpg_key)
448448
echo "GPG_KEYID = '$GPG_KEYID'"
@@ -517,7 +517,7 @@ jobs:
517517
$GPG_EXECUTABLE --list-keys || echo "first invocation of gpg creates directories and returns 1"
518518
$GPG_EXECUTABLE --list-keys
519519
VERSION=$(python -c "import setup; print(setup.VERSION)")
520-
pip install twine
520+
pip install twine packaging -U
521521
pip install urllib3 requests[security] twine
522522
GPG_KEYID=$(cat dev/public_gpg_key)
523523
echo "GPG_KEYID = '$GPG_KEYID'"

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ This project (loosely) adheres to [Semantic Versioning](https://semver.org/spec/
66

77
## Version 1.3.8 - Unreleased
88

9+
### Added
10+
* Added: `ub.Path.relative_to` now backports the `walk_up` feature from Python 3.12
11+
12+
### Changed
13+
* `ub.Path.walk` now supports the same signature as the new version in Python 3.12
14+
915

1016
## Version 1.3.7 - Released 2024-12-06
1117

docs/source/conf.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ def visit_Assign(self, node):
136136
return visitor.version
137137

138138
project = 'ubelt'
139-
copyright = '2024, Jon Crall'
139+
copyright = '2025, Jon Crall'
140140
author = 'Jon Crall'
141141
modname = 'ubelt'
142142

tests/test_pathlib.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -540,3 +540,40 @@ def test_copy_dir_to_existing_dir_withconflict():
540540

541541
def _comparable_walk(p):
542542
return sorted([(tuple(sorted(f)), tuple(sorted(d))) for (r, f, d) in (p).walk()])
543+
544+
545+
def test_walk_compat_312():
546+
"""
547+
Test that our version works the same as the 3.12 version
548+
"""
549+
import sys
550+
if sys.version_info[0:2] < (3, 12):
551+
import pytest
552+
pytest.skip('only test on 3.12')
553+
554+
import ubelt as ub
555+
import pathlib
556+
557+
ours = ub.Path.appdir('ubelt/tests/ls')
558+
theirs = pathlib.Path(ours)
559+
560+
(ours / 'dir1').ensuredir()
561+
(ours / 'dir2').ensuredir()
562+
(ours / 'file1').touch()
563+
(ours / 'file2').touch()
564+
(ours / 'dir1/file3').touch()
565+
(ours / 'dir2/file4').touch()
566+
567+
subdirs1 = list(ours.walk())
568+
assert len(subdirs1) == 3
569+
570+
subdirs2 = list(theirs.walk())
571+
assert subdirs1 == subdirs2
572+
573+
574+
def test_walk_bad_kwargs():
575+
import ubelt as ub
576+
import pytest
577+
self = ub.Path('foo')
578+
with pytest.raises(TypeError):
579+
list(self.walk(does_not_exist=True))

ubelt/util_path.py

Lines changed: 111 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@
4848

4949
WIN32 = sys.platform.startswith('win32')
5050

51+
PYTHON_LT_3_8 = sys.version_info[0:2] < (3, 8)
52+
PYTHON_GE_3_12 = sys.version_info[0:2] >= (3, 12)
53+
5154

5255
def augpath(path, suffix='', prefix='', ext=None, tail='', base=None,
5356
dpath=None, relative=None, multidot=False):
@@ -1228,7 +1231,57 @@ def touch(self, mode=0o0666, exist_ok=True):
12281231
super().touch(mode=mode, exist_ok=exist_ok)
12291232
return self
12301233

1231-
def walk(self, topdown=True, onerror=None, followlinks=False):
1234+
def relative_to(self, *other, **kwargs):
1235+
"""
1236+
Return the relative path to another path identified by the passed
1237+
arguments. If the operation is not possible (because this is not a
1238+
subpath of the other path), raise ValueError.
1239+
1240+
Includes a Backport of :meth:`pathlib.Path.relative_to` with
1241+
``walk_up=True`` that's not available pre 3.12.
1242+
1243+
Args:
1244+
other (Path | str): the base path
1245+
1246+
walk_up (bool):
1247+
controls whether `..` may be used to resolve the path.
1248+
1249+
Returns:
1250+
Path: the new relative path
1251+
1252+
References:
1253+
https://stackoverflow.com/questions/38083555/using-pathlibs-relative-to-for-directories-on-the-same-level
1254+
https://github.com/p2p-ld/numpydantic/blob/66fffc49f87bfaaa2f4d05bf1730c343b10c9cc6/src/numpydantic/serialization.py#L107-L142
1255+
1256+
Example:
1257+
>>> import ubelt as ub
1258+
>>> import pytest
1259+
>>> self = ub.Path('foo/bar')
1260+
>>> other = ub.Path('foo/bar/baz')
1261+
>>> result = self.relative_to(other, walk_up=True)
1262+
>>> assert result == ub.Path('..')
1263+
>>> with pytest.raises(ValueError):
1264+
>>> self.relative_to(other)
1265+
>>> with pytest.raises(ValueError):
1266+
>>> self.relative_to(other, walk_up=False)
1267+
>>> with pytest.raises(TypeError):
1268+
>>> self.relative_to(other, not_a_kwarg=False)
1269+
"""
1270+
if PYTHON_GE_3_12:
1271+
return super().relative_to(*other, **kwargs)
1272+
else:
1273+
# Test to see if we need the backport
1274+
walk_up = kwargs.pop('walk_up', False)
1275+
if len(kwargs):
1276+
bad_key = list(kwargs)[0]
1277+
raise TypeError(f'{self.__class__.__name__}.relative_to() got an unexpected keyword argument {bad_key!r}')
1278+
if not walk_up:
1279+
return super().relative_to(*other, **kwargs)
1280+
else:
1281+
# Use the backport
1282+
return _relative_path_backport(self, other, walk_up=walk_up)
1283+
1284+
def walk(self, topdown=True, onerror=None, followlinks=False, **kwargs):
12321285
"""
12331286
A variant of :func:`os.walk` for pathlib
12341287
@@ -1244,6 +1297,11 @@ def walk(self, topdown=True, onerror=None, followlinks=False):
12441297
followlinks (bool):
12451298
if True recurse into symbolic directory links
12461299
1300+
**kwargs:
1301+
Accepts aliases the 3.12 version of the above names: top_down,
1302+
on_error, follow_symlinks. In the future we may switch the 3.12
1303+
variants to be the primary arguments.
1304+
12471305
Yields:
12481306
Tuple['Path', List[str], List[str]]:
12491307
the root path, directory names, and file names
@@ -1274,11 +1332,28 @@ def walk(self, topdown=True, onerror=None, followlinks=False):
12741332
>>> if 'CVS' in dirs:
12751333
>>> dirs.remove('CVS') # don't visit CVS directories
12761334
"""
1277-
cls = self.__class__
1278-
walker = os.walk(self, topdown=topdown, onerror=onerror,
1279-
followlinks=followlinks)
1280-
for root, dnames, fnames in walker:
1281-
yield (cls(root), dnames, fnames)
1335+
# Add kwargs to support ubelt original kwargs as well as pathlib kwargs
1336+
top_down = kwargs.pop('top_down', topdown)
1337+
on_error = kwargs.pop('on_error', onerror)
1338+
follow_symlinks = kwargs.pop('follow_symlinks', followlinks)
1339+
1340+
if len(kwargs):
1341+
bad_key = list(kwargs)[0]
1342+
raise TypeError(f'{self.__class__.__name__}.relative_to() got an unexpected keyword argument {bad_key!r}')
1343+
1344+
if PYTHON_GE_3_12:
1345+
# Use the parent implementation if available
1346+
yield from super().walk(
1347+
top_down=top_down, on_error=on_error,
1348+
follow_symlinks=follow_symlinks)
1349+
else:
1350+
# TODO: backport the 3.12 implementation, which is more efficient
1351+
# Our original implementation
1352+
cls = self.__class__
1353+
walker = os.walk(self, topdown=top_down, onerror=on_error,
1354+
followlinks=follow_symlinks)
1355+
for root, dnames, fnames in walker:
1356+
yield (cls(root), dnames, fnames)
12821357

12831358
def __add__(self, other):
12841359
"""
@@ -1565,7 +1640,7 @@ def copy(self, dst, follow_file_symlinks=False, follow_dir_symlinks=False,
15651640
_patch_win32_stats_on_pypy()
15661641

15671642
if self.is_dir():
1568-
if sys.version_info[0:2] < (3, 8): # nocover
1643+
if PYTHON_LT_3_8: # nocover
15691644
copytree = _compat_copytree
15701645
else:
15711646
copytree = shutil.copytree
@@ -1894,7 +1969,35 @@ def _patch_win32_stats_on_pypy():
18941969
stat.IO_REPARSE_TAG_SYMLINK = 0xa000000c # windows
18951970

18961971

1897-
if sys.version_info[0:2] < (3, 8): # nocover
1972+
def _relative_path_backport(self, other, walk_up=False):
1973+
if not isinstance(other, _PathBase):
1974+
other = type(self)(*other)
1975+
# other = self.with_segments(other)
1976+
# anchor0, parts0 = self._stack
1977+
# anchor1, parts1 = other._stack
1978+
self_parts = self.parts
1979+
other_parts = other.parts
1980+
anchor0, parts0 = self_parts[0], list(reversed(self_parts[1:]))
1981+
anchor1, parts1 = other_parts[0], list(reversed(other_parts[1:]))
1982+
if anchor0 != anchor1:
1983+
raise ValueError(f"{self._raw_path!r} and {other._raw_path!r} have different anchors")
1984+
while parts0 and parts1 and parts0[-1] == parts1[-1]:
1985+
parts0.pop()
1986+
parts1.pop()
1987+
for part in parts1:
1988+
if not part or part == '.':
1989+
pass
1990+
elif not walk_up:
1991+
raise ValueError(f"{self._raw_path!r} is not in the subpath of {other._raw_path!r}")
1992+
elif part == '..':
1993+
raise ValueError(f"'..' segment in {other._raw_path!r} cannot be walked")
1994+
else:
1995+
parts0.append('..')
1996+
# return self.with_segments('', *reversed(parts0))
1997+
return type(self)('', *reversed(parts0))
1998+
1999+
2000+
if PYTHON_LT_3_8: # nocover
18982001

18992002
# Vendor in a nearly modern copytree for Python 3.6 and 3.7
19002003
def _compat_copytree(src, dst, symlinks=False, ignore=None,

0 commit comments

Comments
 (0)