fonttools 4.58.5__cp39-cp39-macosx_10_9_universal2.whl → 4.59.1__cp39-cp39-macosx_10_9_universal2.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of fonttools might be problematic. Click here for more details.

Files changed (67) hide show
  1. fontTools/__init__.py +1 -1
  2. fontTools/cffLib/CFF2ToCFF.py +40 -10
  3. fontTools/cffLib/transforms.py +11 -6
  4. fontTools/cu2qu/cu2qu.c +21 -6
  5. fontTools/cu2qu/cu2qu.cpython-39-darwin.so +0 -0
  6. fontTools/feaLib/builder.py +6 -1
  7. fontTools/feaLib/lexer.c +21 -6
  8. fontTools/feaLib/lexer.cpython-39-darwin.so +0 -0
  9. fontTools/merge/__init__.py +1 -1
  10. fontTools/misc/bezierTools.c +24 -9
  11. fontTools/misc/bezierTools.cpython-39-darwin.so +0 -0
  12. fontTools/misc/filesystem/__init__.py +68 -0
  13. fontTools/misc/filesystem/_base.py +134 -0
  14. fontTools/misc/filesystem/_copy.py +45 -0
  15. fontTools/misc/filesystem/_errors.py +54 -0
  16. fontTools/misc/filesystem/_info.py +75 -0
  17. fontTools/misc/filesystem/_osfs.py +164 -0
  18. fontTools/misc/filesystem/_path.py +67 -0
  19. fontTools/misc/filesystem/_subfs.py +92 -0
  20. fontTools/misc/filesystem/_tempfs.py +34 -0
  21. fontTools/misc/filesystem/_tools.py +34 -0
  22. fontTools/misc/filesystem/_walk.py +55 -0
  23. fontTools/misc/filesystem/_zipfs.py +204 -0
  24. fontTools/misc/psCharStrings.py +17 -2
  25. fontTools/misc/sstruct.py +2 -6
  26. fontTools/misc/xmlWriter.py +28 -1
  27. fontTools/pens/momentsPen.c +11 -4
  28. fontTools/pens/momentsPen.cpython-39-darwin.so +0 -0
  29. fontTools/pens/roundingPen.py +2 -2
  30. fontTools/qu2cu/qu2cu.c +23 -8
  31. fontTools/qu2cu/qu2cu.cpython-39-darwin.so +0 -0
  32. fontTools/subset/__init__.py +11 -11
  33. fontTools/ttLib/sfnt.py +2 -3
  34. fontTools/ttLib/tables/S__i_l_f.py +2 -2
  35. fontTools/ttLib/tables/T_S_I__1.py +2 -5
  36. fontTools/ttLib/tables/T_S_I__5.py +2 -2
  37. fontTools/ttLib/tables/_c_m_a_p.py +1 -1
  38. fontTools/ttLib/tables/_c_v_t.py +1 -2
  39. fontTools/ttLib/tables/_g_l_y_f.py +9 -10
  40. fontTools/ttLib/tables/_g_v_a_r.py +6 -3
  41. fontTools/ttLib/tables/_h_d_m_x.py +4 -4
  42. fontTools/ttLib/tables/_h_m_t_x.py +7 -3
  43. fontTools/ttLib/tables/_l_o_c_a.py +2 -2
  44. fontTools/ttLib/tables/_p_o_s_t.py +3 -4
  45. fontTools/ttLib/tables/otBase.py +2 -4
  46. fontTools/ttLib/tables/otTables.py +7 -12
  47. fontTools/ttLib/tables/sbixStrike.py +3 -3
  48. fontTools/ttLib/ttFont.py +7 -16
  49. fontTools/ttLib/woff2.py +10 -13
  50. fontTools/ufoLib/__init__.py +20 -25
  51. fontTools/ufoLib/glifLib.py +12 -17
  52. fontTools/ufoLib/validators.py +2 -4
  53. fontTools/unicodedata/__init__.py +2 -0
  54. fontTools/varLib/featureVars.py +8 -0
  55. fontTools/varLib/hvar.py +1 -1
  56. fontTools/varLib/instancer/__init__.py +65 -5
  57. fontTools/varLib/iup.c +23 -8
  58. fontTools/varLib/iup.cpython-39-darwin.so +0 -0
  59. fontTools/varLib/mutator.py +11 -0
  60. {fonttools-4.58.5.dist-info → fonttools-4.59.1.dist-info}/METADATA +42 -12
  61. {fonttools-4.58.5.dist-info → fonttools-4.59.1.dist-info}/RECORD +67 -55
  62. {fonttools-4.58.5.dist-info → fonttools-4.59.1.dist-info}/licenses/LICENSE.external +29 -0
  63. {fonttools-4.58.5.data → fonttools-4.59.1.data}/data/share/man/man1/ttx.1 +0 -0
  64. {fonttools-4.58.5.dist-info → fonttools-4.59.1.dist-info}/WHEEL +0 -0
  65. {fonttools-4.58.5.dist-info → fonttools-4.59.1.dist-info}/entry_points.txt +0 -0
  66. {fonttools-4.58.5.dist-info → fonttools-4.59.1.dist-info}/licenses/LICENSE +0 -0
  67. {fonttools-4.58.5.dist-info → fonttools-4.59.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,134 @@
1
+ from __future__ import annotations
2
+
3
+ import typing
4
+ from abc import ABC, abstractmethod
5
+
6
+ from ._copy import copy_dir, copy_file
7
+ from ._errors import (
8
+ DestinationExists,
9
+ DirectoryExpected,
10
+ FileExpected,
11
+ FilesystemClosed,
12
+ NoSysPath,
13
+ ResourceNotFound,
14
+ )
15
+ from ._path import dirname
16
+ from ._walk import BoundWalker
17
+
18
+ if typing.TYPE_CHECKING:
19
+ from typing import IO, Any, Collection, Iterator, Self, Type
20
+
21
+ from ._info import Info
22
+ from ._subfs import SubFS
23
+
24
+
25
+ class FS(ABC):
26
+ """Abstract base class for custom filesystems."""
27
+
28
+ _closed: bool = False
29
+
30
+ @abstractmethod
31
+ def open(self, path: str, mode: str = "rb", **kwargs) -> IO[Any]: ...
32
+
33
+ @abstractmethod
34
+ def exists(self, path: str) -> bool: ...
35
+
36
+ @abstractmethod
37
+ def isdir(self, path: str) -> bool: ...
38
+
39
+ @abstractmethod
40
+ def isfile(self, path: str) -> bool: ...
41
+
42
+ @abstractmethod
43
+ def listdir(self, path: str) -> list[str]: ...
44
+
45
+ @abstractmethod
46
+ def makedir(self, path: str, recreate: bool = False) -> SubFS: ...
47
+
48
+ @abstractmethod
49
+ def makedirs(self, path: str, recreate: bool = False) -> SubFS: ...
50
+
51
+ @abstractmethod
52
+ def getinfo(self, path: str, namespaces: Collection[str] | None = None) -> Info: ...
53
+
54
+ @abstractmethod
55
+ def remove(self, path: str) -> None: ...
56
+
57
+ @abstractmethod
58
+ def removedir(self, path: str) -> None: ...
59
+
60
+ @abstractmethod
61
+ def removetree(self, path: str) -> None: ...
62
+
63
+ @abstractmethod
64
+ def movedir(self, src: str, dst: str, create: bool = False) -> None: ...
65
+
66
+ def getsyspath(self, path: str) -> str:
67
+ raise NoSysPath(f"the filesystem {self!r} has no system path")
68
+
69
+ def close(self):
70
+ self._closed = True
71
+
72
+ def isclosed(self) -> bool:
73
+ return self._closed
74
+
75
+ def __enter__(self) -> Self:
76
+ return self
77
+
78
+ def __exit__(self, exc_type, exc, tb):
79
+ self.close()
80
+ return False # never swallow exceptions
81
+
82
+ def check(self):
83
+ if self._closed:
84
+ raise FilesystemClosed(f"the filesystem {self!r} is closed")
85
+
86
+ def opendir(self, path: str, *, factory: Type[SubFS] | None = None) -> SubFS:
87
+ """Return a sub‑filesystem rooted at `path`."""
88
+ if factory is None:
89
+ from ._subfs import SubFS
90
+
91
+ factory = SubFS
92
+ return factory(self, path)
93
+
94
+ def scandir(
95
+ self, path: str, namespaces: Collection[str] | None = None
96
+ ) -> Iterator[Info]:
97
+ return (self.getinfo(f"{path}/{p}", namespaces) for p in self.listdir(path))
98
+
99
+ @property
100
+ def walk(self) -> BoundWalker:
101
+ return BoundWalker(self)
102
+
103
+ def readbytes(self, path: str) -> bytes:
104
+ with self.open(path, "rb") as f:
105
+ return f.read()
106
+
107
+ def writebytes(self, path: str, data: bytes):
108
+ with self.open(path, "wb") as f:
109
+ f.write(data)
110
+
111
+ def create(self, path: str, wipe: bool = False):
112
+ if not wipe and self.exists(path):
113
+ return False
114
+ with self.open(path, "wb"):
115
+ pass # 'touch' empty file
116
+ return True
117
+
118
+ def copy(self, src_path: str, dst_path: str, overwrite=False):
119
+ if not self.exists(src_path):
120
+ raise ResourceNotFound(f"{src_path!r} does not exist")
121
+ elif not self.isfile(src_path):
122
+ raise FileExpected(f"path {src_path!r} should be a file")
123
+ if not overwrite and self.exists(dst_path):
124
+ raise DestinationExists(f"destination {dst_path!r} already exists")
125
+ if not self.isdir(dirname(dst_path)):
126
+ raise DirectoryExpected(f"path {dirname(dst_path)!r} should be a directory")
127
+ copy_file(self, src_path, self, dst_path)
128
+
129
+ def copydir(self, src_path: str, dst_path: str, create=False):
130
+ if not create and not self.exists(dst_path):
131
+ raise ResourceNotFound(f"{dst_path!r} does not exist")
132
+ if not self.isdir(src_path):
133
+ raise DirectoryExpected(f"path {src_path!r} should be a directory")
134
+ copy_dir(self, src_path, self, dst_path)
@@ -0,0 +1,45 @@
1
+ from __future__ import annotations
2
+
3
+ import typing
4
+
5
+ from ._errors import IllegalDestination
6
+ from ._path import combine, frombase, isbase
7
+ from ._tools import copy_file_data
8
+
9
+ if typing.TYPE_CHECKING:
10
+ from ._base import FS
11
+
12
+
13
+ def copy_file(src_fs: FS, src_path: str, dst_fs: FS, dst_path: str):
14
+ if src_fs is dst_fs and src_path == dst_path:
15
+ raise IllegalDestination(f"cannot copy {src_path!r} to itself")
16
+
17
+ with src_fs.open(src_path, "rb") as src_file:
18
+ with dst_fs.open(dst_path, "wb") as dst_file:
19
+ copy_file_data(src_file, dst_file)
20
+
21
+
22
+ def copy_structure(
23
+ src_fs: FS,
24
+ dst_fs: FS,
25
+ src_root: str = "/",
26
+ dst_root: str = "/",
27
+ ):
28
+ if src_fs is dst_fs and isbase(src_root, dst_root):
29
+ raise IllegalDestination(f"cannot copy {src_fs!r} to itself")
30
+
31
+ dst_fs.makedirs(dst_root, recreate=True)
32
+ for dir_path in src_fs.walk.dirs(src_root):
33
+ dst_fs.makedir(combine(dst_root, frombase(src_root, dir_path)), recreate=True)
34
+
35
+
36
+ def copy_dir(src_fs: FS, src_path: str, dst_fs: FS, dst_path: str):
37
+ copy_structure(src_fs, dst_fs, src_path, dst_path)
38
+
39
+ for file_path in src_fs.walk.files(src_path):
40
+ copy_path = combine(dst_path, frombase(src_path, file_path))
41
+ copy_file(src_fs, file_path, dst_fs, copy_path)
42
+
43
+
44
+ def copy_fs(src_fs: FS, dst_fs: FS):
45
+ copy_dir(src_fs, "/", dst_fs, "/")
@@ -0,0 +1,54 @@
1
+ class FSError(Exception):
2
+ pass
3
+
4
+
5
+ class CreateFailed(FSError):
6
+ pass
7
+
8
+
9
+ class FilesystemClosed(FSError):
10
+ pass
11
+
12
+
13
+ class MissingInfoNamespace(FSError):
14
+ pass
15
+
16
+
17
+ class NoSysPath(FSError):
18
+ pass
19
+
20
+
21
+ class OperationFailed(FSError):
22
+ pass
23
+
24
+
25
+ class IllegalDestination(OperationFailed):
26
+ pass
27
+
28
+
29
+ class ResourceError(FSError):
30
+ pass
31
+
32
+
33
+ class ResourceNotFound(ResourceError):
34
+ pass
35
+
36
+
37
+ class DirectoryExpected(ResourceError):
38
+ pass
39
+
40
+
41
+ class DirectoryNotEmpty(ResourceError):
42
+ pass
43
+
44
+
45
+ class FileExpected(ResourceError):
46
+ pass
47
+
48
+
49
+ class DestinationExists(ResourceError):
50
+ pass
51
+
52
+
53
+ class ResourceReadOnly(ResourceError):
54
+ pass
@@ -0,0 +1,75 @@
1
+ from __future__ import annotations
2
+
3
+ import typing
4
+ from datetime import datetime, timezone
5
+
6
+ from ._errors import MissingInfoNamespace
7
+
8
+ if typing.TYPE_CHECKING:
9
+ from collections.abc import Mapping
10
+ from typing import Any
11
+
12
+
13
+ def epoch_to_datetime(t: int | None) -> datetime | None:
14
+ """Convert epoch time to a UTC datetime."""
15
+ if t is None:
16
+ return None
17
+ return datetime.fromtimestamp(t, tz=timezone.utc)
18
+
19
+
20
+ class Info:
21
+ __slots__ = ["raw", "namespaces"]
22
+
23
+ def __init__(self, raw_info: Mapping[str, Any]):
24
+ self.raw = raw_info
25
+ self.namespaces = frozenset(raw_info.keys())
26
+
27
+ def get(self, namespace: str, key: str, default: Any | None = None) -> Any | None:
28
+ try:
29
+ return self.raw[namespace].get(key, default)
30
+ except KeyError:
31
+ raise MissingInfoNamespace(f"Namespace {namespace!r} does not exist")
32
+
33
+ @property
34
+ def name(self) -> str:
35
+ return self.get("basic", "name")
36
+
37
+ @property
38
+ def is_dir(self) -> bool:
39
+ return self.get("basic", "is_dir")
40
+
41
+ @property
42
+ def is_file(self) -> bool:
43
+ return not self.is_dir
44
+
45
+ @property
46
+ def accessed(self) -> datetime | None:
47
+ return epoch_to_datetime(self.get("details", "accessed"))
48
+
49
+ @property
50
+ def modified(self) -> datetime | None:
51
+ return epoch_to_datetime(self.get("details", "modified"))
52
+
53
+ @property
54
+ def size(self) -> int | None:
55
+ return self.get("details", "size")
56
+
57
+ @property
58
+ def type(self) -> int | None:
59
+ return self.get("details", "type")
60
+
61
+ @property
62
+ def created(self) -> datetime | None:
63
+ return epoch_to_datetime(self.get("details", "created"))
64
+
65
+ @property
66
+ def metadata_changed(self) -> datetime | None:
67
+ return epoch_to_datetime(self.get("details", "metadata_changed"))
68
+
69
+ def __str__(self) -> str:
70
+ if self.is_dir:
71
+ return "<dir '{}'>".format(self.name)
72
+ else:
73
+ return "<file '{}'>".format(self.name)
74
+
75
+ __repr__ = __str__
@@ -0,0 +1,164 @@
1
+ from __future__ import annotations
2
+
3
+ import errno
4
+ import platform
5
+ import shutil
6
+ import stat
7
+ import typing
8
+ from os import PathLike
9
+ from pathlib import Path
10
+
11
+ from ._base import FS
12
+ from ._errors import (
13
+ CreateFailed,
14
+ DirectoryExpected,
15
+ DirectoryNotEmpty,
16
+ FileExpected,
17
+ IllegalDestination,
18
+ ResourceError,
19
+ ResourceNotFound,
20
+ )
21
+ from ._info import Info
22
+ from ._path import isbase
23
+
24
+ if typing.TYPE_CHECKING:
25
+ from collections.abc import Collection
26
+ from typing import IO, Any
27
+
28
+ from ._subfs import SubFS
29
+
30
+
31
+ _WINDOWS_PLATFORM = platform.system() == "Windows"
32
+
33
+
34
+ class OSFS(FS):
35
+ """Filesystem for a directory on the local disk.
36
+
37
+ A thin layer on top of `pathlib.Path`.
38
+ """
39
+
40
+ def __init__(self, root: str | PathLike, create: bool = False):
41
+ super().__init__()
42
+ self._root = Path(root).resolve()
43
+ if create:
44
+ self._root.mkdir(parents=True, exist_ok=True)
45
+ else:
46
+ if not self._root.is_dir():
47
+ raise CreateFailed(
48
+ f"unable to create OSFS: {root!r} does not exist or is not a directory"
49
+ )
50
+
51
+ def _abs(self, rel_path: str) -> Path:
52
+ self.check()
53
+ return (self._root / rel_path.strip("/")).resolve()
54
+
55
+ def open(self, path: str, mode: str = "rb", **kwargs) -> IO[Any]:
56
+ try:
57
+ return self._abs(path).open(mode, **kwargs)
58
+ except FileNotFoundError:
59
+ raise ResourceNotFound(f"No such file or directory: {path!r}")
60
+
61
+ def exists(self, path: str) -> bool:
62
+ return self._abs(path).exists()
63
+
64
+ def isdir(self, path: str) -> bool:
65
+ return self._abs(path).is_dir()
66
+
67
+ def isfile(self, path: str) -> bool:
68
+ return self._abs(path).is_file()
69
+
70
+ def listdir(self, path: str) -> list[str]:
71
+ return [p.name for p in self._abs(path).iterdir()]
72
+
73
+ def _mkdir(self, path: str, parents: bool = False, exist_ok: bool = False) -> SubFS:
74
+ self._abs(path).mkdir(parents=parents, exist_ok=exist_ok)
75
+ return self.opendir(path)
76
+
77
+ def makedir(self, path: str, recreate: bool = False) -> SubFS:
78
+ return self._mkdir(path, parents=False, exist_ok=recreate)
79
+
80
+ def makedirs(self, path: str, recreate: bool = False) -> SubFS:
81
+ return self._mkdir(path, parents=True, exist_ok=recreate)
82
+
83
+ def getinfo(self, path: str, namespaces: Collection[str] | None = None) -> Info:
84
+ path = self._abs(path)
85
+ if not path.exists():
86
+ raise ResourceNotFound(f"No such file or directory: {str(path)!r}")
87
+ info = {
88
+ "basic": {
89
+ "name": path.name,
90
+ "is_dir": path.is_dir(),
91
+ }
92
+ }
93
+ namespaces = namespaces or ()
94
+ if "details" in namespaces:
95
+ stat_result = path.stat()
96
+ details = info["details"] = {
97
+ "accessed": stat_result.st_atime,
98
+ "modified": stat_result.st_mtime,
99
+ "size": stat_result.st_size,
100
+ "type": stat.S_IFMT(stat_result.st_mode),
101
+ "created": getattr(stat_result, "st_birthtime", None),
102
+ }
103
+ ctime_key = "created" if _WINDOWS_PLATFORM else "metadata_changed"
104
+ details[ctime_key] = stat_result.st_ctime
105
+ return Info(info)
106
+
107
+ def remove(self, path: str):
108
+ path = self._abs(path)
109
+ try:
110
+ path.unlink()
111
+ except FileNotFoundError:
112
+ raise ResourceNotFound(f"No such file or directory: {str(path)!r}")
113
+ except OSError as e:
114
+ if path.is_dir():
115
+ raise FileExpected(f"path {str(path)!r} should be a file")
116
+ else:
117
+ raise ResourceError(f"unable to remove {str(path)!r}: {e}")
118
+
119
+ def removedir(self, path: str):
120
+ try:
121
+ self._abs(path).rmdir()
122
+ except NotADirectoryError:
123
+ raise DirectoryExpected(f"path {path!r} should be a directory")
124
+ except OSError as e:
125
+ if e.errno == errno.ENOTEMPTY:
126
+ raise DirectoryNotEmpty(f"Directory not empty: {path!r}")
127
+ else:
128
+ raise ResourceError(f"unable to remove {path!r}: {e}")
129
+
130
+ def removetree(self, path: str):
131
+ shutil.rmtree(self._abs(path))
132
+
133
+ def movedir(self, src_dir: str, dst_dir: str, create: bool = False):
134
+ if isbase(src_dir, dst_dir):
135
+ raise IllegalDestination(f"cannot move {src_dir!r} to {dst_dir!r}")
136
+ src_path = self._abs(src_dir)
137
+ if not src_path.exists():
138
+ raise ResourceNotFound(f"Source {src_dir!r} does not exist")
139
+ elif not src_path.is_dir():
140
+ raise DirectoryExpected(f"Source {src_dir!r} should be a directory")
141
+ dst_path = self._abs(dst_dir)
142
+ if not create and not dst_path.exists():
143
+ raise ResourceNotFound(f"Destination {dst_dir!r} does not exist")
144
+ if dst_path.is_file():
145
+ raise DirectoryExpected(f"Destination {dst_dir!r} should be a directory")
146
+ if create:
147
+ dst_path.parent.mkdir(parents=True, exist_ok=True)
148
+ if dst_path.exists():
149
+ if list(dst_path.iterdir()):
150
+ raise DirectoryNotEmpty(f"Destination {dst_dir!r} is not empty")
151
+ elif _WINDOWS_PLATFORM:
152
+ # on Unix os.rename silently replaces an empty dst_dir whereas on
153
+ # Windows it always raises FileExistsError, empty or not.
154
+ dst_path.rmdir()
155
+ src_path.rename(dst_path)
156
+
157
+ def getsyspath(self, path: str) -> str:
158
+ return str(self._abs(path))
159
+
160
+ def __repr__(self) -> str:
161
+ return f"{self.__class__.__name__}({str(self._root)!r})"
162
+
163
+ def __str__(self) -> str:
164
+ return f"<{self.__class__.__name__.lower()} '{self._root}'>"
@@ -0,0 +1,67 @@
1
+ import os
2
+ import platform
3
+
4
+ _WINDOWS_PLATFORM = platform.system() == "Windows"
5
+
6
+
7
+ def combine(path1: str, path2) -> str:
8
+ if not path1:
9
+ return path2
10
+ return "{}/{}".format(path1.rstrip("/"), path2.lstrip("/"))
11
+
12
+
13
+ def split(path: str) -> tuple[str, str]:
14
+ if "/" not in path:
15
+ return ("", path)
16
+ split = path.rsplit("/", 1)
17
+ return (split[0] or "/", split[1])
18
+
19
+
20
+ def dirname(path: str) -> str:
21
+ return split(path)[0]
22
+
23
+
24
+ def basename(path: str) -> str:
25
+ return split(path)[1]
26
+
27
+
28
+ def forcedir(path: str) -> str:
29
+ # Ensure the path ends with a trailing forward slash.
30
+ if not path.endswith("/"):
31
+ return path + "/"
32
+ return path
33
+
34
+
35
+ def abspath(path: str) -> str:
36
+ # FS objects have no concept of a *current directory*. This simply
37
+ # ensures the path starts with a forward slash.
38
+ if not path.startswith("/"):
39
+ return "/" + path
40
+ return path
41
+
42
+
43
+ def isbase(path1: str, path2: str) -> bool:
44
+ # Check if `path1` is a base or prefix of `path2`.
45
+ _path1 = forcedir(abspath(path1))
46
+ _path2 = forcedir(abspath(path2))
47
+ return _path2.startswith(_path1)
48
+
49
+
50
+ def frombase(path1: str, path2: str) -> str:
51
+ # Get the final path of `path2` that isn't in `path1`.
52
+ if not isbase(path1, path2):
53
+ raise ValueError(f"path1 must be a prefix of path2: {path1!r} vs {path2!r}")
54
+ return path2[len(path1) :]
55
+
56
+
57
+ def relpath(path: str) -> str:
58
+ return path.lstrip("/")
59
+
60
+
61
+ def normpath(path: str) -> str:
62
+ normalized = os.path.normpath(path)
63
+ if _WINDOWS_PLATFORM:
64
+ # os.path.normpath converts backslashes to forward slashes on Windows
65
+ # but we want forward slashes, so we convert them back
66
+ normalized = normalized.replace("\\", "/")
67
+ return normalized
@@ -0,0 +1,92 @@
1
+ from __future__ import annotations
2
+
3
+ import typing
4
+ from pathlib import PurePosixPath
5
+
6
+ from ._base import FS
7
+ from ._errors import DirectoryExpected, ResourceNotFound
8
+
9
+ if typing.TYPE_CHECKING:
10
+ from collections.abc import Collection
11
+ from typing import IO, Any
12
+
13
+ from ._info import Info
14
+
15
+
16
+ class SubFS(FS):
17
+ """Maps a sub-directory of another filesystem."""
18
+
19
+ def __init__(self, parent: FS, sub_path: str):
20
+ super().__init__()
21
+ self._parent = parent
22
+ self._prefix = PurePosixPath(sub_path).as_posix().rstrip("/")
23
+ if not parent.exists(self._prefix):
24
+ raise ResourceNotFound(f"No such file or directory: {sub_path!r}")
25
+ elif not parent.isdir(self._prefix):
26
+ raise DirectoryExpected(f"{sub_path!r} is not a directory")
27
+
28
+ def delegate_fs(self):
29
+ return self._parent
30
+
31
+ def _full(self, rel: str) -> str:
32
+ self.check()
33
+ return f"{self._prefix}/{PurePosixPath(rel).as_posix()}".lstrip("/")
34
+
35
+ def open(self, path: str, mode: str = "rb", **kwargs) -> IO[Any]:
36
+ return self._parent.open(self._full(path), mode, **kwargs)
37
+
38
+ def exists(self, path: str) -> bool:
39
+ return self._parent.exists(self._full(path))
40
+
41
+ def isdir(self, path: str) -> bool:
42
+ return self._parent.isdir(self._full(path))
43
+
44
+ def isfile(self, path: str) -> bool:
45
+ return self._parent.isfile(self._full(path))
46
+
47
+ def listdir(self, path: str) -> list[str]:
48
+ return self._parent.listdir(self._full(path))
49
+
50
+ def makedir(self, path: str, recreate: bool = False):
51
+ return self._parent.makedir(self._full(path), recreate=recreate)
52
+
53
+ def makedirs(self, path: str, recreate: bool = False):
54
+ return self._parent.makedirs(self._full(path), recreate=recreate)
55
+
56
+ def getinfo(self, path: str, namespaces: Collection[str] | None = None) -> Info:
57
+ return self._parent.getinfo(self._full(path), namespaces=namespaces)
58
+
59
+ def remove(self, path: str):
60
+ return self._parent.remove(self._full(path))
61
+
62
+ def removedir(self, path: str):
63
+ return self._parent.removedir(self._full(path))
64
+
65
+ def removetree(self, path: str):
66
+ return self._parent.removetree(self._full(path))
67
+
68
+ def movedir(self, src: str, dst: str, create: bool = False):
69
+ self._parent.movedir(self._full(src), self._full(dst), create=create)
70
+
71
+ def getsyspath(self, path: str) -> str:
72
+ return self._parent.getsyspath(self._full(path))
73
+
74
+ def readbytes(self, path: str) -> bytes:
75
+ return self._parent.readbytes(self._full(path))
76
+
77
+ def writebytes(self, path: str, data: bytes):
78
+ self._parent.writebytes(self._full(path), data)
79
+
80
+ def __repr__(self) -> str:
81
+ return f"{self.__class__.__name__}({self._parent!r}, {self._prefix!r})"
82
+
83
+ def __str__(self) -> str:
84
+ return f"{self._parent}/{self._prefix}"
85
+
86
+
87
+ class ClosingSubFS(SubFS):
88
+ """Like SubFS, but auto-closes the parent filesystem when closed."""
89
+
90
+ def close(self):
91
+ super().close()
92
+ self._parent.close()
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ import shutil
4
+ import tempfile
5
+
6
+ from ._errors import OperationFailed
7
+ from ._osfs import OSFS
8
+
9
+
10
+ class TempFS(OSFS):
11
+ def __init__(self, auto_clean: bool = True, ignore_clean_errors: bool = True):
12
+ self.auto_clean = auto_clean
13
+ self.ignore_clean_errors = ignore_clean_errors
14
+ self._temp_dir = tempfile.mkdtemp("__temp_fs__")
15
+ self._cleaned = False
16
+ super().__init__(self._temp_dir)
17
+
18
+ def close(self):
19
+ if self.auto_clean:
20
+ self.clean()
21
+ super().close()
22
+
23
+ def clean(self):
24
+ if self._cleaned:
25
+ return
26
+
27
+ try:
28
+ shutil.rmtree(self._temp_dir)
29
+ except Exception as e:
30
+ if not self.ignore_clean_errors:
31
+ raise OperationFailed(
32
+ f"failed to remove temporary directory: {self._temp_dir!r}"
33
+ ) from e
34
+ self._cleaned = True
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ import typing
4
+ from pathlib import PurePosixPath
5
+
6
+ from ._errors import DirectoryNotEmpty
7
+
8
+ if typing.TYPE_CHECKING:
9
+ from typing import IO
10
+
11
+ from ._base import FS
12
+
13
+
14
+ def remove_empty(fs: FS, path: str):
15
+ """Remove all empty parents."""
16
+ path = PurePosixPath(path)
17
+ root = PurePosixPath("/")
18
+ try:
19
+ while path != root:
20
+ fs.removedir(path.as_posix())
21
+ path = path.parent
22
+ except DirectoryNotEmpty:
23
+ pass
24
+
25
+
26
+ def copy_file_data(src_file: IO, dst_file: IO, chunk_size: int | None = None):
27
+ """Copy data from one file object to another."""
28
+ _chunk_size = 1024 * 1024 if chunk_size is None else chunk_size
29
+ read = src_file.read
30
+ write = dst_file.write
31
+ # in iter(callable, sentilel), callable is called until it returns the sentinel;
32
+ # this allows to copy `chunk_size` bytes at a time.
33
+ for chunk in iter(lambda: read(_chunk_size) or None, None):
34
+ write(chunk)