flatfs 0.0.0__tar.gz → 0.3.0__tar.gz

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.
@@ -1,8 +1,9 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: flatfs
3
- Version: 0.0.0
3
+ Version: 0.3.0
4
4
  Summary: Minimal filesystem abstraction with pluggable backends, treating paths as keys and file contents as values.
5
5
  License: MIT
6
+ License-File: LICENSE.txt
6
7
  Keywords: flat,filesystem,abstraction,library
7
8
  Author: Maciej Wiatrzyk
8
9
  Author-email: maciej.wiatrzyk@gmail.com
@@ -0,0 +1 @@
1
+ __version__ = "0.3.0"
@@ -0,0 +1,110 @@
1
+ import asyncio
2
+ import datetime
3
+ import functools
4
+ import queue
5
+ from typing import AsyncGenerator, Callable, Generator, TypeVar
6
+
7
+ from flatfs.exc import PathAccessError
8
+
9
+ T = TypeVar("T")
10
+
11
+
12
+ def normalize_path(path: str) -> str:
13
+ """Normalizes path by removing duplicated slash characters and by
14
+ evaluating relative path components (e.g. ``../`` or ``./``).
15
+
16
+ Returns normalized path, or raises :exc:`flatfs.exc.FileAccessError` if
17
+ final path would go beyond filesystem bounds.
18
+
19
+ :param path:
20
+ The path to normalize.
21
+ """
22
+ parts: list[str] = []
23
+ for part in path.split("/"):
24
+ if not part or part == ".":
25
+ continue
26
+ if part == "..":
27
+ if not parts:
28
+ raise PathAccessError(path)
29
+ parts.pop()
30
+ else:
31
+ parts.append(part)
32
+ out = "/".join(parts)
33
+ if out[0] == "/":
34
+ return out
35
+ return "/" + out
36
+
37
+
38
+ async def run_blocking(func: Callable[..., T], *args, **kwargs) -> T:
39
+ """Run blocking (i.e. non-async) task in an event loop's default
40
+ executor.
41
+
42
+ On success, return *func*'s result, on failure raise *func*'s exception.
43
+
44
+ :param func:
45
+ The non-async callable to run.
46
+
47
+ :param `*args`:
48
+ Positional args to be passed to *func*.
49
+
50
+ :param `**kwargs`:
51
+ Named args to be passed to *func*.
52
+ """
53
+ wrapped_func = functools.partial(func, *args, **kwargs)
54
+ return await asyncio.get_running_loop().run_in_executor(None, wrapped_func)
55
+
56
+
57
+ def utcnow() -> datetime.datetime:
58
+ """Get the current date and time in UTC."""
59
+ return datetime.datetime.now(datetime.timezone.utc)
60
+
61
+
62
+ def split_into_chunks(data: bytes, chunk_size: int) -> Generator[bytes, None, None]:
63
+ """Generator that splits given binary data into chunks of provided maximum size.
64
+
65
+ :param data:
66
+ The data to split.
67
+
68
+ :chunk_size:
69
+ The maximum size of a single chunk.
70
+ """
71
+ while chunk := data[:chunk_size]:
72
+ yield chunk
73
+ data = data[chunk_size:]
74
+
75
+
76
+ async def async_split_into_chunks(data: bytes, chunk_size: int) -> AsyncGenerator[bytes, None]:
77
+ """Async variant of the :func:`split_into_chunks` function.
78
+
79
+ :param data:
80
+ The data to split.
81
+
82
+ :param chunk_size:
83
+ The maximum size of a single chunk.
84
+ """
85
+ while chunk := data[:chunk_size]:
86
+ yield chunk
87
+ data = data[chunk_size:]
88
+
89
+
90
+ def generate_chunks_from_queue(chunk_queue: queue.Queue[bytes]) -> Generator[bytes, None, None]:
91
+ """Generator that yields chunks it receives via provided queue until empty
92
+ bytes object is received.
93
+
94
+ :param chunk_queue:
95
+ Chunk queue to adapt.
96
+ """
97
+ while True:
98
+ chunk = chunk_queue.get()
99
+ if not chunk:
100
+ return
101
+ yield chunk
102
+
103
+
104
+ async def async_generate_chunks_from_queue(chunk_queue: asyncio.Queue[bytes]) -> AsyncGenerator[bytes, None]:
105
+ """Async variant of the :func:`generate_chunks_from_queue` function."""
106
+ while True:
107
+ chunk = await chunk_queue.get()
108
+ if not chunk:
109
+ return
110
+ yield chunk
@@ -1,12 +1,14 @@
1
1
  import asyncio
2
+ import dataclasses
3
+ import datetime
2
4
  import fnmatch
3
5
  import os
4
6
  import pathlib
5
7
  from queue import Queue
6
- from typing import AsyncGenerator, AsyncIterable, Generator, Iterable, Iterator, Optional
8
+ from typing import AsyncGenerator, AsyncIterator, Generator, Iterator, Optional
7
9
 
8
10
  from flatfs.exc import PathAccessError, PathNotFoundError
9
- from flatfs.interface import FlatFsReaderWriter
11
+ from flatfs.interface import FlatFsReaderWriter, Stat
10
12
 
11
13
  from . import _utils, _export
12
14
 
@@ -80,11 +82,21 @@ class LocalFlatFs:
80
82
  def scan(self) -> Iterator[str]:
81
83
  return self.__walk(self.__root_dir, "")
82
84
 
85
+ def stat(self, path: str) -> Stat:
86
+ abspath = self.__make_abspath(path)
87
+ if not abspath.is_file():
88
+ raise PathNotFoundError(path)
89
+ stat_result = abspath.stat()
90
+ return Stat(
91
+ modified=datetime.datetime.fromtimestamp(stat_result.st_mtime, datetime.timezone.utc),
92
+ size=stat_result.st_size,
93
+ )
94
+
83
95
  def exists(self, path: str) -> bool:
84
96
  abspath = self.__make_abspath(path)
85
97
  return abspath.is_file()
86
98
 
87
- def read_chunks(self, path: str, chunk_size: int = 65535) -> Iterator[bytes]:
99
+ def read_chunks(self, path: str, chunk_size: int = 65535) -> Generator[bytes, None, None]:
88
100
  abspath = self.__make_abspath(path)
89
101
  if not abspath.is_file():
90
102
  raise PathNotFoundError(path)
@@ -95,12 +107,12 @@ class LocalFlatFs:
95
107
  break
96
108
  yield chunk
97
109
 
98
- def write_chunks(self, path: str, chunks: Iterable[bytes]):
110
+ def write_chunks(self, path: str, chunk_gen: Generator[bytes, None, None]) -> Generator[int, None, None]:
99
111
  abspath = self.__make_abspath(path)
100
112
  abspath.parent.mkdir(parents=True, exist_ok=True)
101
113
  with abspath.open("wb") as fd:
102
- for chunk in chunks:
103
- fd.write(chunk)
114
+ for chunk in chunk_gen:
115
+ yield fd.write(chunk)
104
116
 
105
117
  def remove(self, path: str):
106
118
  abspath = self.__make_abspath(path)
@@ -117,8 +129,13 @@ class InMemoryFlatFs:
117
129
  disk usage.
118
130
  """
119
131
 
132
+ @dataclasses.dataclass
133
+ class _File:
134
+ payload: bytes
135
+ stat: Stat
136
+
120
137
  def __init__(self) -> None:
121
- self.__storage: dict[str, bytes] = {}
138
+ self.__storage: dict[str, InMemoryFlatFs._File] = {}
122
139
 
123
140
  def __make_key(self, path: str) -> str:
124
141
  normalized_key = _utils.normalize_path(path)
@@ -127,21 +144,37 @@ class InMemoryFlatFs:
127
144
  def scan(self) -> Iterator[str]:
128
145
  return iter(self.__storage.keys())
129
146
 
147
+ def stat(self, path: str) -> Stat:
148
+ key = self.__make_key(path)
149
+ if key not in self.__storage:
150
+ raise PathNotFoundError(path)
151
+ return self.__storage[key].stat
152
+
130
153
  def exists(self, path: str) -> bool:
131
154
  return self.__make_key(path) in self.__storage
132
155
 
133
- def read_chunks(self, path: str, chunk_size: int = 65535) -> Iterator[bytes]:
156
+ def read_chunks(self, path: str, chunk_size: int = 65535) -> Generator[bytes, None, None]:
134
157
  key = self.__make_key(path)
135
158
  if key not in self.__storage:
136
159
  raise PathNotFoundError(path)
137
- data_left = self.__storage[key]
160
+ data_left = self.__storage[key].payload
138
161
  while len(data_left) > 0:
139
162
  chunk = data_left[:chunk_size]
140
163
  data_left = data_left[chunk_size:]
141
164
  yield chunk
142
165
 
143
- def write_chunks(self, path: str, chunks: Iterable[bytes]):
144
- self.__storage[self.__make_key(path)] = b"".join(chunks)
166
+ def write_chunks(self, path: str, chunk_gen: Generator[bytes, None, None]) -> Generator[int, None, None]:
167
+ now = _utils.utcnow()
168
+ stat = Stat(
169
+ modified=now,
170
+ size=0,
171
+ )
172
+ self.__storage[self.__make_key(path)] = file = self._File(b"", stat)
173
+ for chunk in chunk_gen:
174
+ file.payload += chunk
175
+ write_count = len(chunk)
176
+ file.stat.size += write_count
177
+ yield write_count
145
178
 
146
179
  def remove(self, path: str):
147
180
  key = self.__make_key(path)
@@ -186,7 +219,10 @@ class AsyncFlatFsAdapter:
186
219
  async def exists(self, path: str) -> bool:
187
220
  return await _utils.run_blocking(self.__target.exists, path)
188
221
 
189
- async def read_chunks(self, path: str, chunk_size: int = 65535) -> AsyncGenerator[bytes, None]:
222
+ async def stat(self, path: str) -> Stat:
223
+ return await _utils.run_blocking(self.__target.stat, path)
224
+
225
+ async def read_chunks(self, path: str, chunk_size: int = 65535) -> AsyncIterator[bytes]:
190
226
 
191
227
  def reader():
192
228
  try:
@@ -207,21 +243,23 @@ class AsyncFlatFsAdapter:
207
243
  raise chunk_or_exc
208
244
  yield chunk_or_exc
209
245
 
210
- async def write_chunks(self, path: str, chunks: AsyncIterable[bytes]):
246
+ async def write_chunks(self, path: str, chunk_gen: AsyncGenerator[bytes, None]) -> AsyncGenerator[int, None]:
211
247
 
212
- def gen() -> Generator[bytes, None, None]:
213
- while True:
214
- chunk = queue.get()
215
- if not chunk:
216
- return
217
- yield chunk
248
+ def blocking_writer():
249
+ for write_count in self.__target.write_chunks(path, _utils.generate_chunks_from_queue(chunk_queue)):
250
+ loop.call_soon_threadsafe(ack_queue.put_nowait, write_count)
218
251
 
219
- queue: Queue[bytes] = Queue()
220
- task = asyncio.create_task(_utils.run_blocking(self.__target.write_chunks, path, gen()))
221
- async for chunk in chunks:
222
- queue.put(chunk)
223
- queue.put(b"")
224
- await task
252
+ loop = asyncio.get_running_loop()
253
+ chunk_queue: Queue[bytes] = Queue()
254
+ ack_queue: asyncio.Queue[int] = asyncio.Queue()
255
+ task = asyncio.create_task(_utils.run_blocking(blocking_writer))
256
+ try:
257
+ async for chunk in chunk_gen:
258
+ chunk_queue.put(chunk)
259
+ yield await ack_queue.get()
260
+ finally:
261
+ chunk_queue.put(b"")
262
+ await task
225
263
 
226
264
  async def remove(self, path: str):
227
265
  await _utils.run_blocking(self.__target.remove, path)
@@ -0,0 +1,449 @@
1
+ import asyncio
2
+ from contextlib import AbstractAsyncContextManager, AbstractContextManager
3
+ from queue import Queue
4
+ from types import TracebackType
5
+ from typing import Iterator, AsyncIterator
6
+
7
+ from . import _export, _utils
8
+ from .interface import SupportsAsyncReadChunks, SupportsAsyncWriteChunks, SupportsReadChunks, SupportsWriteChunks
9
+
10
+ __all__ = export = _export.Export() # type: ignore
11
+
12
+
13
+ @export
14
+ def read_bytes(fs: SupportsReadChunks, path: str, chunk_size: int = 65535) -> bytes:
15
+ """Read whole file and return as bytes.
16
+
17
+ :param fs:
18
+ FlatFS object to read from.
19
+
20
+ :param path:
21
+ Path to a file to read.
22
+
23
+ :param chunk_size:
24
+ The chunk size to use during read.
25
+
26
+ This is the maximum number of bytes to read from a file per single read
27
+ operation.
28
+
29
+ Defaults to 64kB.
30
+ """
31
+ out = b""
32
+ for chunk in fs.read_chunks(path, chunk_size):
33
+ out += chunk
34
+ return out
35
+
36
+
37
+ @export
38
+ async def async_read_bytes(fs: SupportsAsyncReadChunks, path: str, chunk_size: int = 65535) -> bytes:
39
+ """Async variant of the :func:`read_bytes` helper."""
40
+ out = b""
41
+ async for chunk in fs.read_chunks(path, chunk_size):
42
+ out += chunk
43
+ return out
44
+
45
+
46
+ @export
47
+ def write_bytes(fs: SupportsWriteChunks, path: str, data: bytes, chunk_size: int = 65535) -> int:
48
+ """Write given data to file at given path either creating the file, or
49
+ replacing with new content.
50
+
51
+ Returns the number of bytes written.
52
+
53
+ :param fs:
54
+ FlatFS object to write to.
55
+
56
+ :param path:
57
+ The path to a file to create or replace.
58
+
59
+ :param data:
60
+ The data to write.
61
+
62
+ :param chunk_size:
63
+ The chunk size to use during writing.
64
+
65
+ If *data* length is greater, it will be split into chunks and written
66
+ chunk by chunk, with each chunk having at most *chunk_size* bytes.
67
+
68
+ Defaults to 64kB.
69
+ """
70
+ total_bytes = 0
71
+ for byte_count in fs.write_chunks(path, _utils.split_into_chunks(data, chunk_size)):
72
+ total_bytes += byte_count
73
+ return total_bytes
74
+
75
+
76
+ @export
77
+ async def async_write_bytes(fs: SupportsAsyncWriteChunks, path: str, data: bytes, chunk_size: int = 65535) -> int:
78
+ """Async variant of the :func:`write_bytes` helper."""
79
+ total_bytes = 0
80
+ async for byte_count in fs.write_chunks(path, _utils.async_split_into_chunks(data, chunk_size)):
81
+ total_bytes += byte_count
82
+ return total_bytes
83
+
84
+
85
+ @export
86
+ def read_text(fs: SupportsReadChunks, path: str, encoding: str = "utf-8", chunk_size: int = 65535) -> str:
87
+ """Read whole text file and return as string.
88
+
89
+ Uses :func:`read_bytes` underneath and then just decodes its output using
90
+ provided *encoding*, which is ``utf-8`` by default.
91
+
92
+ :param fs:
93
+ FlatFS object to read from.
94
+
95
+ :param path:
96
+ The path to a file to read.
97
+
98
+ :param encoding:
99
+ The encoding to use.
100
+
101
+ :param chunk_size:
102
+ The chunk size to use during reading.
103
+
104
+ This tells how many bytes can at most be read in single pass.
105
+ """
106
+ return read_bytes(fs, path, chunk_size).decode(encoding)
107
+
108
+
109
+ @export
110
+ async def async_read_text(
111
+ fs: SupportsAsyncReadChunks, path: str, encoding: str = "utf-8", chunk_size: int = 65535
112
+ ) -> str:
113
+ """Async variant of the :func:`read_text` helper."""
114
+ return (await async_read_bytes(fs, path, chunk_size)).decode(encoding)
115
+
116
+
117
+ @export
118
+ def write_text(fs: SupportsWriteChunks, path: str, data: str, encoding: str = "utf-8", chunk_size: int = 65535):
119
+ """Create new text file or replace existing with given text data.
120
+
121
+ Uses :func:`write_bytes` underneath and just encodes given *data* with
122
+ provided *encoding*, which is ``utf-8`` by default.
123
+
124
+ :param fs:
125
+ FlatFS object to write to.
126
+
127
+ :param path:
128
+ The path to a file to create or replace.
129
+
130
+ :param encoding:
131
+ The encoding to use.
132
+
133
+ :param chunk_size:
134
+ The chunk size to use during writing.
135
+
136
+ This tells how many bytes can at most be written in a single pass.
137
+ """
138
+ return write_bytes(fs, path, data.encode(encoding), chunk_size)
139
+
140
+
141
+ @export
142
+ async def async_write_text(
143
+ fs: SupportsAsyncWriteChunks, path: str, data: str, encoding: str = "utf-8", chunk_size: int = 65535
144
+ ):
145
+ """Async variant of the :func:`write_text` helper."""
146
+ await async_write_bytes(fs, path, data.encode(encoding), chunk_size)
147
+
148
+
149
+ @export
150
+ class BufferedWriter(AbstractContextManager):
151
+ """Buffered writer for creating new or replacing existing files.
152
+
153
+ It allows efficient writing of arbitrary binary data thanks to the buffer
154
+ being used. Once the length of buffered data exceeds given buffer size, the
155
+ data is flushed to the underlying filesystem and buffer is cleared. This
156
+ allows to limit actual write operations to minimum.
157
+
158
+ This helper can be used as a context manager.
159
+
160
+ :param fs:
161
+ FlatFS object to write to.
162
+
163
+ :param path:
164
+ Path to a file to create or replace.
165
+
166
+ :param buffer_size:
167
+ The size of a buffer, in bytes.
168
+
169
+ This is the maximum amount of data that can be buffered before it is
170
+ flushed to the backend. This is equivalent to the maximum size of a
171
+ single chunk that is written to the underlying filesystem.
172
+ """
173
+
174
+ def __init__(self, fs: SupportsWriteChunks, path: str, buffer_size: int = 65535):
175
+ self.__chunk_queue: Queue[bytes] = Queue()
176
+ self.__buffer_size = buffer_size
177
+ self.__buffer = b""
178
+ self.__writer = fs.write_chunks(path, _utils.generate_chunks_from_queue(self.__chunk_queue))
179
+
180
+ def __enter__(self) -> "BufferedWriter":
181
+ return self
182
+
183
+ def __exit__(
184
+ self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None
185
+ ) -> bool | None:
186
+ return self.close()
187
+
188
+ def __write_chunk(self, chunk: bytes):
189
+ self.__chunk_queue.put(chunk)
190
+ next(self.__writer)
191
+
192
+ def flush(self):
193
+ """Flush the buffer and clear it.
194
+
195
+ This is automatically called on close or context manager exit.
196
+ """
197
+ if not self.__buffer:
198
+ return # Nothing to do
199
+ self.__write_chunk(self.__buffer)
200
+ self.__buffer = b""
201
+
202
+ def close(self):
203
+ """Flush remaining data and close underlying file."""
204
+ self.flush()
205
+ return self.__writer.close()
206
+
207
+ def write(self, data: bytes) -> int:
208
+ """Write data to the buffer.
209
+
210
+ If the buffer is full, it will be flushed and remaining data (if any)
211
+ will be used to initialize emptied buffer.
212
+
213
+ :param data:
214
+ The data to write.
215
+ """
216
+ self.__buffer += data
217
+ while len(self.__buffer) > self.__buffer_size:
218
+ chunk = self.__buffer[: self.__buffer_size]
219
+ self.__write_chunk(chunk)
220
+ self.__buffer = self.__buffer[self.__buffer_size :]
221
+ return len(data)
222
+
223
+
224
+ @export
225
+ class AsyncBufferedWriter(AbstractAsyncContextManager):
226
+ """Async variant of the :class:`BufferedWriter` class."""
227
+
228
+ def __init__(self, fs: SupportsAsyncWriteChunks, path: str, buffer_size: int = 65535):
229
+ self.__chunk_queue: asyncio.Queue[bytes] = asyncio.Queue()
230
+ self.__buffer_size = buffer_size
231
+ self.__buffer = b""
232
+ self.__writer = fs.write_chunks(path, _utils.async_generate_chunks_from_queue(self.__chunk_queue))
233
+
234
+ async def __aenter__(self) -> "AsyncBufferedWriter":
235
+ return self
236
+
237
+ async def __aexit__(
238
+ self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None
239
+ ) -> bool | None:
240
+ return await self.close()
241
+
242
+ async def __write_chunk(self, chunk: bytes):
243
+ self.__chunk_queue.put_nowait(chunk)
244
+ await anext(self.__writer)
245
+
246
+ async def write(self, data: bytes) -> int:
247
+ self.__buffer += data
248
+ while len(self.__buffer) > self.__buffer_size:
249
+ chunk = self.__buffer[: self.__buffer_size]
250
+ await self.__write_chunk(chunk)
251
+ self.__buffer = self.__buffer[self.__buffer_size :]
252
+ return len(data)
253
+
254
+ async def flush(self):
255
+ if not self.__buffer:
256
+ return # Nothing to do
257
+ await self.__write_chunk(self.__buffer)
258
+ self.__buffer = b""
259
+
260
+ async def close(self):
261
+ await self.flush()
262
+ await self.__writer.aclose()
263
+
264
+
265
+ @export
266
+ class BufferedReader(AbstractContextManager):
267
+ """Helper for buffered reading from underlying flat filesystem.
268
+
269
+ If reads from an underlying filesystem chunks of bytes of given maximum
270
+ length and stores it locally for reading. When entire buffer is read, the
271
+ next chunk is fetched from the filesystem. The whole operation is repeated
272
+ until file is fully read or the reader is closed.
273
+
274
+ This helper can be used via context manager.
275
+
276
+ :param fs:
277
+ The filesystem to read from.
278
+
279
+ :param path:
280
+ The path to a file to read.
281
+
282
+ :param buffer_size:
283
+ The size of a read buffer, in bytes.
284
+
285
+ This is also the maximum size of a single chunk read from a file.
286
+ """
287
+
288
+ def __init__(self, fs: SupportsReadChunks, path: str, buffer_size: int = 65535):
289
+ self.__reader = fs.read_chunks(path, chunk_size=buffer_size)
290
+ self.__buffer = b""
291
+
292
+ def __enter__(self) -> "BufferedReader":
293
+ return self
294
+
295
+ def __exit__(
296
+ self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None
297
+ ) -> bool | None:
298
+ return self.close()
299
+
300
+ def close(self):
301
+ self.__buffer = b""
302
+ self.__reader.close()
303
+
304
+ def read(self, count: int) -> bytes:
305
+ if not self.__buffer:
306
+ self.__buffer = next(self.__reader, b"")
307
+ data = self.__buffer[:count]
308
+ self.__buffer = self.__buffer[count:]
309
+ return data
310
+
311
+
312
+ @export
313
+ class AsyncBufferedReader(AbstractAsyncContextManager):
314
+ """Async variant of the :class:`BufferedReader` class."""
315
+
316
+ def __init__(self, fs: SupportsAsyncReadChunks, path: str, buffer_size: int = 65535):
317
+ self.__reader = fs.read_chunks(path, chunk_size=buffer_size)
318
+ self.__buffer = b""
319
+
320
+ async def __aenter__(self) -> "AsyncBufferedReader":
321
+ return self
322
+
323
+ async def __aexit__(
324
+ self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None
325
+ ) -> bool | None:
326
+ return await self.close()
327
+
328
+ async def close(self):
329
+ self.__buffer = b""
330
+ await self.__reader.aclose()
331
+
332
+ async def read(self, count: int) -> bytes:
333
+ if not self.__buffer:
334
+ self.__buffer = await anext(self.__reader, b"")
335
+ data = self.__buffer[:count]
336
+ self.__buffer = self.__buffer[count:]
337
+ return data
338
+
339
+
340
+ @export
341
+ class BufferedTextReader(AbstractContextManager):
342
+ """Equivalent of :class:`BufferedReader` for text files.
343
+
344
+ It reads at most *buffer_size* bytes from underlying file, stores in local
345
+ buffer, and then streams from that buffer until it expires. Once it is
346
+ expired and more data is needed, the next chunk of data is automatically
347
+ read and stored in the buffer.
348
+
349
+ :param fs:
350
+ The filesystem reader object.
351
+
352
+ :param path:
353
+ The path to a text file to read.
354
+
355
+ :param encoding:
356
+ The encoding to use.
357
+
358
+ :param buffer_size:
359
+ The maximum amount of data to read from an underlying file in one pass,
360
+ in bytes.
361
+ """
362
+
363
+ def __init__(self, fs: SupportsReadChunks, path: str, encoding: str = "utf-8", buffer_size: int = 65535):
364
+ self.__reader = fs.read_chunks(path, chunk_size=buffer_size)
365
+ self.__encoding = encoding
366
+ self.__buffer = ""
367
+
368
+ def __read_next_chunk(self) -> str:
369
+ return next(self.__reader, b"").decode(self.__encoding)
370
+
371
+ def __enter__(self) -> "BufferedTextReader":
372
+ return self
373
+
374
+ def __exit__(
375
+ self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None
376
+ ) -> bool | None:
377
+ return self.close()
378
+
379
+ def __iter__(self) -> Iterator[str]:
380
+ while next_chunk := self.__read_next_chunk():
381
+ self.__buffer += next_chunk
382
+ lines = self.__buffer.splitlines()
383
+ if len(lines) > 1:
384
+ for line in lines[:-1]:
385
+ yield line
386
+ self.__buffer = lines[-1]
387
+ yield self.__buffer
388
+
389
+ def read(self, count: int) -> str:
390
+ """Read at most *count* characters.
391
+
392
+ :param count:
393
+ The maximum number of characters to read.
394
+ """
395
+ if not self.__buffer:
396
+ self.__buffer = self.__read_next_chunk()
397
+ out = self.__buffer[:count]
398
+ self.__buffer = self.__buffer[count:]
399
+ return out
400
+
401
+ def close(self):
402
+ """Close this reader.
403
+
404
+ This also closes the underlying file.
405
+ """
406
+ self.__buffer = ""
407
+ self.__reader.close()
408
+
409
+
410
+ @export
411
+ class AsyncBufferedTextReader(AbstractAsyncContextManager):
412
+ """Async variant of the :class:`BufferedTextReader` class."""
413
+
414
+ def __init__(self, fs: SupportsAsyncReadChunks, path: str, encoding: str = "utf-8", buffer_size: int = 65535):
415
+ self.__reader = fs.read_chunks(path, chunk_size=buffer_size)
416
+ self.__encoding = encoding
417
+ self.__buffer = ""
418
+
419
+ async def __read_next_chunk(self) -> str:
420
+ return (await anext(self.__reader, b"")).decode(self.__encoding)
421
+
422
+ async def __aenter__(self) -> "AsyncBufferedTextReader":
423
+ return self
424
+
425
+ async def __aexit__(
426
+ self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None
427
+ ) -> bool | None:
428
+ return await self.close()
429
+
430
+ async def __aiter__(self) -> AsyncIterator[str]:
431
+ while next_chunk := await self.__read_next_chunk():
432
+ self.__buffer += next_chunk
433
+ lines = self.__buffer.splitlines()
434
+ if len(lines) > 1:
435
+ for line in lines[:-1]:
436
+ yield line
437
+ self.__buffer = lines[-1]
438
+ yield self.__buffer
439
+
440
+ async def read(self, count: int) -> str:
441
+ if not self.__buffer:
442
+ self.__buffer = await self.__read_next_chunk()
443
+ out = self.__buffer[:count]
444
+ self.__buffer = self.__buffer[count:]
445
+ return out
446
+
447
+ async def close(self):
448
+ self.__buffer = ""
449
+ await self.__reader.aclose()
@@ -0,0 +1,198 @@
1
+ import dataclasses
2
+ import datetime
3
+ from typing import AsyncGenerator, AsyncIterator, Generator, Iterator, Protocol
4
+
5
+ from . import _export
6
+
7
+ __all__ = export = _export.Export() # type: ignore
8
+
9
+
10
+ @export
11
+ @dataclasses.dataclass
12
+ class Stat:
13
+ """Object containing file stats."""
14
+
15
+ #: The file last modification date and time (in UTC).
16
+ modified: datetime.datetime
17
+
18
+ #: The file size in bytes.
19
+ size: int
20
+
21
+
22
+ @export
23
+ class SupportsReadChunks(Protocol):
24
+ """Protocol for objects that support chunked reading."""
25
+
26
+ def read_chunks(self, path: str, chunk_size: int = 65535) -> Generator[bytes, None, None]:
27
+ """Return generator for reading existing file in chunks of given
28
+ maximum size.
29
+
30
+ If *path* does not point to an existing file
31
+ :exc:`flatfs.exc.PathNotFoundError` will be raised.
32
+
33
+ Closing or expiring the returned generator automatically closes
34
+ underlying file.
35
+
36
+ :param path:
37
+ The path to a file to read.
38
+
39
+ :param chunk_size:
40
+ The maximum chunk size (in bytes).
41
+
42
+ This is the maximum length of a single generated bytes object.
43
+ """
44
+ ...
45
+
46
+
47
+ @export
48
+ class SupportsWriteChunks(Protocol):
49
+ """Protocol for object that support writing in chunks."""
50
+
51
+ def write_chunks(self, path: str, chunk_gen: Generator[bytes, None, None]) -> Generator[int, None, None]:
52
+ """Create or replace file at given path.
53
+
54
+ Returns a generator that consumes a chunk from provided *chunk_gen*,
55
+ writes it to the file, and yields number of bytes written for the
56
+ current chunk.
57
+
58
+ Closing or expiring returned generator automatically closes underlying
59
+ file and flushes what was written so far.
60
+
61
+ :param path:
62
+ The path to a file.
63
+
64
+ :param chunk_gen:
65
+ Generator producing chunks to be written.
66
+ """
67
+ ...
68
+
69
+
70
+ @export
71
+ class FlatFsReader(SupportsReadChunks, Protocol):
72
+ """Read-only interface to access files in the flat filesystem."""
73
+
74
+ def scan(self) -> Iterator[str]:
75
+ """Return iterator that yields normalized paths to existing files."""
76
+ ...
77
+
78
+ def stat(self, path: str) -> Stat:
79
+ """Get stats of a file at given path.
80
+
81
+ This can be used to retrieve file size, file modification time etc.
82
+
83
+ If the file does not exist, :exc:`flatfs.exc.PathNotFoundError` will be
84
+ raised.
85
+
86
+ :param path:
87
+ The path to a file.
88
+ """
89
+ ...
90
+
91
+ def exists(self, path: str) -> bool:
92
+ """Check if path points to an existing file.
93
+
94
+ :param path:
95
+ The path to a file.
96
+ """
97
+ ...
98
+
99
+
100
+ @export
101
+ class FlatFsReaderWriter(FlatFsReader, SupportsWriteChunks, Protocol):
102
+ """Read-write interface to access files in the flat filesystem."""
103
+
104
+ def remove(self, path: str):
105
+ """Remove existing file at given path.
106
+
107
+ Raises :exc:`flatfs.exc.PathNotFoundError` if there was no file to
108
+ remove at given path.
109
+
110
+ :param path:
111
+ The path to a file to remove.
112
+ """
113
+ ...
114
+
115
+
116
+ @export
117
+ class SupportsAsyncReadChunks(Protocol):
118
+ """Async variant of the :class:`SupportsReadChunks` protocol."""
119
+
120
+ def read_chunks(self, path: str, chunk_size: int = 65535) -> AsyncGenerator[bytes, None]:
121
+ """Return a generator for reading file in chunks of given maximum size.
122
+
123
+ This is the async version of :meth:`SupportsReadChunks.read_chunks`
124
+ method.
125
+
126
+ :param path:
127
+ The path to a file to read.
128
+
129
+ :param chunk_size:
130
+ The maximum chunk size, in bytes.
131
+ """
132
+ ...
133
+
134
+
135
+ @export
136
+ class SupportsAsyncWriteChunks(Protocol):
137
+ """Async variant of the :class:`SupportsWriteChunks` protocol."""
138
+
139
+ def write_chunks(self, path: str, chunk_gen: AsyncGenerator[bytes, None]) -> AsyncGenerator[int, None]:
140
+ """Generator for creating or overwriting files.
141
+
142
+ This is the async version of :meth:`SupportsWriteChunks.write_chunks`
143
+ method.
144
+
145
+ :param path:
146
+ The path to a file to create or replace.
147
+
148
+ :param chunk_gen:
149
+ Async generator yielding file chunks.
150
+ """
151
+ ...
152
+
153
+
154
+ @export
155
+ class AsyncFlatFsReader(SupportsAsyncReadChunks, Protocol):
156
+ """Async variant of the :class:`FlatFsReader` protocol."""
157
+
158
+ def scan(self) -> AsyncIterator[str]:
159
+ """Return iterator that yields normalized paths to existing files.
160
+
161
+ This is the async version of :meth:`FlatFsReader.scan` method.
162
+ """
163
+ ...
164
+
165
+ async def stat(self, path: str) -> Stat:
166
+ """Get stats of a file at given path.
167
+
168
+ This is the async version of :meth:`FlatFsReader.stat` method.
169
+
170
+ :param path:
171
+ The path to a file.
172
+ """
173
+ ...
174
+
175
+ async def exists(self, path: str) -> bool:
176
+ """Check if file at given path exists.
177
+
178
+ This is the async version of :meth:`FlatFsReader.exists` method.
179
+
180
+ :param path:
181
+ The path to check.
182
+ """
183
+ ...
184
+
185
+
186
+ @export
187
+ class AsyncFlatFsReaderWriter(AsyncFlatFsReader, SupportsAsyncWriteChunks, Protocol):
188
+ """Async variant of the :class:`FlatFsReaderWriter` protocol."""
189
+
190
+ async def remove(self, path: str):
191
+ """Remove existing file.
192
+
193
+ This is the async version of :meth:`FlatFsReaderWriter.remove` method.
194
+
195
+ :param path:
196
+ The path to a file to remove.
197
+ """
198
+ ...
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "flatfs"
3
- version = "0.0.0"
3
+ version = "0.3.0"
4
4
  description = "Minimal filesystem abstraction with pluggable backends, treating paths as keys and file contents as values."
5
5
  authors = [
6
6
  {name = "Maciej Wiatrzyk", email = "maciej.wiatrzyk@gmail.com"}
@@ -35,3 +35,4 @@ mockify = "^0.14.0"
35
35
  pytest-cov = "^7.1.0"
36
36
  ruff = "^0.15.12"
37
37
  mypy = "^2.0.0"
38
+ bumpify = "^0.5.1"
File without changes
@@ -1,52 +0,0 @@
1
- import asyncio
2
- import functools
3
- from typing import Callable, TypeVar
4
-
5
- from flatfs.exc import PathAccessError
6
-
7
- T = TypeVar("T")
8
-
9
-
10
- def normalize_path(path: str) -> str:
11
- """Normalizes path by removing duplicated slash characters and by
12
- evaluating relative path components (e.g. ``../`` or ``./``).
13
-
14
- Returns normalized path, or raises :exc:`flatfs.exc.FileAccessError` if
15
- final path would go beyond filesystem bounds.
16
-
17
- :param path:
18
- The path to normalize.
19
- """
20
- parts: list[str] = []
21
- for part in path.split("/"):
22
- if not part or part == ".":
23
- continue
24
- if part == "..":
25
- if not parts:
26
- raise PathAccessError(path)
27
- parts.pop()
28
- else:
29
- parts.append(part)
30
- out = "/".join(parts)
31
- if out[0] == "/":
32
- return out
33
- return "/" + out
34
-
35
-
36
- async def run_blocking(func: Callable[..., T], *args, **kwargs) -> T:
37
- """Run blocking (i.e. non-async) task in an event loop's default
38
- executor.
39
-
40
- On success, return *func*'s result, on failure raise *func*'s exception.
41
-
42
- :param func:
43
- The non-async callable to run.
44
-
45
- :param `*args`:
46
- Positional args to be passed to *func*.
47
-
48
- :param `**kwargs`:
49
- Named args to be passed to *func*.
50
- """
51
- wrapped_func = functools.partial(func, *args, **kwargs)
52
- return await asyncio.get_running_loop().run_in_executor(None, wrapped_func)
@@ -1,124 +0,0 @@
1
- from . import _export
2
- from .interface import SupportsAsyncReadChunks, SupportsAsyncWriteChunks, SupportsReadChunks, SupportsWriteChunks
3
-
4
- __all__ = export = _export.Export() # type: ignore
5
-
6
-
7
- @export
8
- def read_bytes(fs: SupportsReadChunks, path: str, chunk_size: int = 65535) -> bytes:
9
- """Read whole file and return as bytes.
10
-
11
- :param fs:
12
- FlatFS object to read from.
13
-
14
- :param path:
15
- Path to a file to read.
16
-
17
- :param chunk_size:
18
- The chunk size to use during read.
19
-
20
- This is the maximum number of bytes to read from a file per single read
21
- operation.
22
-
23
- Defaults to 64kB.
24
- """
25
- out = b""
26
- for chunk in fs.read_chunks(path, chunk_size):
27
- out += chunk
28
- return out
29
-
30
-
31
- @export
32
- def write_bytes(fs: SupportsWriteChunks, path: str, data: bytes, chunk_size: int = 65535):
33
- """Write given data to file at given path either creating the file, or
34
- replacing with new content.
35
-
36
- :param fs:
37
- FlatFS object to write to.
38
-
39
- :param path:
40
- The path to a file to create or replace.
41
-
42
- :param data:
43
- The data to write.
44
-
45
- :param chunk_size:
46
- The chunk size to use during writing.
47
-
48
- If *data* length is greater, it will be split into chunks and written
49
- chunk by chunk, with each chunk having at most *chunk_size* bytes.
50
-
51
- Defaults to 64kB.
52
- """
53
-
54
- def gen():
55
- data_left = data
56
- while True:
57
- chunk = data_left[:chunk_size]
58
- if not chunk:
59
- break
60
- data_left = data_left[chunk_size:]
61
- yield chunk
62
-
63
- fs.write_chunks(path, gen())
64
-
65
-
66
- @export
67
- def read_text(fs: SupportsReadChunks, path: str, encoding: str = "utf-8", chunk_size: int = 65535) -> str:
68
- """Read whole text file and return as string.
69
-
70
- Uses :func:`read_bytes` underneath and then just decodes its output using
71
- provided *encoding*, which is ``utf-8`` by default.
72
- """
73
- return read_bytes(fs, path, chunk_size).decode(encoding)
74
-
75
-
76
- @export
77
- def write_text(fs: SupportsWriteChunks, path: str, data: str, encoding: str = "utf-8", chunk_size: int = 65535):
78
- """Create new text file or replace existing with given text data.
79
-
80
- Uses :func:`write_bytes` underneath and just encodes given *data* with
81
- provided *encoding*, which is ``utf-8`` by default.
82
- """
83
- return write_bytes(fs, path, data.encode(encoding), chunk_size)
84
-
85
-
86
- @export
87
- async def async_read_bytes(fs: SupportsAsyncReadChunks, path: str, chunk_size: int = 65535) -> bytes:
88
- """Same as :func:`read_bytes`, but for async code."""
89
- out = b""
90
- async for chunk in fs.read_chunks(path, chunk_size):
91
- out += chunk
92
- return out
93
-
94
-
95
- @export
96
- async def async_write_bytes(fs: SupportsAsyncWriteChunks, path: str, data: bytes, chunk_size: int = 65535):
97
- """Same as :func:`write_bytes`, but for async code."""
98
-
99
- async def gen():
100
- data_left = data
101
- while True:
102
- chunk = data_left[:chunk_size]
103
- if not chunk:
104
- break
105
- data_left = data_left[chunk_size:]
106
- yield chunk
107
-
108
- await fs.write_chunks(path, gen())
109
-
110
-
111
- @export
112
- async def async_read_text(
113
- fs: SupportsAsyncReadChunks, path: str, encoding: str = "utf-8", chunk_size: int = 65535
114
- ) -> str:
115
- """Same as :func:`read_text`, but for async code."""
116
- return (await async_read_bytes(fs, path, chunk_size)).decode(encoding)
117
-
118
-
119
- @export
120
- async def async_write_text(
121
- fs: SupportsAsyncWriteChunks, path: str, data: str, encoding: str = "utf-8", chunk_size: int = 65535
122
- ):
123
- """Same as :func:`write_text`, but for async code."""
124
- await async_write_bytes(fs, path, data.encode(encoding), chunk_size)
@@ -1,149 +0,0 @@
1
- from typing import AsyncGenerator, AsyncIterable, Iterable, Iterator, Protocol
2
-
3
- from . import _export
4
-
5
- __all__ = export = _export.Export() # type: ignore
6
-
7
-
8
- @export
9
- class SupportsReadChunks(Protocol):
10
- """Protocol for objects that support chunked reading."""
11
-
12
- def read_chunks(self, path: str, chunk_size: int = 65535) -> Iterator[bytes]:
13
- """Read file in chunks and return iterator yielding chunks.
14
-
15
- This method can be safely used with large files without the risk of
16
- getting out of memory.
17
-
18
- If *path* does not point to an existing file,
19
- :exc:`flatfs.exc.PathNotFoundError` will be raised.
20
-
21
- :param path:
22
- The path to a file to read.
23
-
24
- :param chunk_size:
25
- The maximum chunk size (in bytes).
26
-
27
- This is the maximum length of a single generated bytes object.
28
- """
29
- ...
30
-
31
-
32
- @export
33
- class SupportsWriteChunks(Protocol):
34
- """Protocol for object that support writing in chunks."""
35
-
36
- def write_chunks(self, path: str, chunks: Iterable[bytes]):
37
- """Create or overwrite file at given *path* and write chunks of data
38
- generated by provided *chunks* iterable.
39
-
40
- :param path:
41
- The path to a file to create or modify.
42
-
43
- :param chunks:
44
- Iterable providing chunks of bytes to write to a file.
45
- """
46
- ...
47
-
48
-
49
- @export
50
- class FlatFsReader(SupportsReadChunks, Protocol):
51
- """Read-only interface to access files in the flat filesystem."""
52
-
53
- def scan(self) -> Iterator[str]:
54
- """Scan filesystem and generate paths to existing files.
55
-
56
- Each yielded path can later be used with other methods of this
57
- interface.
58
- """
59
- ...
60
-
61
- def exists(self, path: str) -> bool:
62
- """Check if *path* points to an existing file."""
63
- ...
64
-
65
-
66
- @export
67
- class FlatFsReaderWriter(FlatFsReader, SupportsWriteChunks, Protocol):
68
- """Read-write interface to access files in the flat filesystem."""
69
-
70
- def remove(self, path: str):
71
- """Remove file at given *path*.
72
-
73
- Raises :exc:`flatfs.exc.PathNotFoundError` if there was no file to
74
- remove at given *path*.
75
-
76
- :param path:
77
- The path to a file to remove.
78
- """
79
- ...
80
-
81
-
82
- @export
83
- class SupportsAsyncReadChunks(Protocol):
84
- def read_chunks(self, path: str, chunk_size: int = 65535) -> AsyncGenerator[bytes, None]:
85
- """Read file in chunks and return iterator yielding chunks.
86
-
87
- This method can be safely used with large files without the risk of
88
- getting out of memory.
89
-
90
- If *path* does not point to an existing file,
91
- :exc:`flatfs.exc.PathNotFoundError` will be raised.
92
-
93
- :param path:
94
- The path to a file to read.
95
-
96
- :param chunk_size:
97
- The maximum chunk size (in bytes).
98
-
99
- This is the maximum length of a single generated bytes object.
100
- """
101
- ...
102
-
103
-
104
- @export
105
- class SupportsAsyncWriteChunks(Protocol):
106
- async def write_chunks(self, path: str, chunks: AsyncIterable[bytes]):
107
- """Create or overwrite file at given *path* and write chunks of data
108
- generated by provided *chunks* iterable.
109
-
110
- :param path:
111
- The path to a file to create or modify.
112
-
113
- :param chunks:
114
- Iterable providing chunks of bytes to write to a file.
115
- """
116
- ...
117
-
118
-
119
- @export
120
- class AsyncFlatFsReader(SupportsAsyncReadChunks, Protocol):
121
- """Async read-only interface to access files in the flat filesystem."""
122
-
123
- def scan(self) -> AsyncGenerator[str, None]:
124
- """Scan filesystem and generate paths to existing files.
125
-
126
- Each yielded path can later be used with other methods of this
127
- interface.
128
- """
129
- ...
130
-
131
- async def exists(self, path: str) -> bool:
132
- """Check if *path* points to an existing file."""
133
- ...
134
-
135
-
136
- @export
137
- class AsyncFlatFsReaderWriter(AsyncFlatFsReader, SupportsAsyncWriteChunks, Protocol):
138
- """Async read-write interface to access files in the flat filesystem."""
139
-
140
- async def remove(self, path: str):
141
- """Remove file at given *path*.
142
-
143
- Raises :exc:`flatfs.exc.PathNotFoundError` if there was no file to
144
- remove at given *path*.
145
-
146
- :param path:
147
- The path to a file to remove.
148
- """
149
- ...
File without changes
File without changes
File without changes
File without changes
File without changes