Repository: google/etils

Issue:
Tests fail on Python 3.10
There are two test failures with Python 3.10. With Python 3.9 everything seems fine. Could you have a look?
```
================================================================================== FAILURES ==================================================================================
_________________________________________________________________________________ test_repr __________________________________________________________________________________

    def test_repr():
>     assert repr(R(123, R11(y='abc'))) == epy.dedent("""
      R(
          x=123,
          y=R11(
              x=None,
              y='abc',
              z=None,
          ),
      )
      """)
E     assert "R(x=123, y=R...bc', z=None))" == 'R(\n    x=12...e,\n    ),\n)'
E       + R(x=123, y=R11(x=None, y='abc', z=None))
E       - R(
E       -     x=123,
E       -     y=R11(
E       -         x=None,
E       -         y='abc',
E       -         z=None,...
E
E       ...Full output truncated (3 lines hidden), use '-vv' to show

etils/edc/dataclass_utils_test.py:108: AssertionError
_____________________________________________________________________________ test_resource_path _____________________________________________________________________________

    def test_resource_path():
      path = epath.resource_utils.ResourcePath(_make_zip_file())
      assert isinstance(path, os.PathLike)
      assert path.joinpath('b/c.txt').read_text() == 'content of c'
      sub_dirs = list(path.joinpath('b').iterdir())
      assert len(sub_dirs) == 3
      for p in sub_dirs:  # Childs should be `ResourcePath` instances
        assert isinstance(p, epath.resource_utils.ResourcePath)

      # Forwarded to `Path` keep the resource.
      path = epath.Path(path)
      assert isinstance(path, epath.resource_utils.ResourcePath)

>     assert path.joinpath() == path
E     AssertionError: assert ResourcePath('alpharep.zip', '') == ResourcePath('alpharep.zip', '')
E      +  where ResourcePath('alpharep.zip', '') = <bound method Path.joinpath of ResourcePath('alpharep.zip', '')>()
E      +    where <bound method Path.joinpath of ResourcePath('alpharep.zip', '')> = ResourcePath('alpharep.zip', '').joinpath
```

For `test_repr`, apparently custom `__repr__` is not applied as [`__qualname__`](https://github.com/google/etils/blob/3b7bc11f103bf62d3f9e8f48ede035a292a51ff0/etils/edc/dataclass_utils.py#L199) is changed in Python 3.10.

For `test_resource_path`, `joinpath()` returns a new object for Python >= 3.10 as that function is [not overridden](https://github.com/google/etils/blob/3b7bc11f103bf62d3f9e8f48ede035a292a51ff0/etils/epath/resource_utils.py#L74).

I noticed those failures when I'm creating a unofficial package python-etils for Arch Linux as a new dependency for the latest [python-tensorflow-datasets](https://aur.archlinux.org/packages/python-tensorflow-datasets).

Environment: Arch Linux x86_64, Python 3.10.4


Discussion:
Thank you for reporting and finding those bugs. Your info are very helpful.

Don't hesitate to send a PR. Otherwise I'll have a look this week

Thanks for the suggestion! I didn't send a PR yet as I'm not sure how to fix the issues. The `__qualname__` issue involves Python internals and I may not be able to find a good fix soon. The joinpath issue is more like an incorrect test - apparently `zipfile.Path.joinpath()` still creates a new object when paths to join are empty. I'm not sure if epath should match the behavior of zipfile.Path or not.
Thanks for the answer. I fixed those issues in https://github.com/google/etils/pull/159.

For the `zipfile`, it looks like the issue is that `__eq__` and `__hash__` are missing from `zipfile.Path`. Opened an issue in python: https://discuss.python.org/t/missing-zipfile-path-eq-and-zipfile-path-hash/16519

Thanks, that works!

> it looks like the issue is that `__eq__` and `__hash__` are missing from zipfile.Path

Yeah, that sounds reasonable. I found a more ambitious attempt https://github.com/python/cpython/pull/31085: "It could also be used to make zipfile.Path objects fully pathlib-compatible (no missing methods!)"

Resolving patch:
diff --git a/etils/edc/dataclass_utils.py b/etils/edc/dataclass_utils.py
--- a/etils/edc/dataclass_utils.py
+++ b/etils/edc/dataclass_utils.py
@@ -18,6 +18,7 @@
 
 import dataclasses
 import functools
+import inspect
 import reprlib
 import typing
 from typing import Any, Callable, TypeVar
@@ -196,7 +197,8 @@ def has_default_repr(cls: _Cls) -> bool:
       # Use `cls.__dict__` and not `hasattr` to ignore parent classes
       '__repr__' not in cls.__dict__
       # `__repr__` exists but is the default dataclass implementation
-      or cls.__repr__.__qualname__ == '__create_fn__.<locals>.__repr__'
+      or inspect.unwrap(cls.__repr__).__qualname__
+      == '__create_fn__.<locals>.__repr__'
   )
 
 
diff --git a/etils/epath/gpath.py b/etils/epath/gpath.py
--- a/etils/epath/gpath.py
+++ b/etils/epath/gpath.py
@@ -133,6 +133,7 @@ def expanduser(self: _P) -> _P:
 
   def resolve(self: _P, strict: bool = False) -> _P:
     """Returns the abolute path."""
+    # TODO(epot): In pathlib, `resolve` also resolve the symlinks
     return self._new(self._PATH.abspath(self._path_str))
 
   def glob(self: _P, pattern: str) -> Iterator[_P]:
diff --git a/etils/epath/resource_utils.py b/etils/epath/resource_utils.py
--- a/etils/epath/resource_utils.py
+++ b/etils/epath/resource_utils.py
@@ -17,8 +17,8 @@
 from __future__ import annotations
 
 import itertools
-import os
 import pathlib
+import posixpath
 import sys
 import types
 import typing
@@ -26,7 +26,6 @@
 
 from etils.epath import abstract_path
 from etils.epath import register
-from etils.epath.typing import PathLike
 
 # pylint: disable=g-import-not-at-top
 if sys.version_info >= (3, 9):  # `importlib.resources.files` was added in 3.9
@@ -64,19 +63,31 @@ def __fspath__(self) -> str:
     """
     raise NotImplementedError('zipapp not supported. Please send us a PR.')
 
+  # zipfile.Path do not define `__eq__` nor `__hash__`. See:
+  # https://discuss.python.org/t/missing-zipfile-path-eq-and-zipfile-path-hash/16519
+  def __eq__(self, other) -> bool:
+    # pyformat:disable
+    return (
+        type(self) == type(other)  # pylint: disable=unidiomatic-typecheck
+        and self.root == other.root  # pytype: disable=attribute-error
+        and self.at == other.at  # pytype: disable=attribute-error
+    )
+    # pyformat:enable
+
+  def __hash__(self) -> int:
+    return hash((self.root, self.at))  # pytype: disable=attribute-error
+
   if sys.version_info < (3, 10):
 
     # Required due to: https://bugs.python.org/issue42043
     def _next(self, at) -> 'ResourcePath':
-      return type(self)(self.root, at)  # pytype: disable=attribute-error  # py39-upgrade
+      return type(self)(self.root, at)  # pytype: disable=attribute-error
 
     # Before 3.10, joinpath only accept a single arg
-    def joinpath(self, *parts: PathLike) -> 'ResourcePath':
+    def joinpath(self, *other):
       """Overwrite `joinpath` to be consistent with `pathlib.Path`."""
-      if not parts:
-        return self
-      else:
-        return super().joinpath(os.path.join(*parts))  # pylint: disable=no-value-for-parameter  # pytype: disable=bad-return-type  # py39-upgrade
+      next_ = posixpath.join(self.at, *other)  # pytype: disable=attribute-error
+      return self._next(self.root.resolve_dir(next_))  # pytype: disable=attribute-error
 
 
 def resource_path(package: Union[str, types.ModuleType]) -> abstract_path.Path:


Test patch:
diff --git a/etils/epath/resource_utils_test.py b/etils/epath/resource_utils_test.py
--- a/etils/epath/resource_utils_test.py
+++ b/etils/epath/resource_utils_test.py
@@ -50,6 +50,8 @@ def _make_zip_file() -> zipfile.ZipFile:
 def test_resource_path():
   path = epath.resource_utils.ResourcePath(_make_zip_file())
   assert isinstance(path, os.PathLike)
+  assert path.joinpath('b/c.txt') == path / 'b' / 'c.txt'
+  assert hash(path.joinpath('b/c.txt')) == hash(path / 'b' / 'c.txt')
   assert path.joinpath('b/c.txt').read_text() == 'content of c'
   sub_dirs = list(path.joinpath('b').iterdir())
   assert len(sub_dirs) == 3