├── requirements.txt ├── mafs ├── __init__.py ├── router.py ├── file.py ├── filesystem.py └── mafs.py ├── examples ├── places.py ├── numbers.py ├── big-brother.py └── dict.py ├── setup.py ├── LICENSE.txt ├── .gitignore ├── README.md └── tests └── test_router.py /requirements.txt: -------------------------------------------------------------------------------- 1 | fusepy 2 | -------------------------------------------------------------------------------- /mafs/__init__.py: -------------------------------------------------------------------------------- 1 | from .mafs import MagicFS 2 | from .mafs import FileType 3 | 4 | __all__ = ['MagicFS', 'FileNotFoundError', 'FileType'] 5 | -------------------------------------------------------------------------------- /examples/places.py: -------------------------------------------------------------------------------- 1 | from mafs import MagicFS 2 | 3 | fs = MagicFS() 4 | 5 | 6 | @fs.read('/place/here') 7 | def place_here(path, ps): 8 | return 'this is here\n' 9 | 10 | 11 | @fs.read('/place/there') 12 | def place_there(path, ps): 13 | return 'this is there\n' 14 | 15 | 16 | @fs.read('/place/:any') 17 | def place_any(path, ps): 18 | return 'this is ' + ps.any + '!\n' 19 | 20 | 21 | @fs.readlink('/shortcut') 22 | def shortcut(path, ps): 23 | return './place/a quicker way' 24 | 25 | 26 | fs.run() 27 | -------------------------------------------------------------------------------- /examples/numbers.py: -------------------------------------------------------------------------------- 1 | from mafs import MagicFS 2 | 3 | fs = MagicFS() 4 | 5 | 6 | @fs.read('/table') 7 | def numbers(path, ps): 8 | for i in range(10): 9 | for j in range(10): 10 | yield '{:>2} '.format(i * 10 + j) 11 | yield '\n' 12 | 13 | 14 | number = 5 15 | 16 | 17 | @fs.read('/multiple') 18 | def multiple_read(path, ps): 19 | for i in range(12): 20 | yield str(number + i * number) + ' ' 21 | yield '\n' 22 | 23 | 24 | @fs.write('/multiple') 25 | def multiple_write(path, ps): 26 | def callback(contents): 27 | global number 28 | number = int(contents.strip()) 29 | return callback 30 | 31 | 32 | fs.run() 33 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | with open('README.md', 'r') as fh: 4 | long_description = fh.read() 5 | 6 | setuptools.setup( 7 | name='mafs', 8 | version='0.2.1', 9 | license='MIT', 10 | 11 | author='Justin Chadwell', 12 | author_email='jedevc@gmail.com', 13 | 14 | url='https://github.com/jedevc/mafs', 15 | description='Quickly conjure up virtual fileysystems', 16 | long_description=long_description, 17 | long_description_content_type='text/markdown', 18 | 19 | packages=setuptools.find_packages(), 20 | install_requires=[ 21 | 'fusepy' 22 | ], 23 | 24 | classifiers=[ 25 | 'Development Status :: 3 - Alpha', 26 | 'Intended Audience :: Developers', 27 | 'License :: OSI Approved :: MIT License', 28 | 'Operating System :: POSIX :: Linux', 29 | 'Programming Language :: Python :: 3', 30 | 'Topic :: Software Development :: Libraries', 31 | 'Topic :: System :: Filesystems' 32 | ] 33 | ) 34 | -------------------------------------------------------------------------------- /examples/big-brother.py: -------------------------------------------------------------------------------- 1 | import mafs 2 | 3 | import os.path 4 | import pathlib 5 | 6 | fs = mafs.MagicFS() 7 | 8 | 9 | def prefix(f): 10 | PREFIX = str(pathlib.Path.home()) 11 | return os.path.join(PREFIX, f.strip('/')) 12 | 13 | 14 | @fs.read('*file', encoding=None) 15 | def read(path, ps): 16 | print('read', path) 17 | return open(prefix(path), 'rb') 18 | 19 | 20 | @fs.write('*file', encoding=None) 21 | def write(path, ps): 22 | print('write', path) 23 | return open(prefix(path), 'wb') 24 | 25 | 26 | @fs.stat('*file') 27 | def stat(path, ps): 28 | print('stat', path) 29 | stat = os.stat(prefix(path)) 30 | 31 | return { 32 | 'st_mode': stat.st_mode, 33 | 'st_nlink': stat.st_nlink, 34 | 'st_uid': stat.st_uid, 35 | 'st_gid': stat.st_gid, 36 | 'st_size': stat.st_size, 37 | 'st_atime': stat.st_atime, 38 | 'st_mtime': stat.st_mtime, 39 | 'st_ctime': stat.st_ctime 40 | } 41 | 42 | 43 | @fs.list('/') 44 | @fs.list('*file') 45 | def list(path, ps): 46 | print('list', path) 47 | return os.listdir(prefix(path)) 48 | 49 | 50 | fs.run() 51 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Justin Chadwell 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /examples/dict.py: -------------------------------------------------------------------------------- 1 | import mafs 2 | 3 | import json 4 | 5 | fs = mafs.MagicFS() 6 | fs.add_argument('file', help='json file to read from') 7 | 8 | # read json file 9 | with open(fs.args.file) as f: 10 | items = json.load(f) 11 | 12 | 13 | def dig(d, parts): 14 | if parts: 15 | try: 16 | res = d.get(parts[0]) 17 | if res: 18 | return dig(res, parts[1:]) 19 | except (KeyError, AttributeError): 20 | return None 21 | else: 22 | return d 23 | 24 | 25 | @fs.read('/*item') 26 | def read_item(path, ps): 27 | return str(dig(items, ps.item)) + '\n' 28 | 29 | 30 | @fs.list('/') 31 | def list_root(path, ps): 32 | return items.keys() 33 | 34 | 35 | @fs.list('/*item') 36 | def list_item(path, ps): 37 | return dig(items, ps.item).keys() 38 | 39 | 40 | @fs.stat('/*item') 41 | def stat_item(path, ps): 42 | item = dig(items, ps.item) 43 | 44 | if item: 45 | if hasattr(item, 'get'): 46 | return {'st_mode': 0o755 | mafs.FileType.DIRECTORY} 47 | else: 48 | return {} 49 | 50 | raise FileNotFoundError() 51 | 52 | 53 | fs.run() 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MagicFS (mafs) 2 | 3 | MagicFS is an easy-to-use library that allows anyone to easily create virtual 4 | filesystems using FUSE. 5 | 6 | MagicFS allows you to redirect file requests, so instead of the request going to 7 | an underlying storage medium like a hard drive, the request goes to a program 8 | that you've written. 9 | 10 | If you like the idea of playing around with virtual filesystems, but have been 11 | put off by the complexity of it all, then this library could be for you. You can 12 | easily create whole, feature-complete filesystems in just a few lines of code. 13 | No need for painstakingly dealing with folder structures and buffers, mafs 14 | manages all the low-level details, provides sane defaults, and lets you focus on 15 | the functionality. 16 | 17 | ## Installation 18 | 19 | MagicFS is available on [pypi](https://pypi.org/project/mafs/), and can be 20 | easily installed with pip. 21 | 22 | $ pip3 install mafs 23 | 24 | ## Examples 25 | 26 | All of the examples are listed in `examples/`. Here's a demo of running the 27 | `places.py` example. 28 | 29 | $ mkdir fs 30 | $ python3 examples/places.py fs 31 | $ ls fs 32 | place shortcut 33 | $ ls fs/place 34 | here there 35 | $ cat fs/place/here 36 | this is here 37 | $ cat fs/place/there 38 | this is there 39 | $ cat fs/place/anywhere 40 | this is anywhere! 41 | $ fusermount -u fs 42 | 43 | ## Development 44 | 45 | To download MagicFS for development, execute the following commands: 46 | 47 | $ git clone https://github.com/jedevc/mafs.git 48 | $ cd mafs 49 | $ pip3 install -r requirements.txt 50 | 51 | To launch mafs with an example, execute the following: 52 | 53 | $ PYTHONPATH=. python3 examples/places.py fs -fg 54 | 55 | Note the use of the `PYTHONPATH` environment variable to include the 56 | library, and the use of the `-fg` flag to run mafs in the foreground for 57 | easier debugging. 58 | 59 | ### Tests 60 | 61 | To run the tests for MagicFS, install nose, and then use it to run the tests. 62 | 63 | $ pip install nose 64 | $ nosetests 65 | 66 | If you make any changes, please run the tests before you commit to ensure that 67 | you haven't broken anything. 68 | -------------------------------------------------------------------------------- /tests/test_router.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from mafs import router 4 | 5 | 6 | class RouterTests(unittest.TestCase): 7 | def test_add(self): 8 | r = router.Router() 9 | r.add('/foo', 'in foo') 10 | self.assertEqual(r.lookup('/foo').data, 'in foo') 11 | 12 | # should not be able to override 13 | with self.assertRaises(router.RoutingError): 14 | r.add('/foo', 'in foo again') 15 | 16 | def test_lookup(self): 17 | r = router.Router() 18 | r.add('/valid/test', 'in valid/test') 19 | 20 | na = r.lookup('/invalid') 21 | self.assertEqual(na, None) 22 | 23 | folder = r.lookup('/valid') 24 | self.assertNotEqual(folder, None) 25 | self.assertEqual(folder.data, None) 26 | 27 | fil = r.lookup('/valid/test') 28 | self.assertNotEqual(fil, None) 29 | self.assertEqual(fil.data, 'in valid/test') 30 | 31 | def test_lookup_parameters(self): 32 | r = router.Router() 33 | 34 | # single parameter 35 | r.add('/single/:file', 'in single') 36 | result = r.lookup('/single/foo') 37 | self.assertEqual(result.parameters.file, 'foo') 38 | 39 | # multi parameter 40 | r.add('/double/:folder/:file', 'in double') 41 | result = r.lookup('/double/foo/bar') 42 | self.assertEqual(result.parameters.folder, 'foo') 43 | self.assertEqual(result.parameters.file, 'bar') 44 | 45 | # fallback 46 | r.add('/fallback/foo', 'in foo') 47 | r.add('/fallback/:file', 'in fallback') 48 | self.assertEqual(r.lookup('/fallback/foo').data, 'in foo') 49 | self.assertEqual(r.lookup('/fallback/other').data, 'in fallback') 50 | 51 | def test_lookup_strings(self): 52 | # normal path 53 | r = router.Router() 54 | r.add('/test/here', 'in test/here') 55 | self.assertEqual(r.lookup('test/here').data, 'in test/here') 56 | self.assertEqual(r.lookup('/test/here').data, 'in test/here') 57 | self.assertEqual(r.lookup('/test//here').data, 'in test/here') 58 | 59 | # without / prefix 60 | r = router.Router() 61 | r.add('test/here', 'in test/here') 62 | self.assertEqual(r.lookup('test/here').data, 'in test/here') 63 | self.assertEqual(r.lookup('/test/here').data, 'in test/here') 64 | self.assertEqual(r.lookup('/test//here').data, 'in test/here') 65 | 66 | # with duplicates 67 | r = router.Router() 68 | r.add('test//here', 'in test/here') 69 | self.assertEqual(r.lookup('test/here').data, 'in test/here') 70 | self.assertEqual(r.lookup('/test/here').data, 'in test/here') 71 | self.assertEqual(r.lookup('/test//here').data, 'in test/here') 72 | 73 | def test_list(self): 74 | r = router.Router() 75 | r.add('/foo', 'in foo') 76 | r.add('/bar', 'in bar') 77 | r.add('/baz', 'in baz') 78 | 79 | self.assertEqual(r.list('/').data, ['foo', 'bar', 'baz']) 80 | 81 | 82 | if __name__ == "__main__": 83 | unittest.main() 84 | -------------------------------------------------------------------------------- /mafs/router.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from collections import namedtuple 4 | 5 | 6 | class Router: 7 | def __init__(self): 8 | self.root = Node() 9 | 10 | def add(self, route, data): 11 | route = self._split_route(route) 12 | 13 | self.root.add(route, data) 14 | 15 | def lookup(self, route): 16 | route = self._split_route(route) 17 | 18 | result = self.root.find(route) 19 | if result: 20 | result.data = result.data.final 21 | return result 22 | 23 | def list(self, route): 24 | route = self._split_route(route) 25 | 26 | result = self.root.find(route) 27 | if result: 28 | keys = result.data.routes.keys() 29 | keys = list(keys) 30 | result.data = keys 31 | return result 32 | 33 | def _split_route(self, route): 34 | route = os.path.normpath(route) 35 | if route == '/': 36 | return [] 37 | else: 38 | route = route.strip('/') 39 | return route.split('/') 40 | 41 | 42 | class Node: 43 | def __init__(self): 44 | self.final = None 45 | 46 | self.routes = {} 47 | self.vroutes = {} 48 | self.rroutes = {} 49 | 50 | def add(self, route, data): 51 | if route: 52 | first, rest = route[0], route[1:] 53 | 54 | if first.startswith(':'): 55 | first = first[1:] 56 | if first not in self.vroutes: 57 | self.vroutes[first] = Node() 58 | self.vroutes[first].add(rest, data) 59 | elif first.startswith('*'): 60 | first = first[1:] 61 | if first not in self.rroutes: 62 | self.rroutes[first] = Node() 63 | self.rroutes[first].add(rest, data) 64 | else: 65 | if first not in self.routes: 66 | self.routes[first] = Node() 67 | self.routes[first].add(rest, data) 68 | else: 69 | if self.final: 70 | raise RoutingError('node already has assigned value') 71 | self.final = data 72 | 73 | def find(self, route): 74 | if route: 75 | first, rest = route[0], route[1:] 76 | 77 | if first in self.routes: 78 | result = self.routes[first].find(rest) 79 | if result: 80 | return result 81 | 82 | for var in self.vroutes: 83 | result = self.vroutes[var].find(rest) 84 | if result: 85 | result.parameter(var, first) 86 | return result 87 | 88 | for var in self.rroutes: 89 | vals = [] 90 | while rest: 91 | vals.append(first) 92 | 93 | result = self.rroutes[var].find(rest) 94 | if result: 95 | result.parameter(var, vals) 96 | return result 97 | 98 | first, rest = rest[0], rest[1:] 99 | 100 | vals.append(first) 101 | result = Result(self.rroutes[var]) 102 | result.parameter(var, vals) 103 | return result 104 | 105 | return None 106 | else: 107 | return Result(self) 108 | 109 | 110 | class Result: 111 | def __init__(self, data): 112 | self.data = data 113 | 114 | self._parameters = {} 115 | 116 | def parameter(self, param, data): 117 | self._parameters[param] = data 118 | 119 | @property 120 | def parameters(self): 121 | Parameters = namedtuple('Parameters', self._parameters.keys()) 122 | return Parameters(**self._parameters) 123 | 124 | 125 | class RoutingError(Exception): 126 | pass 127 | -------------------------------------------------------------------------------- /mafs/file.py: -------------------------------------------------------------------------------- 1 | import errno 2 | import fuse 3 | import inspect 4 | 5 | 6 | class FileReader: 7 | def create(contents, encoding): 8 | READERS = [FileReader.Raw, FileReader.File, 9 | FileReader.Function, FileReader.Iterable] 10 | 11 | for reader in READERS: 12 | r = reader.create(contents, encoding) 13 | if r: 14 | return r 15 | 16 | raise FileError(str(contents) + ' cannot be used as a file reader') 17 | 18 | class Raw: 19 | @staticmethod 20 | def create(contents, encoding): 21 | try: 22 | return FileReader.Raw(contents.encode(encoding)) 23 | except AttributeError: 24 | return None 25 | 26 | def __init__(self, contents): 27 | self.contents = contents 28 | 29 | def read(self, length, offset): 30 | return self.contents[offset:offset + length] 31 | 32 | def release(self): 33 | pass 34 | 35 | class File: 36 | @staticmethod 37 | def create(contents, encoding): 38 | if hasattr(contents, 'read') and hasattr(contents, 'write'): 39 | return FileReader.File(contents, encoding) 40 | 41 | def __init__(self, file, encoding=None): 42 | self.file = file 43 | self.encoding = encoding 44 | 45 | def read(self, length, offset): 46 | self.file.seek(offset) 47 | data = self.file.read(length) 48 | if self.encoding: 49 | data = data.encode(self.encoding) 50 | return data 51 | 52 | def release(self): 53 | self.file.close() 54 | 55 | class Function: 56 | @staticmethod 57 | def create(contents, encoding): 58 | if hasattr(contents, '__call__') and _arg_count(contents) == 2: 59 | return FileReader.Function(contents, encoding) 60 | 61 | def __init__(self, func, encoding): 62 | self.func = func 63 | self.encoding = encoding 64 | 65 | def read(self, length, offset): 66 | return self.func(length, offset) 67 | 68 | def release(self): 69 | pass 70 | 71 | class Iterable: 72 | @staticmethod 73 | def create(contents, encoding): 74 | try: 75 | return FileReader.Iterable(iter(contents), encoding) 76 | except TypeError: 77 | return None 78 | 79 | def __init__(self, iterable, encoding): 80 | self.generator = iterable 81 | self.cache = bytes() 82 | self.encoding = encoding 83 | 84 | def read(self, length, offset): 85 | # read data into cache if provided by an iterable 86 | while self.generator and len(self.cache) < offset + length: 87 | try: 88 | part = next(self.generator) 89 | if self.encoding: 90 | part = part.encode(self.encoding) 91 | self.cache += part 92 | except StopIteration: 93 | self.generator = None 94 | 95 | # provide requested data from the cache 96 | return self.cache[offset:offset + length] 97 | 98 | def release(self): 99 | pass 100 | 101 | 102 | class FileWriter: 103 | def create(contents, encoding): 104 | for writer in [FileWriter.Function, FileWriter.Full, FileWriter.File]: 105 | w = writer.create(contents, encoding) 106 | if w: 107 | return w 108 | 109 | raise FileError(str(contents) + ' cannot be used as a file writer') 110 | 111 | class Function: 112 | def create(contents, encoding): 113 | if hasattr(contents, '__call__') and _arg_count(contents) == 2: 114 | return FileWriter.Function(contents) 115 | 116 | def __init__(self, func): 117 | self.func = func 118 | 119 | def write(self, data, offset): 120 | self.func(data, offset) 121 | return len(data) 122 | 123 | def release(self): 124 | pass 125 | 126 | class Full: 127 | def create(contents, encoding): 128 | if hasattr(contents, '__call__') and _arg_count(contents) == 1: 129 | return FileWriter.Full(contents, encoding) 130 | 131 | def __init__(self, callback, encoding): 132 | self.callback = callback 133 | self.encoding = encoding 134 | 135 | self.cache = [] 136 | 137 | def write(self, data, offset): 138 | # extend cache size 139 | ldiff = len(data) - len(self.cache) 140 | if ldiff > 0: 141 | self.cache.extend([None] * ldiff) 142 | 143 | self.cache[offset:offset + len(data)] = data 144 | return len(data) 145 | 146 | def release(self): 147 | try: 148 | self.callback(bytes(self.cache).decode(self.encoding)) 149 | except ValueError: 150 | raise fuse.FuseOSError(errno.EIO) 151 | 152 | class File: 153 | def create(contents, encoding): 154 | if hasattr(contents, 'read') and hasattr(contents, 'write'): 155 | return FileWriter.File(contents, encoding) 156 | 157 | def __init__(self, file, encoding): 158 | self.file = file 159 | self.encoding = encoding 160 | 161 | def write(self, data, offset): 162 | self.file.seek(offset) 163 | if self.encoding: 164 | data = data.decode(self.encoding) 165 | return self.file.write(data) 166 | 167 | def release(self): 168 | self.file.close() 169 | 170 | 171 | class FileError(Exception): 172 | pass 173 | 174 | 175 | def _arg_count(func): 176 | return len(inspect.signature(func).parameters) 177 | -------------------------------------------------------------------------------- /mafs/filesystem.py: -------------------------------------------------------------------------------- 1 | import fuse 2 | 3 | import os 4 | import stat 5 | import errno 6 | 7 | from enum import Enum 8 | 9 | import time 10 | import itertools 11 | 12 | from . import router 13 | from . import file 14 | 15 | 16 | class FileSystem(fuse.Operations): 17 | def __init__(self): 18 | self.routers = {method: router.Router() for method in Method} 19 | self.readers = {} 20 | self.writers = {} 21 | 22 | self.fh = 0 23 | 24 | self.timestamp = time.time() 25 | 26 | # Filesystem methods 27 | # ================== 28 | 29 | def getattr(self, path, fi=None): 30 | reader = self.routers[Method.READ].lookup(path) 31 | writer = self.routers[Method.WRITE].lookup(path) 32 | 33 | # FIXME: alert when reader and writer contradict each other 34 | if reader and reader.data or writer and writer.data: 35 | ftype = stat.S_IFREG 36 | permissions = 0 37 | if reader and reader.data: 38 | permissions |= stat.S_IRUSR | stat.S_IRGRP | stat.S_IROTH 39 | if writer and writer.data: 40 | permissions |= stat.S_IWUSR 41 | elif reader or writer: 42 | ftype = stat.S_IFDIR 43 | permissions = 0o755 44 | else: 45 | link = self.routers[Method.READLINK].lookup(path) 46 | if link and link.data: 47 | ftype = stat.S_IFLNK 48 | permissions = 0o755 49 | else: 50 | # cannot find in router 51 | raise fuse.FuseOSError(errno.ENOENT) 52 | 53 | uid, gid, _ = fuse.fuse_get_context() 54 | base = { 55 | 'st_atime': self.timestamp, 56 | 'st_ctime': self.timestamp, 57 | 'st_mtime': self.timestamp, 58 | 59 | 'st_gid': gid, 60 | 'st_uid': uid, 61 | 62 | 'st_mode': ftype | permissions, 63 | 'st_nlink': 1, 64 | 'st_size': 0 65 | } 66 | 67 | statter = self.routers[Method.STAT].lookup(path) 68 | if statter and statter.data: 69 | try: 70 | contents = statter.data(path, statter.parameters) 71 | except FileNotFoundError: 72 | raise fuse.FuseOSError(errno.ENOENT) 73 | 74 | if contents: 75 | return {**base, **contents} 76 | 77 | return base 78 | 79 | def readdir(self, path, fi): 80 | dirs = set(['.', '..']) 81 | 82 | ls = self.routers[Method.LIST].lookup(path) 83 | if ls and ls.data: 84 | contents = ls.data(path, ls.parameters) 85 | return itertools.chain(dirs, contents) 86 | else: 87 | for method in (Method.READ, Method.WRITE, Method.READLINK): 88 | contents = self.routers[method].list(path) 89 | if contents and contents.data: 90 | return itertools.chain(dirs, contents.data) 91 | 92 | return dirs 93 | 94 | def readlink(self, path): 95 | result = self.routers[Method.READLINK].lookup(path) 96 | if result: 97 | return result.data(path, result.parameters) 98 | 99 | def truncate(self, path, length, fi=None): 100 | pass 101 | 102 | # File methods 103 | # ============ 104 | 105 | def open(self, path, fi): 106 | reader = self.routers[Method.READ].lookup(path) 107 | writer = self.routers[Method.WRITE].lookup(path) 108 | 109 | success = False 110 | 111 | if fi.flags & os.O_RDONLY == os.O_RDONLY and reader and reader.data: 112 | callback, encoding = reader.data 113 | contents = callback(path, reader.parameters) 114 | 115 | if contents: 116 | r = file.FileReader.create(contents, encoding) 117 | self.readers[self.fh] = r 118 | 119 | success = True 120 | 121 | if fi.flags & os.O_WRONLY == os.O_WRONLY and writer and writer.data: 122 | callback, encoding = writer.data 123 | contents = callback(path, writer.parameters) 124 | 125 | if contents: 126 | w = file.FileWriter.create(contents, encoding) 127 | self.writers[self.fh] = w 128 | 129 | success = True 130 | 131 | if success: 132 | fi.fh = self.fh 133 | self.fh += 1 134 | fi.direct_io = True 135 | 136 | return 0 137 | else: 138 | return -1 139 | 140 | def read(self, path, length, offset, fi): 141 | if fi.fh in self.readers: 142 | return self.readers[fi.fh].read(length, offset) 143 | 144 | def write(self, path, data, offset, fi): 145 | if fi.fh in self.writers: 146 | return self.writers[fi.fh].write(data, offset) 147 | else: 148 | return len(data) 149 | 150 | def release(self, path, fi): 151 | if fi.fh in self.readers: 152 | reader = self.readers.pop(fi.fh) 153 | reader.release() 154 | 155 | if fi.fh in self.writers: 156 | writer = self.writers.pop(fi.fh) 157 | writer.release() 158 | 159 | # Callbacks 160 | # ========= 161 | 162 | def onstat(self, path, callback): 163 | self.routers[Method.STAT].add(path, callback) 164 | 165 | def onread(self, path, callback, encoding='utf-8'): 166 | self.routers[Method.READ].add(path, (callback, encoding)) 167 | 168 | def onwrite(self, path, callback, encoding='utf-8'): 169 | self.routers[Method.WRITE].add(path, (callback, encoding)) 170 | 171 | def onreadlink(self, path, callback): 172 | self.routers[Method.READLINK].add(path, callback) 173 | 174 | def onlist(self, path, callback): 175 | self.routers[Method.LIST].add(path, callback) 176 | 177 | 178 | class Method(Enum): 179 | STAT = 0 180 | READ = 1 181 | WRITE = 2 182 | READLINK = 3 183 | LIST = 4 184 | -------------------------------------------------------------------------------- /mafs/mafs.py: -------------------------------------------------------------------------------- 1 | import fuse 2 | import stat 3 | 4 | import argparse 5 | 6 | from . import filesystem 7 | 8 | 9 | class MagicFS: 10 | ''' 11 | The main class for building magic filesystems. 12 | 13 | Each of the callbacks takes a route and a function to call for paths that 14 | match that route. Some callbacks (such as read or write) also optionally 15 | take an encoding; this is used to encode or decode strings that are 16 | accepted or returned from callbacks. If the encoding=None, then the string 17 | is assumed to be a byte-string. 18 | 19 | Routes 20 | ====== 21 | A route takes the form of a path, e.g. /foo/bar which only matches the 22 | path /foo/bar. It can also contain variables, which have a colon prefixed, 23 | e.g. /foo/:var which matches any file in the directory /foo. Finally, it 24 | can contain recursive variables which match at least one directory, e.g. 25 | /foo/*var, which matches any file in /foo, such as /foo/bar or 26 | /foo/bar/baz. 27 | 28 | Callbacks 29 | ========= 30 | Functions provided as callbacks should return different data types 31 | depending on what kind of action they perform. For specific details, see 32 | the documentatation for each callback register. 33 | ''' 34 | 35 | def __init__(self): 36 | self.fs = filesystem.FileSystem() 37 | 38 | self._user_args = [] 39 | self._args = None 40 | 41 | def mount(self, mountpoint, foreground=False, threads=False): 42 | ''' 43 | Mount the filesystem. 44 | ''' 45 | 46 | fuse.FUSE(self.fs, mountpoint, raw_fi=True, nothreads=not threads, 47 | foreground=foreground, default_permissions=True) 48 | 49 | def run(self): 50 | ''' 51 | Mount the filesystem using options provided at the command line. 52 | ''' 53 | 54 | args = self.args 55 | self.mount(args.mountpoint, args.foreground, args.threads) 56 | 57 | def add_argument(self, *args, **kwargs): 58 | ''' 59 | Add command line arguments for the run() method. 60 | 61 | Each argument is not operated on now, but stored to be added later. 62 | ''' 63 | 64 | self._user_args.append((args, kwargs)) 65 | 66 | @property 67 | def args(self): 68 | if not self._args: 69 | parser = argparse.ArgumentParser() 70 | parser.add_argument('mountpoint', 71 | help='folder to mount the filesystem in') 72 | parser.add_argument('-fg', '--foreground', action='store_true', 73 | help='run in the foreground') 74 | parser.add_argument('-t', '--threads', action='store_true', 75 | help='allow the use of threads') 76 | 77 | for (args, kwargs) in self._user_args: 78 | parser.add_argument(*args, **kwargs) 79 | 80 | self._args = parser.parse_args() 81 | 82 | return self._args 83 | 84 | # Callbacks 85 | # ========= 86 | 87 | def onread(self, route, func, encoding='utf-8'): 88 | ''' 89 | Register a callback for read requests. 90 | 91 | The callback can return: 92 | - None 93 | - a string 94 | - an iterable (or generator) 95 | - a readable file object 96 | - a function taking two parameters, length and offset, and 97 | returning a byte string 98 | ''' 99 | 100 | self.fs.onread(route, func, encoding) 101 | 102 | def onwrite(self, route, func, encoding): 103 | ''' 104 | Register a callback for write requests. 105 | 106 | The callback can return: 107 | - None 108 | - a writable file object 109 | - a function taking one parameter, the string to write 110 | - a function taking two parameters, a byte string and offset 111 | ''' 112 | 113 | self.fs.onwrite(route, func, encoding) 114 | 115 | def onlist(self, route, func): 116 | ''' 117 | Register a callback for listdir requests. 118 | 119 | The callback can return: 120 | - an iterable (or generator) 121 | ''' 122 | 123 | self.fs.onlist(route, func) 124 | 125 | def onstat(self, route, func): 126 | ''' 127 | Register a callback for file stat requests. 128 | 129 | The callback can return: 130 | - None 131 | - a dictionary containing any of: 132 | - 'st_atime', 'st_ctime', 'st_mtime' 133 | - 'st_gid', 'st_uid' 134 | - 'st_mode' 135 | - 'st_nlink' 136 | - 'st_size' 137 | 138 | Note that for 'st_mode', you should use the bitwise 'or' to combine the 139 | file type and the file permissions, e.g. FileType.REGULAR | 0o644. 140 | 141 | If the callback throws a FileNotFoundException, it will be interpreted 142 | as a sign that the indicated file does not exist. 143 | ''' 144 | 145 | self.fs.onstat(route, func) 146 | 147 | def onreadlink(self, route, func): 148 | ''' 149 | Register a callback for readlink requests. 150 | 151 | The callback can return: 152 | - a string pathname 153 | ''' 154 | 155 | self.fs.onreadlink(route, func) 156 | 157 | # Callbacks (decorators) 158 | # ====================== 159 | 160 | def file(self, route, encoding='utf-8'): 161 | ''' 162 | Register various callbacks using a class decorator. 163 | 164 | The read and write methods of the class are added as their respective 165 | callback types. 166 | ''' 167 | 168 | def decorator(cls): 169 | if hasattr(cls, 'read'): 170 | self.onread(route, cls.read, encoding) 171 | if hasattr(cls, 'write'): 172 | self.onwrite(route, cls.write, encoding) 173 | 174 | return cls 175 | return decorator 176 | 177 | def read(self, route, encoding='utf-8'): 178 | ''' 179 | Register a callback for read requests using a function decorator. 180 | 181 | See onread(). 182 | ''' 183 | 184 | def decorator(func): 185 | self.onread(route, func, encoding) 186 | return func 187 | return decorator 188 | 189 | def write(self, route, encoding='utf-8'): 190 | ''' 191 | Register a callback for write requests using a function decorator. 192 | 193 | See onwrite(). 194 | ''' 195 | 196 | def decorator(func): 197 | self.onwrite(route, func, encoding) 198 | return func 199 | return decorator 200 | 201 | def list(self, route): 202 | ''' 203 | Register a callback for listdir requests using a function decorator. 204 | 205 | See onlist(). 206 | ''' 207 | 208 | def decorator(func): 209 | self.onlist(route, func) 210 | return func 211 | return decorator 212 | 213 | def stat(self, route): 214 | ''' 215 | Register a callback for stat requests using a function decorator. 216 | 217 | See onstat(). 218 | ''' 219 | 220 | def decorator(func): 221 | self.onstat(route, func) 222 | return func 223 | return decorator 224 | 225 | def readlink(self, route): 226 | ''' 227 | Register a callback for readlink requests using a function decorator. 228 | 229 | See onreadlink(). 230 | ''' 231 | 232 | def decorator(func): 233 | self.onreadlink(route, func) 234 | return func 235 | return decorator 236 | 237 | 238 | class FileType: 239 | ''' 240 | Helper variables for calculating permissions. 241 | ''' 242 | 243 | REGULAR = stat.S_IFREG 244 | 245 | DIRECTORY = stat.S_IFDIR 246 | FOLDER = stat.S_IFDIR 247 | 248 | LINK = stat.S_IFLNK 249 | --------------------------------------------------------------------------------