flatfs 0.0.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.
- flatfs-0.0.0/LICENSE.txt +19 -0
- flatfs-0.0.0/PKG-INFO +113 -0
- flatfs-0.0.0/README.md +95 -0
- flatfs-0.0.0/flatfs/__init__.py +0 -0
- flatfs-0.0.0/flatfs/_export.py +12 -0
- flatfs-0.0.0/flatfs/_utils.py +52 -0
- flatfs-0.0.0/flatfs/api.py +13 -0
- flatfs-0.0.0/flatfs/backends.py +227 -0
- flatfs-0.0.0/flatfs/exc.py +42 -0
- flatfs-0.0.0/flatfs/helpers.py +124 -0
- flatfs-0.0.0/flatfs/interface.py +149 -0
- flatfs-0.0.0/pyproject.toml +37 -0
flatfs-0.0.0/LICENSE.txt
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Copyright 2026 Maciej Wiatrzyk <maciej.wiatrzyk@gmail.com>
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
|
4
|
+
this software and associated documentation files (the “Software”), to deal in
|
|
5
|
+
the Software without restriction, including without limitation the rights to
|
|
6
|
+
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
|
7
|
+
of the Software, and to permit persons to whom the Software is furnished to do
|
|
8
|
+
so, subject to the following conditions:
|
|
9
|
+
|
|
10
|
+
The above copyright notice and this permission notice shall be included in all
|
|
11
|
+
copies or substantial portions of the Software.
|
|
12
|
+
|
|
13
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
14
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
15
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
16
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
17
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
18
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
19
|
+
SOFTWARE.
|
flatfs-0.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: flatfs
|
|
3
|
+
Version: 0.0.0
|
|
4
|
+
Summary: Minimal filesystem abstraction with pluggable backends, treating paths as keys and file contents as values.
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: flat,filesystem,abstraction,library
|
|
7
|
+
Author: Maciej Wiatrzyk
|
|
8
|
+
Author-email: maciej.wiatrzyk@gmail.com
|
|
9
|
+
Requires-Python: >=3.10,<4
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
14
|
+
Project-URL: Bug Tracker, https://github.com/mwiatrzyk/flatfs/issues
|
|
15
|
+
Project-URL: Repository, https://github.com/mwiatrzyk/flatfs
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# FlatFS
|
|
19
|
+
|
|
20
|
+
Minimal filesystem abstraction with pluggable backends, treating paths as keys
|
|
21
|
+
and file contents as values.
|
|
22
|
+
|
|
23
|
+
## About
|
|
24
|
+
|
|
25
|
+
FlatFS provides minimalistic protocol-based API for accessing files. The API
|
|
26
|
+
allows you to:
|
|
27
|
+
|
|
28
|
+
* create files using generator of binary chunks as source of data
|
|
29
|
+
* read files in chunks
|
|
30
|
+
* checking if file exists
|
|
31
|
+
* removing file from the filesystem
|
|
32
|
+
* scanning filesystem to retrieve all the files that are available
|
|
33
|
+
|
|
34
|
+
The API from above is supplied with a rich set of helpers that can simplify
|
|
35
|
+
common actions, like writing/reading whole files in text or binary mode,
|
|
36
|
+
reading files line-by-line, reading exact amount of bytes etc.
|
|
37
|
+
|
|
38
|
+
FlatFS does not use directories; those are just used to organize paths (aka
|
|
39
|
+
keys) and some implementations may use real directories, and the other may not.
|
|
40
|
+
|
|
41
|
+
The library is implemented in a way allowing it to be used with large files
|
|
42
|
+
thanks to the generator-based core API.
|
|
43
|
+
|
|
44
|
+
## Installation
|
|
45
|
+
|
|
46
|
+
The project can be installed directly from PyPI:
|
|
47
|
+
|
|
48
|
+
```shell
|
|
49
|
+
$ pip install flatfs
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
You can alternatively use any of the existing Python package management tools
|
|
53
|
+
(e.g. Poetry or uv) to install the library directly to your project.
|
|
54
|
+
|
|
55
|
+
## Examples
|
|
56
|
+
|
|
57
|
+
Here are some practical examples of how this library can be used:
|
|
58
|
+
|
|
59
|
+
1. Access ``/tmp`` directory:
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
import pathlib
|
|
63
|
+
|
|
64
|
+
from flatfs.api import LocalFlatFs, FlatFsReaderWriter, write_text, read_text
|
|
65
|
+
|
|
66
|
+
def make_temp_fs() -> FlatFsReaderWriter:
|
|
67
|
+
return LocalFlatFs(pathlib.Path("/tmp"))
|
|
68
|
+
|
|
69
|
+
temp_fs = make_temp_fs()
|
|
70
|
+
write_text(temp_fs, "/foo/bar.txt", "content of file") # Creates text file at /tmp/foo/bar.txt
|
|
71
|
+
content = read_text(temp_fs, "/foo/bar.txt") # Read the content back
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
2. Create in-memory flat filesystem (this implementation can be used during testing):
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from flatfs.api import InMemoryFlatFs
|
|
78
|
+
|
|
79
|
+
fs = InMemoryFlatFs()
|
|
80
|
+
fs.write_chunks("foo.txt", [b"spam"])
|
|
81
|
+
paths = list(fs.scan()) # Will return: ["/foo.txt"]
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
3. Wrap flat filesystem created earlier with async interface:
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from flatfs.api import AsyncFlatFsAdapter, async_read_text
|
|
88
|
+
|
|
89
|
+
async_temp_fs = AsyncFlatFsAdapter(temp_fs)
|
|
90
|
+
content = await async_read_text(async_temp_fs, "foo/bar.txt") # Paths are normalized and leading / is automatically added
|
|
91
|
+
print(content) # Would print: content of file
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Rationale
|
|
95
|
+
|
|
96
|
+
During developing of various systems and tools that follow clean architecture
|
|
97
|
+
and layered design (business logic, use case interactors, data ports and
|
|
98
|
+
gateways etc.) I found myself writing similar tool as one of the port/gateway
|
|
99
|
+
pair over and over again. This library was created to have such toolkit a bit
|
|
100
|
+
more advanced, based on chunk generators and producers, backed up with
|
|
101
|
+
comprehensive set of helpers, just to avoid writing it again for the (N+1)th
|
|
102
|
+
time.
|
|
103
|
+
|
|
104
|
+
## Author
|
|
105
|
+
|
|
106
|
+
Maciej Wiatrzyk <maciej.wiatrzyk@gmail.com>
|
|
107
|
+
|
|
108
|
+
## License
|
|
109
|
+
|
|
110
|
+
This project is released under the terms of the MIT license.
|
|
111
|
+
|
|
112
|
+
See LICENSE.txt for more details.
|
|
113
|
+
|
flatfs-0.0.0/README.md
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# FlatFS
|
|
2
|
+
|
|
3
|
+
Minimal filesystem abstraction with pluggable backends, treating paths as keys
|
|
4
|
+
and file contents as values.
|
|
5
|
+
|
|
6
|
+
## About
|
|
7
|
+
|
|
8
|
+
FlatFS provides minimalistic protocol-based API for accessing files. The API
|
|
9
|
+
allows you to:
|
|
10
|
+
|
|
11
|
+
* create files using generator of binary chunks as source of data
|
|
12
|
+
* read files in chunks
|
|
13
|
+
* checking if file exists
|
|
14
|
+
* removing file from the filesystem
|
|
15
|
+
* scanning filesystem to retrieve all the files that are available
|
|
16
|
+
|
|
17
|
+
The API from above is supplied with a rich set of helpers that can simplify
|
|
18
|
+
common actions, like writing/reading whole files in text or binary mode,
|
|
19
|
+
reading files line-by-line, reading exact amount of bytes etc.
|
|
20
|
+
|
|
21
|
+
FlatFS does not use directories; those are just used to organize paths (aka
|
|
22
|
+
keys) and some implementations may use real directories, and the other may not.
|
|
23
|
+
|
|
24
|
+
The library is implemented in a way allowing it to be used with large files
|
|
25
|
+
thanks to the generator-based core API.
|
|
26
|
+
|
|
27
|
+
## Installation
|
|
28
|
+
|
|
29
|
+
The project can be installed directly from PyPI:
|
|
30
|
+
|
|
31
|
+
```shell
|
|
32
|
+
$ pip install flatfs
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
You can alternatively use any of the existing Python package management tools
|
|
36
|
+
(e.g. Poetry or uv) to install the library directly to your project.
|
|
37
|
+
|
|
38
|
+
## Examples
|
|
39
|
+
|
|
40
|
+
Here are some practical examples of how this library can be used:
|
|
41
|
+
|
|
42
|
+
1. Access ``/tmp`` directory:
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
import pathlib
|
|
46
|
+
|
|
47
|
+
from flatfs.api import LocalFlatFs, FlatFsReaderWriter, write_text, read_text
|
|
48
|
+
|
|
49
|
+
def make_temp_fs() -> FlatFsReaderWriter:
|
|
50
|
+
return LocalFlatFs(pathlib.Path("/tmp"))
|
|
51
|
+
|
|
52
|
+
temp_fs = make_temp_fs()
|
|
53
|
+
write_text(temp_fs, "/foo/bar.txt", "content of file") # Creates text file at /tmp/foo/bar.txt
|
|
54
|
+
content = read_text(temp_fs, "/foo/bar.txt") # Read the content back
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
2. Create in-memory flat filesystem (this implementation can be used during testing):
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from flatfs.api import InMemoryFlatFs
|
|
61
|
+
|
|
62
|
+
fs = InMemoryFlatFs()
|
|
63
|
+
fs.write_chunks("foo.txt", [b"spam"])
|
|
64
|
+
paths = list(fs.scan()) # Will return: ["/foo.txt"]
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
3. Wrap flat filesystem created earlier with async interface:
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
from flatfs.api import AsyncFlatFsAdapter, async_read_text
|
|
71
|
+
|
|
72
|
+
async_temp_fs = AsyncFlatFsAdapter(temp_fs)
|
|
73
|
+
content = await async_read_text(async_temp_fs, "foo/bar.txt") # Paths are normalized and leading / is automatically added
|
|
74
|
+
print(content) # Would print: content of file
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Rationale
|
|
78
|
+
|
|
79
|
+
During developing of various systems and tools that follow clean architecture
|
|
80
|
+
and layered design (business logic, use case interactors, data ports and
|
|
81
|
+
gateways etc.) I found myself writing similar tool as one of the port/gateway
|
|
82
|
+
pair over and over again. This library was created to have such toolkit a bit
|
|
83
|
+
more advanced, based on chunk generators and producers, backed up with
|
|
84
|
+
comprehensive set of helpers, just to avoid writing it again for the (N+1)th
|
|
85
|
+
time.
|
|
86
|
+
|
|
87
|
+
## Author
|
|
88
|
+
|
|
89
|
+
Maciej Wiatrzyk <maciej.wiatrzyk@gmail.com>
|
|
90
|
+
|
|
91
|
+
## License
|
|
92
|
+
|
|
93
|
+
This project is released under the terms of the MIT license.
|
|
94
|
+
|
|
95
|
+
See LICENSE.txt for more details.
|
|
File without changes
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from typing import TypeVar
|
|
2
|
+
|
|
3
|
+
T = TypeVar("T")
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Export(list):
|
|
7
|
+
"""Helper for creating ``__all__`` module properties."""
|
|
8
|
+
|
|
9
|
+
def __call__(self, func_or_cls: T) -> T:
|
|
10
|
+
name = getattr(func_or_cls, "__name__")
|
|
11
|
+
self.append(name)
|
|
12
|
+
return func_or_cls
|
|
@@ -0,0 +1,52 @@
|
|
|
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)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""The all-in-one import helper for FlatFS public API."""
|
|
2
|
+
|
|
3
|
+
from .backends import __all__ as _backends_all
|
|
4
|
+
from .exc import __all__ as _exc_all
|
|
5
|
+
from .helpers import __all__ as _helpers_all
|
|
6
|
+
from .interface import __all__ as _interface_all
|
|
7
|
+
|
|
8
|
+
from .backends import *
|
|
9
|
+
from .exc import *
|
|
10
|
+
from .helpers import *
|
|
11
|
+
from .interface import *
|
|
12
|
+
|
|
13
|
+
__all__ = _backends_all + _exc_all + _helpers_all + _interface_all # type: ignore
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import fnmatch
|
|
3
|
+
import os
|
|
4
|
+
import pathlib
|
|
5
|
+
from queue import Queue
|
|
6
|
+
from typing import AsyncGenerator, AsyncIterable, Generator, Iterable, Iterator, Optional
|
|
7
|
+
|
|
8
|
+
from flatfs.exc import PathAccessError, PathNotFoundError
|
|
9
|
+
from flatfs.interface import FlatFsReaderWriter
|
|
10
|
+
|
|
11
|
+
from . import _utils, _export
|
|
12
|
+
|
|
13
|
+
__all__ = export = _export.Export() # type: ignore
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@export
|
|
17
|
+
class LocalFlatFs:
|
|
18
|
+
"""FlatFS implementation using local filesystem to store files.
|
|
19
|
+
|
|
20
|
+
You can use this to access files from an existing directory (the paths are
|
|
21
|
+
then relative to *root_dir*), or to create a completely new filesystem
|
|
22
|
+
inside an empty directory.
|
|
23
|
+
|
|
24
|
+
:param root_dir:
|
|
25
|
+
The path to the root directory where files will be stored.
|
|
26
|
+
|
|
27
|
+
:param exclude:
|
|
28
|
+
The glob patterns of paths to exclude.
|
|
29
|
+
|
|
30
|
+
For example, you may want to exclude ``.git`` directory when you use
|
|
31
|
+
FlatFS to access content of some Git repository.
|
|
32
|
+
|
|
33
|
+
For more details on pattern syntax used check :mod:`fnmatch` which is
|
|
34
|
+
used underneath.
|
|
35
|
+
|
|
36
|
+
:param include:
|
|
37
|
+
The glob patterns of paths to include.
|
|
38
|
+
|
|
39
|
+
These can only be paths from inside root directory. For example, you
|
|
40
|
+
may wish to allow just ``src`` and ``test`` dirs, not all files.
|
|
41
|
+
|
|
42
|
+
For more details on pattern syntax used check :mod:`fnmatch` which is
|
|
43
|
+
used underneath.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(self, root_dir: pathlib.Path, exclude: Optional[set[str]] = None, include: Optional[set[str]] = None):
|
|
47
|
+
self.__root_dir = root_dir
|
|
48
|
+
self.__exclude = exclude or set()
|
|
49
|
+
self.__include = include or set()
|
|
50
|
+
|
|
51
|
+
def __is_excluded(self, normalized_path: str) -> bool:
|
|
52
|
+
for pattern in self.__exclude:
|
|
53
|
+
if fnmatch.fnmatch(normalized_path, pattern):
|
|
54
|
+
return True
|
|
55
|
+
return False
|
|
56
|
+
|
|
57
|
+
def __is_included(self, normalized_path: str) -> bool:
|
|
58
|
+
if not self.__include:
|
|
59
|
+
return True
|
|
60
|
+
for pattern in self.__include:
|
|
61
|
+
if fnmatch.fnmatch(normalized_path, pattern):
|
|
62
|
+
return True
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
def __make_abspath(self, path: str) -> pathlib.Path:
|
|
66
|
+
normalized_path = _utils.normalize_path(path)
|
|
67
|
+
if self.__is_excluded(normalized_path):
|
|
68
|
+
raise PathAccessError(path)
|
|
69
|
+
return self.__root_dir / normalized_path[1:]
|
|
70
|
+
|
|
71
|
+
def __walk(self, path: pathlib.Path, relpath: str):
|
|
72
|
+
for name in os.listdir(path):
|
|
73
|
+
fullpath = path / name
|
|
74
|
+
fullrelpath = f"{relpath}/{name}"
|
|
75
|
+
if fullpath.is_dir():
|
|
76
|
+
yield from self.__walk(fullpath, fullrelpath)
|
|
77
|
+
elif not self.__is_excluded(fullrelpath) and self.__is_included(fullrelpath):
|
|
78
|
+
yield fullrelpath
|
|
79
|
+
|
|
80
|
+
def scan(self) -> Iterator[str]:
|
|
81
|
+
return self.__walk(self.__root_dir, "")
|
|
82
|
+
|
|
83
|
+
def exists(self, path: str) -> bool:
|
|
84
|
+
abspath = self.__make_abspath(path)
|
|
85
|
+
return abspath.is_file()
|
|
86
|
+
|
|
87
|
+
def read_chunks(self, path: str, chunk_size: int = 65535) -> Iterator[bytes]:
|
|
88
|
+
abspath = self.__make_abspath(path)
|
|
89
|
+
if not abspath.is_file():
|
|
90
|
+
raise PathNotFoundError(path)
|
|
91
|
+
with abspath.open("rb") as fd:
|
|
92
|
+
while True:
|
|
93
|
+
chunk = fd.read(chunk_size)
|
|
94
|
+
if not chunk:
|
|
95
|
+
break
|
|
96
|
+
yield chunk
|
|
97
|
+
|
|
98
|
+
def write_chunks(self, path: str, chunks: Iterable[bytes]):
|
|
99
|
+
abspath = self.__make_abspath(path)
|
|
100
|
+
abspath.parent.mkdir(parents=True, exist_ok=True)
|
|
101
|
+
with abspath.open("wb") as fd:
|
|
102
|
+
for chunk in chunks:
|
|
103
|
+
fd.write(chunk)
|
|
104
|
+
|
|
105
|
+
def remove(self, path: str):
|
|
106
|
+
abspath = self.__make_abspath(path)
|
|
107
|
+
if not abspath.is_file():
|
|
108
|
+
raise PathNotFoundError(path)
|
|
109
|
+
abspath.unlink()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@export
|
|
113
|
+
class InMemoryFlatFs:
|
|
114
|
+
"""In-memory FlatFS implementation.
|
|
115
|
+
|
|
116
|
+
This is mostly suitable for use during testing to avoid unnecessary I/O and
|
|
117
|
+
disk usage.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
def __init__(self) -> None:
|
|
121
|
+
self.__storage: dict[str, bytes] = {}
|
|
122
|
+
|
|
123
|
+
def __make_key(self, path: str) -> str:
|
|
124
|
+
normalized_key = _utils.normalize_path(path)
|
|
125
|
+
return normalized_key
|
|
126
|
+
|
|
127
|
+
def scan(self) -> Iterator[str]:
|
|
128
|
+
return iter(self.__storage.keys())
|
|
129
|
+
|
|
130
|
+
def exists(self, path: str) -> bool:
|
|
131
|
+
return self.__make_key(path) in self.__storage
|
|
132
|
+
|
|
133
|
+
def read_chunks(self, path: str, chunk_size: int = 65535) -> Iterator[bytes]:
|
|
134
|
+
key = self.__make_key(path)
|
|
135
|
+
if key not in self.__storage:
|
|
136
|
+
raise PathNotFoundError(path)
|
|
137
|
+
data_left = self.__storage[key]
|
|
138
|
+
while len(data_left) > 0:
|
|
139
|
+
chunk = data_left[:chunk_size]
|
|
140
|
+
data_left = data_left[chunk_size:]
|
|
141
|
+
yield chunk
|
|
142
|
+
|
|
143
|
+
def write_chunks(self, path: str, chunks: Iterable[bytes]):
|
|
144
|
+
self.__storage[self.__make_key(path)] = b"".join(chunks)
|
|
145
|
+
|
|
146
|
+
def remove(self, path: str):
|
|
147
|
+
key = self.__make_key(path)
|
|
148
|
+
if key not in self.__storage:
|
|
149
|
+
raise PathNotFoundError(path)
|
|
150
|
+
del self.__storage[key]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@export
|
|
154
|
+
class AsyncFlatFsAdapter:
|
|
155
|
+
"""Adapter that wraps given :class:`flatfs.interface.FlatFsReaderWriter`
|
|
156
|
+
with async interface.
|
|
157
|
+
|
|
158
|
+
You can use this to adapt e.g. :class:`LocalFlatFs` or
|
|
159
|
+
:class:`InMemoryFlatFs` objects with async interface allowing for
|
|
160
|
+
convenient use with async code. The blocking calls are dispatched to
|
|
161
|
+
background worker which communicates with the caller using queues.
|
|
162
|
+
|
|
163
|
+
:param target:
|
|
164
|
+
The target FlatFS reader-writer to wrap.
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
def __init__(self, target: FlatFsReaderWriter):
|
|
168
|
+
self.__target = target
|
|
169
|
+
|
|
170
|
+
async def scan(self) -> AsyncGenerator[str, None]:
|
|
171
|
+
|
|
172
|
+
def scanner():
|
|
173
|
+
for path in self.__target.scan():
|
|
174
|
+
loop.call_soon_threadsafe(queue.put_nowait, path)
|
|
175
|
+
loop.call_soon_threadsafe(queue.put_nowait, "")
|
|
176
|
+
|
|
177
|
+
loop = asyncio.get_running_loop()
|
|
178
|
+
asyncio.create_task(_utils.run_blocking(scanner))
|
|
179
|
+
queue: asyncio.Queue[str] = asyncio.Queue()
|
|
180
|
+
while True:
|
|
181
|
+
path = await queue.get()
|
|
182
|
+
if not path:
|
|
183
|
+
break
|
|
184
|
+
yield path
|
|
185
|
+
|
|
186
|
+
async def exists(self, path: str) -> bool:
|
|
187
|
+
return await _utils.run_blocking(self.__target.exists, path)
|
|
188
|
+
|
|
189
|
+
async def read_chunks(self, path: str, chunk_size: int = 65535) -> AsyncGenerator[bytes, None]:
|
|
190
|
+
|
|
191
|
+
def reader():
|
|
192
|
+
try:
|
|
193
|
+
for chunk in self.__target.read_chunks(path, chunk_size):
|
|
194
|
+
loop.call_soon_threadsafe(queue.put_nowait, chunk)
|
|
195
|
+
loop.call_soon_threadsafe(queue.put_nowait, b"")
|
|
196
|
+
except Exception as e:
|
|
197
|
+
loop.call_soon_threadsafe(queue.put_nowait, e)
|
|
198
|
+
|
|
199
|
+
loop = asyncio.get_running_loop()
|
|
200
|
+
queue: asyncio.Queue[bytes | Exception] = asyncio.Queue()
|
|
201
|
+
asyncio.create_task(_utils.run_blocking(reader))
|
|
202
|
+
while True:
|
|
203
|
+
chunk_or_exc = await queue.get()
|
|
204
|
+
if not chunk_or_exc:
|
|
205
|
+
break
|
|
206
|
+
if isinstance(chunk_or_exc, Exception):
|
|
207
|
+
raise chunk_or_exc
|
|
208
|
+
yield chunk_or_exc
|
|
209
|
+
|
|
210
|
+
async def write_chunks(self, path: str, chunks: AsyncIterable[bytes]):
|
|
211
|
+
|
|
212
|
+
def gen() -> Generator[bytes, None, None]:
|
|
213
|
+
while True:
|
|
214
|
+
chunk = queue.get()
|
|
215
|
+
if not chunk:
|
|
216
|
+
return
|
|
217
|
+
yield chunk
|
|
218
|
+
|
|
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
|
|
225
|
+
|
|
226
|
+
async def remove(self, path: str):
|
|
227
|
+
await _utils.run_blocking(self.__target.remove, path)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
from . import _export
|
|
4
|
+
|
|
5
|
+
__all__ = export = _export.Export() # type: ignore
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@export
|
|
9
|
+
class FlatFsError(Exception):
|
|
10
|
+
"""Base class for exceptions raised by this library."""
|
|
11
|
+
|
|
12
|
+
#: Message template or ``None`` to use default ``__str__`` implementation.
|
|
13
|
+
__message_template__: Optional[str] = None
|
|
14
|
+
|
|
15
|
+
def __str__(self) -> str:
|
|
16
|
+
if self.__message_template__ is None:
|
|
17
|
+
return super().__str__()
|
|
18
|
+
return self.__message_template__.format(self=self)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@export
|
|
22
|
+
class PathError(FlatFsError):
|
|
23
|
+
"""Base class for path-related errors."""
|
|
24
|
+
|
|
25
|
+
__message_template__ = "{self.path}"
|
|
26
|
+
|
|
27
|
+
#: Path that caused error.
|
|
28
|
+
path: str
|
|
29
|
+
|
|
30
|
+
def __init__(self, path: str):
|
|
31
|
+
super().__init__()
|
|
32
|
+
self.path = path
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@export
|
|
36
|
+
class PathNotFoundError(PathError):
|
|
37
|
+
"""Raised when file was not found."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@export
|
|
41
|
+
class PathAccessError(PathError):
|
|
42
|
+
"""Raised when file that is tried to be accessed lays beyond filesystem bounds."""
|
|
@@ -0,0 +1,124 @@
|
|
|
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)
|
|
@@ -0,0 +1,149 @@
|
|
|
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
|
+
...
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "flatfs"
|
|
3
|
+
version = "0.0.0"
|
|
4
|
+
description = "Minimal filesystem abstraction with pluggable backends, treating paths as keys and file contents as values."
|
|
5
|
+
authors = [
|
|
6
|
+
{name = "Maciej Wiatrzyk", email = "maciej.wiatrzyk@gmail.com"}
|
|
7
|
+
]
|
|
8
|
+
license = {text = "MIT"}
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
keywords = ["flat", "filesystem", "abstraction", "library"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 4 - Beta",
|
|
13
|
+
#"Development Status :: 5 - Production/Stable" <- Use this for first 1.x version
|
|
14
|
+
"Intended Audience :: Developers",
|
|
15
|
+
"Operating System :: OS Independent",
|
|
16
|
+
"Topic :: Software Development :: Libraries",
|
|
17
|
+
]
|
|
18
|
+
requires-python = ">=3.10,<4"
|
|
19
|
+
dependencies = [
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
repository = "https://github.com/mwiatrzyk/flatfs"
|
|
24
|
+
"Bug Tracker" = "https://github.com/mwiatrzyk/flatfs/issues"
|
|
25
|
+
|
|
26
|
+
[build-system]
|
|
27
|
+
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
|
28
|
+
build-backend = "poetry.core.masonry.api"
|
|
29
|
+
|
|
30
|
+
[tool.poetry.group.dev.dependencies]
|
|
31
|
+
pytest = "^9.0.3"
|
|
32
|
+
invoke = "^3.0.3"
|
|
33
|
+
pytest-asyncio = "^1.3.0"
|
|
34
|
+
mockify = "^0.14.0"
|
|
35
|
+
pytest-cov = "^7.1.0"
|
|
36
|
+
ruff = "^0.15.12"
|
|
37
|
+
mypy = "^2.0.0"
|