paths.py 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108
  1. from __future__ import annotations
  2. import os
  3. import platform
  4. from functools import wraps
  5. from pathlib import PurePath, PurePosixPath
  6. from typing import Any, Union
  7. from typing_extensions import TypeAlias
  8. # Path _inputs_ should generally accept any kind of path. This is named the same and
  9. # modeled after the hint defined in the Python standard library's `typeshed`:
  10. # https://github.com/python/typeshed/blob/0b1cd5989669544866213807afa833a88f649ee7/stdlib/_typeshed/__init__.pyi#L56-L65
  11. StrPath: TypeAlias = Union[str, "os.PathLike[str]"]
  12. FilePathStr: TypeAlias = str #: A native path to a file on a local filesystem.
  13. URIStr: TypeAlias = str
  14. class LogicalPath(str):
  15. """A string that represents a path relative to an artifact or run.
  16. The format of the string is always as a POSIX path, e.g. "foo/bar.txt".
  17. A neat trick is that you can use this class as if it were a PurePosixPath. E.g.:
  18. ```
  19. >>> path = LogicalPath("foo/bar.txt")
  20. >>> path.parts
  21. ('foo', 'bar.txt')
  22. >>> path.parent / "baz.txt"
  23. 'foo/baz.txt'
  24. >>> type(path.relative_to("foo"))
  25. LogicalPath
  26. ```
  27. """
  28. # It should probably always be a relative path, but that would be a behavior change.
  29. #
  30. # These strings used to be the output of `to_forward_slash_path`, which only works
  31. # with strings and whose behavior is pretty simple:
  32. # ```
  33. # if platform.system() == "Windows":
  34. # path = path.replace("\\", "/")
  35. # ```
  36. #
  37. # This results in some weird things, such as backslashes being allowed from
  38. # non-Windows platforms (which would probably break if such an artifact was used
  39. # from Windows) and anchors or absolute paths being allowed. E.g., the Windows path
  40. # "C:\foo\bar.txt" becomes "C:/foo/bar.txt", which then would mount as
  41. # "./artifacts/artifact_name:v0/C:/foo/bar.txt" on MacOS and as
  42. # "./artifacts/artifact_name-v0/C-/foo/bar.txt" on Windows.
  43. #
  44. # This implementation preserves behavior for strings but attempts to sanitize other
  45. # formerly unsupported inputs more aggressively. It uses the `.as_posix()` form of
  46. # pathlib objects rather than the `str()` form to reduce how often identical inputs
  47. # will result in different outputs on different platforms; however, it doesn't alter
  48. # absolute paths or check for prohibited characters etc.
  49. def __new__(cls, path: StrPath) -> LogicalPath:
  50. if isinstance(path, LogicalPath):
  51. return super().__new__(cls, path)
  52. if hasattr(path, "as_posix"):
  53. path = PurePosixPath(path.as_posix())
  54. return super().__new__(cls, str(path))
  55. if hasattr(path, "__fspath__"):
  56. path = path.__fspath__() # Can be str or bytes.
  57. if isinstance(path, bytes):
  58. path = os.fsdecode(path)
  59. # For historical reasons we have to convert backslashes to forward slashes, but
  60. # only on Windows, and need to do it before any pathlib operations.
  61. if platform.system() == "Windows":
  62. path = path.replace("\\", "/")
  63. # This weird contortion and the one above are because in some unusual cases
  64. # PurePosixPath(path.as_posix()).as_posix() != path.as_posix().
  65. path = PurePath(path).as_posix()
  66. return super().__new__(cls, str(PurePosixPath(path)))
  67. def to_path(self) -> PurePosixPath:
  68. """Convert this path to a PurePosixPath."""
  69. return PurePosixPath(self)
  70. def __getattr__(self, name: str) -> Any:
  71. """Act like a subclass of PurePosixPath for all methods not defined on str."""
  72. try:
  73. attr = getattr(self.to_path(), name)
  74. except AttributeError:
  75. classname = type(self).__qualname__
  76. raise AttributeError(f"{classname!r} has no attribute {name!r}") from None
  77. if isinstance(attr, PurePosixPath):
  78. return LogicalPath(attr)
  79. # If the result is a callable (a method), wrap it so that it has the same
  80. # behavior: if the call result returns a PurePosixPath, return a LogicalPath.
  81. if callable(fn := attr):
  82. @wraps(fn)
  83. def wrapper(*args: Any, **kwargs: Any) -> Any:
  84. if isinstance(res := fn(*args, **kwargs), PurePosixPath):
  85. return LogicalPath(res)
  86. return res
  87. return wrapper
  88. return attr
  89. def __truediv__(self, other: StrPath) -> LogicalPath:
  90. """Act like a PurePosixPath for the / operator, but return a LogicalPath."""
  91. return LogicalPath(self.to_path() / LogicalPath(other))