]>
Commit | Line | Data |
---|---|---|
e0df8241 JR |
1 | import abc |
2 | import io | |
3 | import itertools | |
4 | import pathlib | |
5 | from typing import Any, BinaryIO, Iterable, Iterator, NoReturn, Text, Optional | |
6 | ||
7 | from ._compat import runtime_checkable, Protocol, StrPath | |
8 | ||
9 | ||
10 | __all__ = ["ResourceReader", "Traversable", "TraversableResources"] | |
11 | ||
12 | ||
13 | class ResourceReader(metaclass=abc.ABCMeta): | |
14 | """Abstract base class for loaders to provide resource reading support.""" | |
15 | ||
16 | @abc.abstractmethod | |
17 | def open_resource(self, resource: Text) -> BinaryIO: | |
18 | """Return an opened, file-like object for binary reading. | |
19 | ||
20 | The 'resource' argument is expected to represent only a file name. | |
21 | If the resource cannot be found, FileNotFoundError is raised. | |
22 | """ | |
23 | # This deliberately raises FileNotFoundError instead of | |
24 | # NotImplementedError so that if this method is accidentally called, | |
25 | # it'll still do the right thing. | |
26 | raise FileNotFoundError | |
27 | ||
28 | @abc.abstractmethod | |
29 | def resource_path(self, resource: Text) -> Text: | |
30 | """Return the file system path to the specified resource. | |
31 | ||
32 | The 'resource' argument is expected to represent only a file name. | |
33 | If the resource does not exist on the file system, raise | |
34 | FileNotFoundError. | |
35 | """ | |
36 | # This deliberately raises FileNotFoundError instead of | |
37 | # NotImplementedError so that if this method is accidentally called, | |
38 | # it'll still do the right thing. | |
39 | raise FileNotFoundError | |
40 | ||
41 | @abc.abstractmethod | |
42 | def is_resource(self, path: Text) -> bool: | |
43 | """Return True if the named 'path' is a resource. | |
44 | ||
45 | Files are resources, directories are not. | |
46 | """ | |
47 | raise FileNotFoundError | |
48 | ||
49 | @abc.abstractmethod | |
50 | def contents(self) -> Iterable[str]: | |
51 | """Return an iterable of entries in `package`.""" | |
52 | raise FileNotFoundError | |
53 | ||
54 | ||
55 | class TraversalError(Exception): | |
56 | pass | |
57 | ||
58 | ||
59 | @runtime_checkable | |
60 | class Traversable(Protocol): | |
61 | """ | |
62 | An object with a subset of pathlib.Path methods suitable for | |
63 | traversing directories and opening files. | |
64 | ||
65 | Any exceptions that occur when accessing the backing resource | |
66 | may propagate unaltered. | |
67 | """ | |
68 | ||
69 | @abc.abstractmethod | |
70 | def iterdir(self) -> Iterator["Traversable"]: | |
71 | """ | |
72 | Yield Traversable objects in self | |
73 | """ | |
74 | ||
75 | def read_bytes(self) -> bytes: | |
76 | """ | |
77 | Read contents of self as bytes | |
78 | """ | |
79 | with self.open('rb') as strm: | |
80 | return strm.read() | |
81 | ||
82 | def read_text(self, encoding: Optional[str] = None) -> str: | |
83 | """ | |
84 | Read contents of self as text | |
85 | """ | |
86 | with self.open(encoding=encoding) as strm: | |
87 | return strm.read() | |
88 | ||
89 | @abc.abstractmethod | |
90 | def is_dir(self) -> bool: | |
91 | """ | |
92 | Return True if self is a directory | |
93 | """ | |
94 | ||
95 | @abc.abstractmethod | |
96 | def is_file(self) -> bool: | |
97 | """ | |
98 | Return True if self is a file | |
99 | """ | |
100 | ||
101 | def joinpath(self, *descendants: StrPath) -> "Traversable": | |
102 | """ | |
103 | Return Traversable resolved with any descendants applied. | |
104 | ||
105 | Each descendant should be a path segment relative to self | |
106 | and each may contain multiple levels separated by | |
107 | ``posixpath.sep`` (``/``). | |
108 | """ | |
109 | if not descendants: | |
110 | return self | |
111 | names = itertools.chain.from_iterable( | |
112 | path.parts for path in map(pathlib.PurePosixPath, descendants) | |
113 | ) | |
114 | target = next(names) | |
115 | matches = ( | |
116 | traversable for traversable in self.iterdir() if traversable.name == target | |
117 | ) | |
118 | try: | |
119 | match = next(matches) | |
120 | except StopIteration: | |
121 | raise TraversalError( | |
122 | "Target not found during traversal.", target, list(names) | |
123 | ) | |
124 | return match.joinpath(*names) | |
125 | ||
126 | def __truediv__(self, child: StrPath) -> "Traversable": | |
127 | """ | |
128 | Return Traversable child in self | |
129 | """ | |
130 | return self.joinpath(child) | |
131 | ||
132 | @abc.abstractmethod | |
133 | def open(self, mode='r', *args, **kwargs): | |
134 | """ | |
135 | mode may be 'r' or 'rb' to open as text or binary. Return a handle | |
136 | suitable for reading (same as pathlib.Path.open). | |
137 | ||
138 | When opening as text, accepts encoding parameters such as those | |
139 | accepted by io.TextIOWrapper. | |
140 | """ | |
141 | ||
142 | @property | |
143 | @abc.abstractmethod | |
144 | def name(self) -> str: | |
145 | """ | |
146 | The base name of this object without any parent references. | |
147 | """ | |
148 | ||
149 | ||
150 | class TraversableResources(ResourceReader): | |
151 | """ | |
152 | The required interface for providing traversable | |
153 | resources. | |
154 | """ | |
155 | ||
156 | @abc.abstractmethod | |
157 | def files(self) -> "Traversable": | |
158 | """Return a Traversable object for the loaded package.""" | |
159 | ||
160 | def open_resource(self, resource: StrPath) -> io.BufferedReader: | |
161 | return self.files().joinpath(resource).open('rb') | |
162 | ||
163 | def resource_path(self, resource: Any) -> NoReturn: | |
164 | raise FileNotFoundError(resource) | |
165 | ||
166 | def is_resource(self, path: StrPath) -> bool: | |
167 | return self.files().joinpath(path).is_file() | |
168 | ||
169 | def contents(self) -> Iterator[str]: | |
170 | return (item.name for item in self.files().iterdir()) |