Repository: fsspec/universal_pathlib (python)

Issue:
UPathStatResult doesn't allow for `st_birthtime` on Windows machines pre-3.12
The current implementation of `UPathStatResult` relies on a check of `os.stat_result` to determine which properties to expose, in particular `st_birthtime`. This means that any UPath object created within a Windows environment (through Python 3.11) will not have access to the `st_birthtime` property, regardless of whether the (possibly remote) filesystem provides that information.

One my main purposes for using universal pathlibs is to separate from the restrictions of a particular os implementation. I think it would make more sense for `st_birthtime` to always be available for UPath stat results, but to throw an error if the underlying filesystem didn't return that information.

A related issue is that `st_ctime` only returns the created time if the fsspec implementation assigned to the key `"ctime"`. It seems that most fsspec implementations use the key `"created"`. UPathStatResults are instantiated by `.from_info`, which sets all values to fall back on zero. Hence the `st_ctime` silently returns zero, while `st_birthtime` (the only property that uses the `"created"` key) doesn't exist.

Pull request description:
Add `st_birthtime` as standard field
Adds support for st_birthtime regardless of OS environment. Adds tests that timestamp values return reasonable results.

Closes #253

Patch:
diff --git a/.gitignore b/.gitignore
index e331257..059c618 100644
--- a/.gitignore
+++ b/.gitignore
@@ -139,3 +139,6 @@ cython_debug/
 
 # setuptools_scm
 upath/_version.py
+
+# vscode workspace settings
+.vscode/
diff --git a/upath/_stat.py b/upath/_stat.py
index f2cbece..5a4f0b1 100644
--- a/upath/_stat.py
+++ b/upath/_stat.py
@@ -281,6 +281,26 @@ class UPathStatResult:
                 pass
         return self._seq[9]
 
+    @property
+    def st_birthtime(self) -> int | float:
+        """time of creation"""
+        for key in [
+            "birthtime",
+            "created",
+            "creation_time",
+            "timeCreated",
+            "created_at",
+        ]:
+            try:
+                raw_value = self._info[key]
+            except KeyError:
+                continue
+            try:
+                return _convert_value_to_timestamp(raw_value)
+            except (TypeError, ValueError):
+                pass
+        raise AttributeError("birthtime")
+
     # --- extra fields ------------------------------------------------
 
     def __getattr__(self, item):
@@ -288,22 +308,6 @@ class UPathStatResult:
             return 0  # fallback default value
         raise AttributeError(item)
 
-    if "st_birthtime" in _fields_extra:
-
-        @property
-        def st_birthtime(self) -> int | float:
-            """time of creation"""
-            for key in ["created", "creation_time", "timeCreated", "created_at"]:
-                try:
-                    raw_value = self._info[key]
-                except KeyError:
-                    continue
-                try:
-                    return _convert_value_to_timestamp(raw_value)
-                except (TypeError, ValueError):
-                    pass
-            return 0
-
     # --- os.stat_result tuple interface ------------------------------
 
     def __len__(self) -> int:
diff --git a/upath/core.py b/upath/core.py
index b160eee..a1ecfd4 100644
--- a/upath/core.py
+++ b/upath/core.py
@@ -15,14 +15,17 @@ from typing import Literal
 from typing import Mapping
 from typing import Sequence
 from typing import TextIO
+from typing import TypedDict
 from typing import TypeVar
 from typing import overload
 from urllib.parse import urlsplit
 
 if sys.version_info >= (3, 11):
     from typing import Self
+    from typing import Unpack
 else:
     from typing_extensions import Self
+    from typing_extensions import Unpack
 
 from fsspec.registry import get_filesystem_class
 from fsspec.spec import AbstractFileSystem
@@ -91,6 +94,11 @@ def _make_instance(cls, args, kwargs):
     return cls(*args, **kwargs)
 
 
+class _UPathRenameParams(TypedDict, total=False):
+    recursive: bool
+    maxdepth: int | None
+
+
 # accessors are deprecated
 _FSSpecAccessor = FSSpecAccessorShim
 
@@ -1005,21 +1013,33 @@ class UPath(PathlibPathShim, Path):
     def rename(
         self,
         target: str | os.PathLike[str] | UPath,
-        *,
-        recursive: bool = False,
-        maxdepth: int | None = None,
-        **kwargs: Any,
-    ) -> UPath:  # fixme: non-standard
-        target_: UPath
-        if not isinstance(target, UPath):
-            target_ = self.parent.joinpath(target).resolve()
+        **kwargs: Unpack[_UPathRenameParams],  # note: non-standard compared to pathlib
+    ) -> Self:
+        if isinstance(target, str) and self.storage_options:
+            target = UPath(target, **self.storage_options)
+        target_protocol = get_upath_protocol(target)
+        if target_protocol:
+            if target_protocol != self.protocol:
+                raise ValueError(
+                    f"expected protocol {self.protocol!r}, got: {target_protocol!r}"
+                )
+            if not isinstance(target, UPath):
+                target_ = UPath(target, **self.storage_options)
+            else:
+                target_ = target
+            # avoid calling .resolve for subclasses of UPath
+            if ".." in target_.parts or "." in target_.parts:
+                target_ = target_.resolve()
         else:
-            target_ = target
+            parent = self.parent
+            # avoid calling .resolve for subclasses of UPath
+            if ".." in parent.parts or "." in parent.parts:
+                parent = parent.resolve()
+            target_ = parent.joinpath(os.path.normpath(target))
+        assert isinstance(target_, type(self)), "identical protocols enforced above"
         self.fs.mv(
             self.path,
             target_.path,
-            recursive=recursive,
-            maxdepth=maxdepth,
             **kwargs,
         )
         return target_
diff --git a/upath/implementations/smb.py b/upath/implementations/smb.py
index c072165..ef43de0 100644
--- a/upath/implementations/smb.py
+++ b/upath/implementations/smb.py
@@ -1,8 +1,20 @@
+from __future__ import annotations
+
+import os
+import sys
 import warnings
 
+if sys.version_info >= (3, 11):
+    from typing import Self
+    from typing import Unpack
+else:
+    from typing_extensions import Self
+    from typing_extensions import Unpack
+
 import smbprotocol.exceptions
 
 from upath import UPath
+from upath.core import _UPathRenameParams
 
 
 class SMBPath(UPath):
@@ -29,24 +41,21 @@ class SMBPath(UPath):
         else:
             return super().iterdir()
 
-    def rename(self, target, **kwargs):
-        if "recursive" in kwargs:
+    def rename(
+        self,
+        target: str | os.PathLike[str] | UPath,
+        **kwargs: Unpack[_UPathRenameParams],  # note: non-standard compared to pathlib
+    ) -> Self:
+        if kwargs.pop("recursive", None) is not None:
             warnings.warn(
                 "SMBPath.rename(): recursive is currently ignored.",
                 UserWarning,
                 stacklevel=2,
             )
-        if "maxdepth" in kwargs:
+        if kwargs.pop("maxdepth", None) is not None:
             warnings.warn(
                 "SMBPath.rename(): maxdepth is currently ignored.",
                 UserWarning,
                 stacklevel=2,
             )
-        if not isinstance(target, UPath):
-            target = self.parent.joinpath(target).resolve()
-        self.fs.mv(
-            self.path,
-            target.path,
-            **kwargs,
-        )
-        return target
+        return super().rename(target, **kwargs)


Test patch:
diff --git a/upath/tests/cases.py b/upath/tests/cases.py
index ae04ee6..d7e80fb 100644
--- a/upath/tests/cases.py
+++ b/upath/tests/cases.py
@@ -282,6 +282,11 @@ class BaseTests:
         assert target == moved
         assert not upath.exists()
         assert moved.exists()
+        # reverse with an absolute path as str
+        back = moved.rename(str(upath))
+        assert back == upath
+        assert not moved.exists()
+        assert back.exists()
 
     def test_rename2(self):
         upath = self.path.joinpath("folder1/file2.txt")
@@ -291,6 +296,11 @@ class BaseTests:
         assert target_path == moved
         assert not upath.exists()
         assert moved.exists()
+        # reverse with a relative path as UPath
+        back = moved.rename(UPath("file2.txt"))
+        assert back == upath
+        assert not moved.exists()
+        assert back.exists()
 
     def test_replace(self):
         pass
diff --git a/upath/tests/test_stat.py b/upath/tests/test_stat.py
index 66d9668..4922ae1 100644
--- a/upath/tests/test_stat.py
+++ b/upath/tests/test_stat.py
@@ -25,15 +25,45 @@ def test_stat_as_info(pth_file):
 
 
 def test_stat_atime(pth_file):
-    assert isinstance(pth_file.stat().st_atime, (float, int))
+    atime = pth_file.stat().st_atime
+    assert isinstance(atime, (float, int))
+
+
+@pytest.mark.xfail(reason="fsspec does not return 'atime'")
+def test_stat_atime_value(pth_file):
+    atime = pth_file.stat().st_atime
+    assert atime > 0
 
 
 def test_stat_mtime(pth_file):
-    assert isinstance(pth_file.stat().st_mtime, (float, int))
+    mtime = pth_file.stat().st_mtime
+    assert isinstance(mtime, (float, int))
+
+
+def test_stat_mtime_value(pth_file):
+    mtime = pth_file.stat().st_mtime
+    assert mtime > 0
 
 
 def test_stat_ctime(pth_file):
-    assert isinstance(pth_file.stat().st_ctime, (float, int))
+    ctime = pth_file.stat().st_ctime
+    assert isinstance(ctime, (float, int))
+
+
+@pytest.mark.xfail(reason="fsspec returns 'created' but not 'ctime'")
+def test_stat_ctime_value(pth_file):
+    ctime = pth_file.stat().st_ctime
+    assert ctime > 0
+
+
+def test_stat_birthtime(pth_file):
+    birthtime = pth_file.stat().st_birthtime
+    assert isinstance(birthtime, (float, int))
+
+
+def test_stat_birthtime_value(pth_file):
+    birthtime = pth_file.stat().st_birthtime
+    assert birthtime > 0
 
 
 def test_stat_seq_interface(pth_file):