├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── pybake ├── __init__.py ├── abstractimporter.py ├── blobserver.py ├── dictfilesystem.py ├── dictfilesystembuilder.py ├── dictfilesysteminterceptor.py ├── errors.py ├── exceptions.py ├── filesysteminterceptor.py ├── launcher.py ├── moduletree.py └── pybake.py ├── setup.py └── tests ├── __init__.py ├── foo ├── __init__.py ├── bad.py └── foo.py ├── test_filesysteminterceptor.py └── test_pybake.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | .idea/ 3 | .cache*/ 4 | reports/ 5 | dist/ 6 | pybake.egg-info/ 7 | virtualenv/ 8 | venv/ 9 | .pytest_cache/ 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Source Simian 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 | 23 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | 3 | develop: 4 | python3 -m venv venv 5 | 6 | 7 | clean: 8 | git clean -dfxn | grep 'Would remove' | awk '{print $3}' | grep -v -e '^.idea' -e '^.cache' | xargs rm -rf 9 | 10 | 11 | check: 12 | flake8 ./pybake --ignore E501 13 | 14 | 15 | test: 16 | pytest ./tests/ -vvv --junitxml=./reports/unittest-results.xml 17 | 18 | 19 | to_pypi_test: test 20 | python -m build 21 | twine upload -r testpypi dist/* 22 | 23 | 24 | to_pypi_live: test 25 | python -m build 26 | twine upload dist/* 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | PyBake 2 | === 3 | 4 | ***Create single file standalone Python scripts with builtin frozen file system*** 5 | 6 | - [Purpose](#purpose) 7 | - [Usage](#usage) 8 | - [Install](#install) 9 | - [Bake Script](#bake-script) 10 | - [Advanced](#advanced) 11 | - [Tracebacks](#tracebacks) 12 | - [Inspection Server](#inspection-server) 13 | - [Content](#content) 14 | - [Frozen File Systems](#frozen-file-systems) 15 | - [License](#license) 16 | 17 | # Purpose 18 | PyBake can bundle an entire Python project including modules and data files into a single Python file, to provide a "standalone executable" style utility. PyBake supports pure Python modules only, thus PyBakes are multi-platform. 19 | 20 | PyBakes just run and don't need an installation so are a great way of rapidly distributing tooling or utility scripts, yet the development can still be done using a formal module hierarchy. 21 | 22 | The intention of PyBake is to be lightweight and to remain pure Python. There are several other "standalone executable" Python projects, with differing options and capabilities: 23 | * [pex](https://pex.readthedocs.io/) 24 | * [shiv](https://shiv.readthedocs.io/) 25 | * [zipapp](https://docs.python.org/3/library/zipapp.html) 26 | * [PyInstaller](https://www.pyinstaller.org/) 27 | * [py2exe](https://www.py2exe.org/) 28 | * [cx_Freeze](https://marcelotduarte.github.io/cx_Freeze) 29 | * [py2app](https://github.com/ronaldoussoren/py2app/blob/master/README.rst) 30 | 31 | 32 | # Usage 33 | The current usage idiom is to create a bake script that is run when you wish to produce a release of your utility. 34 | ## Install 35 | ``` 36 | pip install pyBake 37 | ``` 38 | 39 | ## Bake Script 40 | This bake script includes an optional header to provide version information and setup instructions. The footer is used to invoke the desired entry point. The footer can be 41 | omitted should you wish your PyBake to simply act as a module that can be imported into other scripts, e.g.: 42 | ``` 43 | #!/usr/bin/env python3 44 | from pybake import PyBake 45 | 46 | HEADER = '''\ 47 | ################################################################################ 48 | ## my-util - Helper for ... ## 49 | ## Setup: Save this entire script to a file called "my-util.py". ## 50 | ## Make it executable, e.g.: `chmod +x ./my-util.py`. ## 51 | ## Run `./my-util.py --help` ## 52 | ## Version: 0.1 ## 53 | ################################################################################ 54 | ''' 55 | 56 | FOOTER = '''\ 57 | if __name__ == '__main__': ## 58 | from my_util.main import main ## 59 | exit(main()) ## 60 | ''' 61 | 62 | pb = PyBake(HEADER, FOOTER) 63 | import my_util 64 | pb.add_module(my_util) 65 | 66 | with open('./data.json', 'rt') as fh: 67 | pb.add_file(('my_util', 'data.json'), fh) 68 | 69 | pb.write_bake('my-util.py') 70 | ``` 71 | 72 | Then just run `my-util.py`. 73 | 74 | # Advanced 75 | ## Tracebacks 76 | A PyBake maintains a full representation of source paths and line numbers in Tracebacks. So it is easy to track down the source of any unhandled exceptions. For example if there was a `KeyError` in `main.py` of your utility, the Traceback would appear as follows. The path points to the PyBake path, and then the path from the frozen filesystem is suffixed. 77 | ``` 78 | Traceback (most recent call last): 79 | File "/home/user/./my-util.py", line 132, in 80 | exit(main()) ## 81 | File "/home/user/./my-util.py/my_util/main.py", line 3, in main 82 | KeyError: 'Oops!' 83 | ``` 84 | 85 | ## Inspection Server 86 | Sometimes you may want to inspect the contents of your PyBake. This can be done by running your PyBake with the `--pybake-server` argument. It will run a small HTTP server that serves on `localhost:8080`. Open your web browser to http://localhost:8080 from where you can browse the contents. An additional argument will be interpreted as a port. 87 | 88 | This feature is also a good way to calm the minds of your more suspicious colleagues, who think you're up to something nefarious because you've "obfuscated" your code. And then after lunch they happily `apt-get` install some binary on their machine without first reading the machine code ¯\_(ツ)_/¯ 89 | 90 | ## Content 91 | The content of a PyBake is pure Python. However, if you glance at the source you might think that it is some form of executable. However, taking a closer look at the head will show, e.g.: 92 | ``` 93 | #!/usr/bin/env python3 94 | ################################################################################ 95 | ## my-util - Helper for ... ## 96 | ## ... 97 | ################################################################################ 98 | _='''eJzVPGuT2kiSf6XDGxv2xPV6QTQ+MxHzAWSkRtB4gEYIeRwdSAIkkISmxUtszH+/zCqp9CrRjHf 99 | ``` 100 | Then many of lines of "garbage", e.g.: 101 | ``` 102 | MzsXtdQdhkOqRle/Myqqv77wg2r8cbg5JtI5/DdmvOIHv+B++fAz2ztFfxx/j9cFZb1ZH//DhfZRYq93 103 | ``` 104 | And the tail will show. e.g.: 105 | ``` 106 | ... 107 | WgtFLYPu09hG9EDwjGF8MG9ei/fHH9/8BdRTTUQ==''' 108 | import binascii, json, zlib ## 109 | _ = json.loads(zlib.decompress(binascii.a2b_base64(_))); exec(_[0], globals())## 110 | if __name__ == '__main__': ## 111 | from my_util.main import main ## 112 | exit(main()) ## 113 | ###########################################################################END## 114 | ``` 115 | As you can see a PyBake is simply a very short Python script with a giant zlib compressed Base 64 encoded data structure assigned to a string called `-`. 116 | 117 | ## Frozen File Systems 118 | As mentioned PyBake is pure Python and serves as a demonstration of the awesome builtin capabilities of the Python standard libraries. If you are interested to understand more, start reading the source in [launcher.py](./pybake/launcher.py) and follow the `DictFileSystem`. 119 | 120 | 121 | # License 122 | 123 | In the spirit of the Hackers of the [Tech Model Railroad Club](https://en.wikipedia.org/wiki/Tech_Model_Railroad_Club) from the [Massachusetts Institute of Technology](https://en.wikipedia.org/wiki/Massachusetts_Institute_of_Technology), who gave us all so very much to play with. The license is [MIT](LICENSE). 124 | -------------------------------------------------------------------------------- /pybake/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | __all__ = ['PyBake'] 4 | 5 | from pybake.pybake import PyBake 6 | -------------------------------------------------------------------------------- /pybake/abstractimporter.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | import types 4 | import os 5 | import sys 6 | 7 | 8 | class AbstractImporter(object): 9 | """ 10 | Module finder/loader based on PEP 302 to load Python modules from a filesystem location 11 | """ 12 | def __init__(self, base_dir, fs, unload=True): 13 | self._unload = unload 14 | self._loaded_modules = set() 15 | self._base_dir = base_dir 16 | self._fs = fs 17 | 18 | def __enter__(self): 19 | self.install() 20 | return self 21 | 22 | def __exit__(self, exc_type, exc_val, exc_tb): 23 | self.uninstall() 24 | 25 | def install(self): 26 | sys.meta_path.insert(0, self) 27 | 28 | def uninstall(self): 29 | try: 30 | sys.meta_path.remove(self) 31 | except ValueError: 32 | pass 33 | 34 | if self._unload: 35 | for fullname in self._loaded_modules: 36 | try: 37 | sys.modules.pop(fullname) 38 | except KeyError: 39 | pass 40 | 41 | def loaded_modules(self): 42 | return list(self._loaded_modules) 43 | 44 | def _add_module(self, fullname): 45 | self._loaded_modules.add(fullname) 46 | 47 | def _full_path(self, fullname): 48 | dpath = tuple(fullname.split('.')) 49 | for tpath in (dpath + ('__init__.py',), dpath[:-1] + (dpath[-1] + '.py',)): 50 | try: 51 | if self._fs.isfile(tpath): 52 | return os.path.join(self._base_dir, *tpath) 53 | except IOError: 54 | pass 55 | return None 56 | 57 | def _read_file(self, full_path): 58 | tpath = self._fs.tpath(full_path) 59 | return self._fs.read(tpath) 60 | 61 | def find_module(self, fullname, path=None): 62 | if self._full_path(fullname): 63 | return self 64 | return None 65 | 66 | def load_module(self, fullname): 67 | full_path = self._full_path(fullname) 68 | 69 | mod = sys.modules.setdefault(fullname, types.ModuleType(fullname)) 70 | mod.__file__ = full_path 71 | mod.__loader__ = self 72 | 73 | if full_path.endswith('/__init__.py'): 74 | mod.__package__ = fullname 75 | path_suffixes = [fullname.split('.'), ''] 76 | mod.__path__ = [os.path.join(self._base_dir, *p) for p in path_suffixes] 77 | else: 78 | mod.__package__ = '.'.join(fullname.split('.')[:-1]) 79 | source = self._read_file(full_path) 80 | try: 81 | exec(compile(source, full_path, 'exec'), mod.__dict__) 82 | except ImportError: 83 | exc_info = sys.exc_info() 84 | exc_info1 = ImportError("%s, while importing '%s'" % (exc_info[1], fullname)) 85 | reraise(exc_info[0], exc_info1, exc_info[2]) 86 | except Exception: 87 | exc_info = sys.exc_info() 88 | exc_info1 = ImportError("%s: %s, while importing '%s'" % (exc_info[0].__name__, 89 | exc_info[1], fullname)) 90 | reraise(ImportError, exc_info1, exc_info[2]) 91 | self._add_module(fullname) 92 | return mod 93 | 94 | def get_source(self, fullname): 95 | full_path = self._full_path(fullname) 96 | return self._read_file(full_path) 97 | 98 | def is_package(self, fullname): 99 | path = self._full_path(fullname) 100 | if path: 101 | return path.endswith('__init__.py') 102 | return False 103 | 104 | def get_code(self, fullname): 105 | print('!!!! get_code') 106 | 107 | def get_data(self, path): 108 | print('!!!! get_data') 109 | 110 | def get_filename(self, fullname): 111 | return self._full_path(fullname) 112 | 113 | 114 | def reraise(tp, value, tb=None): 115 | try: 116 | if value is None: 117 | value = tp() 118 | if value.__traceback__ is not tb: 119 | raise value.with_traceback(tb) 120 | raise value 121 | finally: 122 | value = None 123 | tb = None 124 | -------------------------------------------------------------------------------- /pybake/blobserver.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | 4 | class BlobServer(object): 5 | @classmethod 6 | def check_run(cls): 7 | try: 8 | i = sys.argv.index('--pybake-server') 9 | except ValueError: 10 | return 11 | cls().run(*sys.argv[i + 1: i + 2]) 12 | exit(0) 13 | 14 | def run(self, address='8080'): 15 | if ':' in address: 16 | bind, port = address.split(':', 1) 17 | else: 18 | bind = 'localhost' 19 | port = address 20 | port = int(port) 21 | 22 | from http.server import SimpleHTTPRequestHandler, HTTPServer 23 | 24 | class Server(HTTPServer): 25 | def __init__(self, *args, **kwargs): 26 | HTTPServer.__init__(self, *args, **kwargs) 27 | self.RequestHandlerClass.base_path = sys.argv[0] 28 | 29 | class Handler(SimpleHTTPRequestHandler): 30 | def translate_path(self, path): 31 | ret = sys.argv[0] + path 32 | return ret 33 | 34 | httpd = Server((bind, port), Handler) 35 | sys.stdout.write('Serving PyBake file system on http://%s:%d/ hit CTRL+C to exit\n' % (bind, port)) 36 | try: 37 | httpd.serve_forever() 38 | except KeyboardInterrupt: 39 | sys.stdout.write('\n') 40 | -------------------------------------------------------------------------------- /pybake/dictfilesystem.py: -------------------------------------------------------------------------------- 1 | import errno 2 | import os 3 | import binascii 4 | 5 | 6 | class DictFileSystem(object): 7 | _base_stat = None 8 | 9 | def __init__(self, base_dir=None, dict_tree=None): 10 | self._base_dir = self._normalise_path(base_dir or '/pybake/root') 11 | self._dict_tree = {} if dict_tree is None else dict_tree 12 | try: 13 | self._base_stat = os.stat(self._base_dir) 14 | except OSError: 15 | pass 16 | 17 | def tpath(self, path): 18 | path = self._normalise_path(path) 19 | try: 20 | if isinstance(path, str) and path.startswith(self._base_dir): 21 | subpath = path[len(self._base_dir):] 22 | # if not subpath: 23 | # return None 24 | subpath = subpath.strip(os.sep) 25 | if not subpath: 26 | return () 27 | ret = tuple(subpath.split(os.sep)) 28 | return ret 29 | except AttributeError: 30 | pass 31 | return None 32 | 33 | def _get_tpath(self, tpath): 34 | node = self._dict_tree 35 | for key in tpath: 36 | node = node[key] 37 | return node 38 | 39 | def _set_tpath(self, tpath, value): 40 | node = self._dict_tree 41 | for key in tpath[:-1]: 42 | if key not in node: 43 | node[key] = {} 44 | node = node[key] 45 | node[tpath[-1]] = value 46 | 47 | def _get_node(self, tpath): 48 | try: 49 | return self._get_tpath(tpath) 50 | except KeyError: 51 | pass 52 | raise IOError(errno.ENOENT, "No such file or directory", '%s/%s' % (self._base_dir, os.sep.join(tpath))) 53 | 54 | def listdir(self, tpath): 55 | return sorted(self._get_node(tpath).keys()) 56 | 57 | def read(self, tpath): 58 | type, content = self._get_node(tpath) 59 | if type == 'base64': 60 | content = binascii.a2b_base64(content) 61 | self._set_tpath(tpath, ('raw', content)) 62 | return content 63 | 64 | def write(self, tpath, content): 65 | self._set_tpath(tpath, ('raw', content)) 66 | 67 | def isfile(self, tpath): 68 | try: 69 | return isinstance(self._get_tpath(tpath), list) 70 | except KeyError: 71 | return False 72 | 73 | def isdir(self, tpath): 74 | try: 75 | return isinstance(self._get_tpath(tpath), dict) 76 | except KeyError: 77 | return False 78 | 79 | def exists(self, tpath): 80 | try: 81 | self._get_tpath(tpath) 82 | return True 83 | except KeyError: 84 | return False 85 | 86 | def stat(self, tpath): 87 | type, content = self._get_node(tpath) 88 | 89 | from collections import namedtuple 90 | stat_result = namedtuple('stat_result', ['st_mode', 'st_ino', 'st_dev', 'st_nlink', 91 | 'st_uid', 'st_gid', 'st_size', 'st_atime', 92 | 'st_mtime', 'st_ctime']) 93 | st_mode = self._base_stat.st_mode 94 | st_ino = self._base_stat.st_ino 95 | st_dev = self._base_stat.st_dev 96 | st_nlink = self._base_stat.st_nlink 97 | st_uid = self._base_stat.st_uid 98 | st_gid = self._base_stat.st_gid 99 | st_size = len(content) 100 | st_mtime, st_atime, st_ctime = self._base_stat.st_mtime, self._base_stat.st_atime, self._base_stat.st_ctime 101 | 102 | return stat_result(st_mode, st_ino, st_dev, st_nlink, 103 | st_uid, st_gid, st_size, st_atime, 104 | st_mtime, st_ctime) 105 | 106 | def get_dict_tree(self): 107 | def encode(type, content): 108 | if isinstance(content, str): 109 | content = content.encode('utf-8') 110 | return ('base64', binascii.b2a_base64(content).decode('utf-8')) 111 | # try: 112 | # content.decode('ascii') 113 | # return ('raw', content) 114 | # except UnicodeDecodeError: 115 | # return ('base64', binascii.b2a_base64(content)) 116 | 117 | def walk(src): 118 | dst = {} 119 | for key in src: 120 | if isinstance(src[key], dict): 121 | dst[key] = walk(src[key]) 122 | else: 123 | dst[key] = encode(*src[key]) 124 | return dst 125 | 126 | tree = walk(self._dict_tree) 127 | return tree 128 | 129 | @staticmethod 130 | def _normalise_path(filename): 131 | return os.path.normcase(os.path.realpath(filename)) 132 | -------------------------------------------------------------------------------- /pybake/dictfilesystembuilder.py: -------------------------------------------------------------------------------- 1 | import os 2 | import inspect 3 | import types 4 | 5 | 6 | class DictFileSystemBuilder(object): 7 | def __init__(self, fs): 8 | self._fs = fs 9 | 10 | def add_module(self, module, base=()): 11 | package = () 12 | 13 | if isinstance(module, types.ModuleType): 14 | filepath = inspect.getsourcefile(module) 15 | basedir = os.path.dirname(filepath) 16 | 17 | if module.__package__ is not None: 18 | package = tuple(module.__package__.split('.')) 19 | else: 20 | package = tuple(module.__name__.split('.')) 21 | elif os.path.exists(module): 22 | if os.path.isfile(module): 23 | basedir = os.path.dirname(module) 24 | filepath = module 25 | elif os.path.isdir(module): 26 | basedir = module 27 | filepath = os.path.join(module, '__init__.py') 28 | else: 29 | raise ValueError('Module not found: %s' % module) 30 | else: 31 | raise ValueError('Module not found: %s' % module) 32 | 33 | if os.path.basename(filepath) == '__init__.py': 34 | if not package: 35 | package = (os.path.basename(basedir),) 36 | self._load_tree(base + package, basedir) 37 | else: 38 | self._load_single(base + package, filepath) 39 | 40 | def _load_single(self, package, filepath): 41 | filename = os.path.basename(filepath) 42 | with open(filepath, 'rb') as fh: 43 | self.add_file(package + (filename,), fh) 44 | 45 | def _load_tree(self, package, basedir): 46 | ignored_types = ('.pyc',) 47 | 48 | for dir, dirs, files in os.walk(basedir): 49 | if not dir.startswith(basedir): 50 | raise IOError('Unexpected dir: %s' % dir) 51 | 52 | subdir = dir[len(basedir) + 1:] 53 | pre = tuple(subdir.split(os.sep)) if subdir else () 54 | 55 | for filename in files: 56 | if not filename.endswith(ignored_types): 57 | with open(os.path.join(dir, filename), 'rb') as fh: 58 | self.add_file(package + pre + (filename,), fh) 59 | 60 | def add_file(self, tpath, fh, base=()): 61 | self._fs.write(base + tpath, fh.read()) 62 | -------------------------------------------------------------------------------- /pybake/dictfilesysteminterceptor.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | import errno 4 | import os 5 | from io import BytesIO 6 | 7 | from pybake.filesysteminterceptor import FileSystemInterceptor, BUILTINS_OPEN 8 | 9 | 10 | class DictFileSystemInterceptor(FileSystemInterceptor): 11 | def __init__(self, reader): 12 | intercept_list = [ 13 | BUILTINS_OPEN, 14 | 'os.path.isfile', 15 | 'os.path.isdir', 16 | 'os.path.exists', 17 | 'os.listdir', 18 | 'os.stat', 19 | 'os.access', 20 | 'os.fstat', 21 | ] 22 | super(DictFileSystemInterceptor, self).__init__(intercept_list) 23 | self._reader = reader 24 | self._fileno = FileNo() 25 | 26 | def _builtins_open(self, *args, **kwargs): 27 | return self._builtin_open(*args, **kwargs) 28 | 29 | def _builtin_open(self, path, mode='r', *args, **kwargs): 30 | tpath = self._reader.tpath(path) 31 | if tpath is None: 32 | return self._oldhooks[BUILTINS_OPEN](path, mode, *args, **kwargs) 33 | if 'w' in mode: 34 | raise IOError(errno.EROFS, 'Read-only file system', path) 35 | content = self._reader.read(tpath) 36 | 37 | return FrozenFile(self._fileno, path, content) 38 | 39 | def _os_path_isfile(self, path): 40 | tpath = self._reader.tpath(path) 41 | if tpath is None: 42 | return self._oldhooks['os.path.isfile'](path) 43 | # print 'isfile', path, tpath 44 | return self._reader.isfile(tpath) 45 | 46 | def _os_path_isdir(self, path): 47 | tpath = self._reader.tpath(path) 48 | if tpath is None: 49 | return self._oldhooks['os.path.isdir'](path) 50 | # print 'isdir', path 51 | return self._reader.isdir(tpath) 52 | 53 | def _os_path_exists(self, path): 54 | tpath = self._reader.tpath(path) 55 | if tpath is None: 56 | return self._oldhooks['os.path.exists'](path) 57 | # print 'os.exists', path 58 | return self._reader.exists(tpath) 59 | 60 | def _os_listdir(self, path): 61 | tpath = self._reader.tpath(path) 62 | if tpath is None: 63 | return self._oldhooks['os.listdir'](path) 64 | # print 'listdir', path 65 | return self._reader.listdir(tpath) 66 | 67 | def _os_stat(self, path): 68 | tpath = self._reader.tpath(path) 69 | if tpath is None: 70 | return self._oldhooks['os.stat'](path) 71 | # print 'os.stat', path 72 | return self._reader.stat(tpath) 73 | 74 | def _os_fstat(self, fileno): 75 | try: 76 | tpath = self._reader.tpath(self._fileno.fileno_to_path(fileno)) 77 | if tpath: 78 | return self._reader.stat(tpath) 79 | except KeyError: 80 | pass 81 | # print 'os.fstat', path 82 | return self._oldhooks['os.fstat'](fileno) 83 | 84 | def _os_access(self, path, mode): 85 | tpath = self._reader.tpath(path) 86 | if tpath is None: 87 | return self._oldhooks['os.access'](path, mode) 88 | # print 'os.access', path 89 | if mode & (os.W_OK): 90 | return False 91 | return True 92 | 93 | 94 | class FileNo(object): 95 | def __init__(self): 96 | self._current_fileno = 65536 # This is very dodgy 97 | self._fileno_path_map = {} 98 | self._path_fileno_map = {} 99 | 100 | def _next_fileno(self): 101 | self._current_fileno += 1 102 | return self._current_fileno 103 | 104 | def path_to_fileno(self, path): 105 | try: 106 | return self._path_fileno_map[path] 107 | except KeyError: 108 | pass 109 | 110 | fileno = self._next_fileno() 111 | self._path_fileno_map[path] = fileno 112 | self._fileno_path_map[fileno] = path 113 | return fileno 114 | 115 | def fileno_to_path(self, fileno): 116 | return self._fileno_path_map[fileno] 117 | 118 | def close_path(self, path): 119 | try: 120 | fileno = self._path_fileno_map[path] 121 | del self._path_fileno_map[path] 122 | del self._fileno_path_map[fileno] 123 | except KeyError: 124 | pass 125 | 126 | 127 | class FrozenFile(BytesIO): 128 | def __init__(self, fileno, path, content): 129 | self._fileno = fileno 130 | self._path = path 131 | BytesIO.__init__(self, content) 132 | 133 | def __enter__(self): 134 | return self 135 | 136 | def __exit__(self, exc_type, exc_val, exc_tb): 137 | self._fileno.close_path(self._path) 138 | 139 | def fileno(self): 140 | return self._fileno.path_to_fileno(self._path) 141 | -------------------------------------------------------------------------------- /pybake/errors.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sourcesimian/pyBake/cc2eb821a28cbf73bca89cf104a4ffee3d9fb5bb/pybake/errors.py -------------------------------------------------------------------------------- /pybake/exceptions.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sourcesimian/pyBake/cc2eb821a28cbf73bca89cf104a4ffee3d9fb5bb/pybake/exceptions.py -------------------------------------------------------------------------------- /pybake/filesysteminterceptor.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | if sys.version_info[0] == 3: 4 | BUILTINS_OPEN = 'builtins.open' 5 | else: 6 | BUILTINS_OPEN = '__builtin__.open' 7 | 8 | 9 | class FileSystemInterceptor(object): 10 | def __init__(self, intercept_list): 11 | self._intercept_list = intercept_list 12 | self._oldhooks = {} 13 | 14 | def __enter__(self): 15 | self.install() 16 | return self 17 | 18 | def __exit__(self, exc_type, exc_val, exc_tb): 19 | self.uninstall() 20 | 21 | def install(self): 22 | for fullpath in self._intercept_list: 23 | method_name = self._method_name(fullpath) 24 | try: 25 | method = getattr(self, method_name) 26 | self._oldhooks[fullpath] = self._hook(fullpath, method) 27 | except AttributeError: 28 | def make_dummy(fullpath): 29 | def dummy(*args, **kwargs): 30 | return self._shim(fullpath, *args, **kwargs) 31 | return dummy 32 | self._oldhooks[fullpath] = self._hook(fullpath, make_dummy(fullpath)) 33 | pass 34 | 35 | def uninstall(self): 36 | for fullpath, oldhook in list(self._oldhooks.items()): 37 | self._oldhooks[fullpath] = self._hook(fullpath, oldhook) 38 | 39 | @classmethod 40 | def _method_name(cls, fullpath): 41 | return '_%s' % fullpath.replace('_', '').replace('.', '_') 42 | 43 | @classmethod 44 | def _hook(cls, fullpath, newhook): 45 | s = fullpath.split('.') 46 | path, base = '.'.join(s[:-1]), s[-1] 47 | mod = __import__(path, globals(), locals(), [base]) 48 | try: 49 | oldhook = getattr(mod, base) 50 | except AttributeError: 51 | raise AttributeError("'%s' has no attribute '%s'" % (path, base)) 52 | setattr(mod, base, newhook) 53 | return oldhook 54 | 55 | def _shim(self, fullpath, *args, **kwargs): 56 | ret = self._oldhooks[fullpath](*args, **kwargs) 57 | return ret 58 | -------------------------------------------------------------------------------- /pybake/launcher.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | _ = '' 3 | # -- inline cut -- 4 | import binascii, json, zlib 5 | _ = json.loads(zlib.decompress(binascii.a2b_base64(_))); exec(_[0], globals()) 6 | # -- execable cut -- 7 | import types 8 | import sys 9 | 10 | 11 | sys.modules.setdefault('pybake', types.ModuleType('pybake')) 12 | 13 | for name in _[1]: 14 | mod = sys.modules.setdefault('pybake.%s' % name, 15 | types.ModuleType('pybake.%s' % name)) 16 | 17 | format, content = _[2]['pybake'][name + '.py'] 18 | if format == 'base64': 19 | content = binascii.a2b_base64(content) 20 | exec(compile(content, 21 | __file__ + '/pybake/%s' % name + '.py', 22 | 'exec'), mod.__dict__) 23 | 24 | from pybake.dictfilesystem import DictFileSystem 25 | from pybake.abstractimporter import AbstractImporter 26 | 27 | reader = DictFileSystem(__file__, _[2]) 28 | importer = AbstractImporter(__file__, reader) 29 | importer.install() 30 | 31 | from pybake.dictfilesysteminterceptor import DictFileSystemInterceptor 32 | filesystem = DictFileSystemInterceptor(reader) 33 | filesystem.install() 34 | 35 | from pybake.blobserver import BlobServer 36 | BlobServer.check_run() 37 | 38 | del binascii, json, zlib 39 | del _ 40 | del types, sys 41 | del name, mod, format, content 42 | del DictFileSystem, AbstractImporter, DictFileSystemInterceptor, BlobServer 43 | del reader, importer, filesystem 44 | # -- user init -- 45 | # if __name__ == '__main__': 46 | # from foo.cli.main import main 47 | # exit(main()) 48 | -------------------------------------------------------------------------------- /pybake/moduletree.py: -------------------------------------------------------------------------------- 1 | import os 2 | import inspect 3 | import types 4 | 5 | 6 | class ModuleTree(object): 7 | def __init__(self): 8 | self._d = {} 9 | 10 | def load(self, module, base=()): 11 | package = () 12 | 13 | if isinstance(module, types.ModuleType): 14 | filepath = inspect.getsourcefile(module) 15 | basedir = os.path.dirname(filepath) 16 | 17 | if module.__package__ is not None: 18 | package = tuple(module.__package__.split('.')) 19 | elif os.path.exists(module): 20 | if os.path.isfile(module): 21 | basedir = os.path.dirname(module) 22 | filepath = module 23 | elif os.path.isdir(module): 24 | basedir = module 25 | filepath = os.path.join(module, '__init__.py') 26 | else: 27 | raise ValueError('Module not found: %s' % module) 28 | else: 29 | raise ValueError('Module not found: %s' % module) 30 | 31 | if os.path.basename(filepath) == '__init__.py': 32 | if not package: 33 | package = (os.path.basename(basedir),) 34 | self._load_tree(base + package, basedir) 35 | else: 36 | self._load_single(base + package, filepath) 37 | 38 | def _load_single(self, package, filepath): 39 | filename = os.path.basename(filepath) 40 | with open(filepath, 'rb') as fh: 41 | self.add_file(package + (filename,), fh) 42 | 43 | def _load_tree(self, package, basedir): 44 | ignored_types = ('.pyc',) 45 | 46 | for dir, dirs, files in os.walk(basedir): 47 | if not dir.startswith(basedir): 48 | raise IOError('Unexpected dir: %s' % dir) 49 | 50 | subdir = dir[len(basedir) + 1:] 51 | pre = tuple(subdir.split(os.sep)) if subdir else () 52 | 53 | for filename in files: 54 | if not filename.endswith(ignored_types): 55 | with open(os.path.join(dir, filename), 'rb') as fh: 56 | self.add_file(package + pre + (filename,), fh) 57 | 58 | def add_file(self, path, fh, base=()): 59 | node = self._d 60 | for key in base + tuple(path[:-1]): 61 | if key not in node: 62 | node[key] = {} 63 | node = node[key] 64 | value = fh.read() 65 | if path[-1] in node: 66 | if node[path[-1]] == value: 67 | return 68 | raise KeyError('Path already exists: %s' % (path,)) 69 | node[path[-1]] = value 70 | 71 | def get_tree(self): 72 | return self._d 73 | -------------------------------------------------------------------------------- /pybake/pybake.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | import binascii 4 | import json 5 | import os 6 | import os.path 7 | import zlib 8 | import textwrap 9 | 10 | from pybake.dictfilesystem import DictFileSystem 11 | from pybake.dictfilesystembuilder import DictFileSystemBuilder 12 | 13 | src_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 14 | 15 | BAKED_FILES = ( 16 | 'abstractimporter.py', 17 | 'dictfilesystem.py', 18 | 'filesysteminterceptor.py', 19 | 'dictfilesysteminterceptor.py', 20 | 'blobserver.py', 21 | 'launcher.py', 22 | ) 23 | 24 | PRELOAD_MODULES = ( 25 | 'abstractimporter', 26 | 'dictfilesystem', 27 | 'filesysteminterceptor', 28 | 'dictfilesysteminterceptor', 29 | 'blobserver', 30 | ) 31 | 32 | 33 | class PyBake(object): 34 | def __init__(self, header=None, footer=None, width=80, suffix='##', python='python3'): 35 | self._header = header 36 | self._footer = footer 37 | self._width = width 38 | self._suffix = suffix 39 | self._python = python 40 | self._fs = DictFileSystem() 41 | self._fs_builder = DictFileSystemBuilder(self._fs) 42 | self._add_pybake() 43 | 44 | def add_module(self, module, tpath=()): 45 | """ 46 | Add a module to the filesystem blob. Where `module` can a reference to a Python module or 47 | a path to a directory. The optional `tpath` is the path at which the module will be stored 48 | in the filesystem, default is at root which is `()` 49 | """ 50 | self._fs_builder.add_module(module, tpath) 51 | 52 | def add_file(self, tpath, fh): 53 | """ 54 | Write the contents of `fh` a file like object to the the `tpath` location in the filesystem 55 | blob, there `tpath` is a tuple of path elements. 56 | """ 57 | if not isinstance(tpath, (list, tuple)): 58 | raise TypeError(repr(tpath)) 59 | self._fs_builder.add_file(tpath, fh) 60 | 61 | def dump_bake(self, fh): 62 | """ 63 | Write the complete bake to `fh`, a file like object 64 | """ 65 | self._dump_header(fh) 66 | self._dump_blob(fh) 67 | self._dump_footer(fh) 68 | self._dump_eof(fh) 69 | 70 | def write_bake(self, path): 71 | """ 72 | Write the complete bake to `path` and make it executable 73 | """ 74 | path = os.path.expanduser(path) 75 | with open(path, 'wb') as fh: 76 | self.dump_bake(fh) 77 | os.chmod(path, 0o755) 78 | 79 | def _add_pybake(self): 80 | for name in BAKED_FILES: 81 | with open(os.path.join(os.path.dirname(__file__), name), 'rb') as fh: 82 | self.add_file(('pybake', name), fh) 83 | 84 | @staticmethod 85 | def _write(fh, content): 86 | fh.write(content.encode('utf-8')) 87 | 88 | def _dump_header(self, fh): 89 | self._write(fh, '#!/usr/bin/env %s\n' % self._python) 90 | if self._header: 91 | self._write(fh, self._s(self._header)) 92 | 93 | def _dump_footer(self, fh): 94 | if self._footer: 95 | self._write(fh, self._s(self._footer)) 96 | 97 | def _dump_eof(self, fh): 98 | self._write(fh, '#' * (self._width - 3 - len(self._suffix)) + 'END' + self._suffix + '\n') 99 | 100 | def _dump_blob(self, fh): 101 | inline, execable = self._get_launcher() 102 | self._write(fh, "_='''") 103 | 104 | blob = (execable, PRELOAD_MODULES, self._fs.get_dict_tree()) 105 | json_blob = json.dumps(blob, sort_keys=True, separators=(',', ':')) 106 | zlib_blob = zlib.compress(json_blob.encode('utf-8')) 107 | b64_blob = self._b64e(zlib_blob, self._width, 5) 108 | self._write(fh, b64_blob) 109 | self._write(fh, "'''\n") 110 | self._write(fh, self._s(inline)) 111 | 112 | def _get_launcher(self): 113 | with open(os.path.join(os.path.dirname(__file__), 'launcher.py')) as fh: 114 | lines = fh.readlines() 115 | 116 | inline = lines.index('# -- inline cut --\n') 117 | obscure = lines.index('# -- execable cut --\n') 118 | 119 | return ( 120 | ''.join(lines[inline + 1:obscure]), 121 | ''.join(lines[obscure + 1:]) 122 | ) 123 | 124 | @staticmethod 125 | def _dedent(content, offset=0): 126 | ret = textwrap.dedent(content) 127 | if offset: 128 | ret = ' ' * offset + ret.replace('\n', '\n' + ' ' * offset).rstrip(' ') 129 | return ret 130 | 131 | def _s(self, content): 132 | return self._add_suffix(content, self._suffix, self._width - len(self._suffix)) 133 | 134 | @staticmethod 135 | def _add_suffix(content, suffix, offset=78): 136 | ret = [] 137 | for line in content.splitlines(): 138 | if len(line) <= offset: 139 | line = line + ' ' * (offset - len(line)) + suffix 140 | ret.append(line) 141 | ret = '\n'.join(ret) 142 | if ret[-1] != '\n': 143 | ret += '\n' 144 | return ret 145 | 146 | @staticmethod 147 | def _b64e(str, line, first): 148 | first = line - first 149 | out = binascii.b2a_base64(str) 150 | out = out.decode('utf-8').replace('\n', '') 151 | lines = [] 152 | a = 0 153 | b = first or line 154 | m = len(out) 155 | while a < m: 156 | lines.append(out[a: min(b, m)]) 157 | a, b = b, b + line 158 | return '\n'.join(lines) 159 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | 4 | description = "Create single file standalone Python scripts with builtin frozen file system" 5 | 6 | setup( 7 | name="pyBake", 8 | version="0.0.2", 9 | description=description, 10 | long_description=description, # TODO: https://docs.python.org/2/distutils/packageindex.html#pypi-package-display 11 | author="Source Simian", 12 | author_email="sourcesimian@users.noreply.github.com", 13 | url='https://github.com/sourcesimian/pyBake', 14 | license='MIT', 15 | packages=['pybake'], 16 | ) 17 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /tests/foo/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | from tests.foo.foo import Foo 4 | 5 | foo = "FOO" 6 | -------------------------------------------------------------------------------- /tests/foo/bad.py: -------------------------------------------------------------------------------- 1 | # noqa 2 | x = # Cause import error 3 | -------------------------------------------------------------------------------- /tests/foo/foo.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | 3 | class Foo(object): 4 | m = None 5 | 6 | def __init__(self): 7 | self.m = 'init' 8 | 9 | def file(self): 10 | return __file__ 11 | 12 | def src(self): 13 | return inspect.getsourcefile(self.__class__) 14 | 15 | def bang(self): 16 | self._bang() 17 | 18 | def _bang(self): 19 | raise ValueError('from Foo') 20 | -------------------------------------------------------------------------------- /tests/test_filesysteminterceptor.py: -------------------------------------------------------------------------------- 1 | import binascii 2 | import pytest 3 | import os 4 | try: 5 | from StringIO import StringIO as BytesIO 6 | except ImportError: 7 | from io import BytesIO 8 | 9 | from pybake.filesysteminterceptor import FileSystemInterceptor, BUILTINS_OPEN 10 | from pybake.dictfilesystem import DictFileSystem 11 | from pybake.dictfilesysteminterceptor import DictFileSystemInterceptor 12 | from pybake.dictfilesystembuilder import DictFileSystemBuilder 13 | 14 | 15 | class MockFileSystemInterceptor(FileSystemInterceptor): 16 | def __init__(self, d): 17 | intercept_list = [ 18 | BUILTINS_OPEN, 19 | 'os.path.isfile', 20 | 'os.path.isdir', 21 | 'os.path.exists', 22 | 'os.listdir', 23 | 'os.stat', 24 | 'os.access', 25 | 'os.fstat', 26 | ] 27 | super(MockFileSystemInterceptor, self).__init__(intercept_list) 28 | self._d = d 29 | 30 | def _builtins_open(self, *args, **kwargs): 31 | return self._builtin_open(*args, **kwargs) 32 | 33 | def _builtin_open(self, path, mode='r', *args, **kwargs): 34 | self._d.append(('open', path, mode)) 35 | 36 | def _os_path_isfile(self, path): 37 | self._d.append(('os.path.isfile', path)) 38 | 39 | def _os_path_isdir(self, path): 40 | self._d.append(('os.path.isdir', path)) 41 | 42 | def _os_path_exists(self, path): 43 | self._d.append(('os.path.exists', path)) 44 | 45 | def _os_listdir(self, path): 46 | self._d.append(('os.listdir', path)) 47 | 48 | def _os_stat(self, path): 49 | self._d.append(('os.stat', path)) 50 | 51 | def _os_fstat(self, fileno): 52 | self._d.append(('os.fstat', fileno)) 53 | 54 | def _os_access(self, path, mode): 55 | self._d.append(('os.access', path, mode)) 56 | 57 | 58 | def test_interceptor(): 59 | d = [] 60 | fs = MockFileSystemInterceptor(d) 61 | 62 | with fs: 63 | open('foo') 64 | os.path.isfile('bar') 65 | os.path.isdir('fish') 66 | os.path.exists('paste') 67 | os.listdir('bubble') 68 | os.stat('gum') 69 | os.fstat(9) 70 | os.access('flavour', os.W_OK) 71 | 72 | expected = [ 73 | ('open', 'foo', 'r'), 74 | ('os.path.isfile', 'bar'), 75 | ('os.path.isdir', 'fish'), 76 | ('os.path.exists', 'paste'), 77 | ('os.listdir', 'bubble'), 78 | ('os.stat', 'gum'), 79 | ('os.fstat', 9), 80 | ('os.access', 'flavour', 2) 81 | ] 82 | assert expected == d 83 | 84 | 85 | def test_dictfilesystem(): 86 | dict_tree = { 87 | 'readme.txt': ('raw', 'Glad you did?'.encode()), 88 | 'foo': { 89 | 'bar.txt': ('raw', 'A zebra walks into a bar'.encode('utf-8')), 90 | 'fish.txt': ('base64', binascii.b2a_base64('A fish called Nemo?'.encode('utf-8')).decode('utf-8')), 91 | }, 92 | } 93 | root = '/rootdir' 94 | fs = DictFileSystem(base_dir=root, dict_tree=dict_tree) 95 | 96 | with DictFileSystemInterceptor(fs): 97 | expected = ['foo', 'readme.txt'] 98 | actual = os.listdir(root) 99 | assert expected == actual 100 | 101 | expected = b'Glad you did?' 102 | with open(root + '/readme.txt', 'rt') as fh: 103 | actual = fh.read() 104 | assert expected == actual 105 | 106 | expected = ['bar.txt', 'fish.txt'] 107 | actual = os.listdir(root + '/foo') 108 | assert expected == actual 109 | 110 | expected = b'A zebra walks into a bar' 111 | with open(root + '/foo/bar.txt', 'rt') as fh: 112 | actual = fh.read() 113 | assert expected == actual 114 | 115 | expected = b'A fish called Nemo?' 116 | with open(root + '/foo/fish.txt', 'rt') as fh: 117 | actual = fh.read() 118 | assert expected == actual 119 | 120 | 121 | def test_filesystembuilder(): 122 | dict_tree = {} 123 | root = '/rootdir' 124 | fs = DictFileSystem(base_dir=root, dict_tree=dict_tree) 125 | bld = DictFileSystemBuilder(fs) 126 | 127 | text = BytesIO(b'Hello world!') 128 | bld.add_file(('res', 'msg.txt'), text) 129 | 130 | assert dict_tree['res']['msg.txt'][1] == b'Hello world!' 131 | 132 | import tests.foo 133 | bld.add_module(tests.foo) 134 | 135 | assert sorted(dict_tree['tests'].keys()) == ['foo'] 136 | assert sorted(dict_tree['tests']['foo'].keys()) == ['__init__.py', 'bad.py', 'foo.py'] 137 | 138 | bld.add_module(tests.foo, ('a', '1')) 139 | 140 | assert sorted(dict_tree['a']['1']['tests'].keys()) == ['foo'] 141 | assert sorted(dict_tree['a']['1']['tests']['foo'].keys()) == ['__init__.py', 'bad.py', 'foo.py'] 142 | 143 | bld.add_module(os.path.dirname(tests.foo.__file__), ('a', '2')) 144 | 145 | assert sorted(dict_tree['a']['2'].keys()) == ['foo'] 146 | assert sorted(dict_tree['a']['2']['foo'].keys()) == ['__init__.py', 'bad.py', 'foo.py'] 147 | -------------------------------------------------------------------------------- /tests/test_pybake.py: -------------------------------------------------------------------------------- 1 | import re 2 | import binascii 3 | import json 4 | import zlib 5 | import sys 6 | import subprocess 7 | try: 8 | from StringIO import StringIO as BytesIO 9 | except ImportError: 10 | from io import BytesIO 11 | from textwrap import dedent 12 | 13 | import pybake 14 | from pybake import PyBake 15 | 16 | 17 | def test_bake(tmpdir): 18 | header = '# HEADER' 19 | footer = '# FOOTER' 20 | width = 50 21 | pb = PyBake(header, footer, width=width, suffix='#|', python='python2') 22 | 23 | text = BytesIO(b'Hello world!') 24 | pb.add_file(('res', 'msg.txt'), text) 25 | 26 | path = str(tmpdir.join('foobake.py')) 27 | pb.write_bake(path) 28 | 29 | with open(path, 'rt') as fh: 30 | bake_content_lines = fh.read().splitlines() 31 | 32 | assert len(bake_content_lines[1]) == width 33 | assert header in bake_content_lines[1] 34 | 35 | assert len(bake_content_lines[-2]) == width 36 | assert footer in bake_content_lines[-2] 37 | 38 | assert len(bake_content_lines[-1]) == width 39 | 40 | b64_blob = None 41 | start, end = "_='''", "'''" 42 | for line in bake_content_lines: 43 | if start in line: 44 | b64_blob = line[len(start):] 45 | continue 46 | if end in line: 47 | i = line.find(end) 48 | b64_blob += line[:i] 49 | break 50 | if b64_blob is not None: 51 | b64_blob += line + '\n' 52 | 53 | zlib_blob = binascii.a2b_base64(b64_blob) 54 | json_blob = zlib.decompress(zlib_blob) 55 | blob = json.loads(json_blob) 56 | execable, preload, fs = blob 57 | 58 | assert fs['res']['msg.txt'][0] == 'base64' 59 | 60 | expected = b'Hello world!' 61 | actual = binascii.a2b_base64(fs['res']['msg.txt'][1]) 62 | assert expected == actual 63 | 64 | 65 | def run_code(cwd, code): 66 | args = [sys.executable, '-c', code] 67 | p = subprocess.Popen(args=args, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) 68 | p.wait() 69 | return p.returncode, p.stdout.read().decode('utf-8'), p.stderr.read().decode('utf-8') 70 | 71 | 72 | def test_as_module(tmpdir): 73 | header = '# HEADER' 74 | footer = '# FOOTER' 75 | width = 50 76 | pb = PyBake(header, footer, width=width, suffix='#|', python='python2') 77 | 78 | text = BytesIO(b'') 79 | pb.add_file(('tests', '__init__.py'), text) 80 | 81 | import tests.foo 82 | pb.add_module(tests.foo) 83 | 84 | pb.add_module(pybake, ('sub',)) 85 | 86 | text = BytesIO(b'Hello world!') 87 | pb.add_file(('res', 'msg.txt'), text) 88 | 89 | path = str(tmpdir.join('foobake.py')) 90 | pb.write_bake(path) 91 | 92 | # Test dir() on module 93 | _, stdout, stderr = run_code(tmpdir.strpath, 94 | r"import foobake; print('\n'.join(sorted(dir(foobake))))") 95 | 96 | found = stdout.splitlines() 97 | expected = ('__builtins__', '__doc__', '__file__', '__name__', '__package__') 98 | for item in expected: 99 | assert item in found 100 | 101 | # Test module __file__ 102 | _, stdout, _ = run_code(tmpdir.strpath, 103 | r"import foobake, os; print(foobake.__file__)") 104 | 105 | assert stdout.rstrip().endswith('foobake.pyc') or stdout.rstrip().endswith('foobake.py') 106 | 107 | # Test os.path.exists() on blob file system 108 | _, stdout, stderr = run_code(tmpdir.strpath, 109 | r"import foobake, os; print(os.path.exists(foobake.__file__))") 110 | 111 | assert stdout.rstrip() == 'True' 112 | 113 | 114 | # Test listdir() on blob file system 115 | _, stdout, _ = run_code(tmpdir.strpath, 116 | r"import foobake, os; print('\n'.join(sorted(os.listdir(os.path.join(foobake.__file__, 'sub/pybake')))))") 117 | 118 | found = stdout.splitlines() 119 | expected = ( 120 | '__init__.py', 121 | 'abstractimporter.py', 122 | 'blobserver.py', 123 | 'dictfilesystem.py', 124 | 'dictfilesystembuilder.py', 125 | 'dictfilesysteminterceptor.py', 126 | 'filesysteminterceptor.py', 127 | 'launcher.py', 128 | 'moduletree.py', 129 | 'pybake.py', 130 | ) 131 | for item in expected: 132 | assert item in found 133 | 134 | # Test open() on blob file system 135 | _, stdout, stderr = run_code(tmpdir.strpath, 136 | r"import foobake, os; print(open(os.path.join(foobake.__file__, 'res/msg.txt')).read().decode('utf-8'))") 137 | 138 | assert stdout == 'Hello world!\n' 139 | 140 | # Test importing and inspection on blob file system 141 | _, stdout, _ = run_code(tmpdir.strpath, 142 | r"import foobake; from tests.foo import Foo; f = Foo(); print(f.file()); print(f.src())") 143 | 144 | actual = stdout.splitlines() 145 | expected = ( 146 | 'foobake.py/tests/foo/foo.py', 147 | 'foobake.pyc/tests/foo/foo.py' 148 | ) 149 | for item in actual: 150 | assert item.endswith(expected) 151 | 152 | # Test exception tracebacks on blob files system 153 | _, _, stderr = run_code(tmpdir.strpath, 154 | r"import foobake; from tests.foo import Foo; f = Foo(); f.bang()") 155 | 156 | def normalise_traceback(input): 157 | return re.sub('File ".*foobake.py(c)?', 'File "foobake.py', input) 158 | 159 | stderr = normalise_traceback(stderr) 160 | expected = dedent('''\ 161 | Traceback (most recent call last): 162 | File "", line 1, in 163 | File "foobake.py/tests/foo/foo.py", line 16, in bang 164 | File "foobake.py/tests/foo/foo.py", line 19, in _bang 165 | ValueError: from Foo 166 | ''') 167 | 168 | assert expected == stderr 169 | 170 | _, _, stderr = run_code(tmpdir.strpath, 171 | r"import foobake; import tests.foo.bad") 172 | stderr = normalise_traceback(stderr) 173 | 174 | expected = dedent('''\ 175 | Traceback (most recent call last): 176 | File "foobake.py/pybake/abstractimporter.py", line 81, in load_module 177 | File "foobake.py/tests/foo/bad.py", line 2 178 | x = # Cause import error 179 | ^ 180 | SyntaxError: invalid syntax 181 | 182 | During handling of the above exception, another exception occurred: 183 | 184 | Traceback (most recent call last): 185 | File "", line 1, in 186 | File "foobake.py/pybake/abstractimporter.py", line 90, in load_module 187 | File "foobake.py/pybake/abstractimporter.py", line 119, in reraise 188 | File "foobake.py/pybake/abstractimporter.py", line 81, in load_module 189 | ImportError: SyntaxError: invalid syntax (bad.py, line 2), while importing 'tests.foo.bad' 190 | ''') 191 | 192 | assert stderr == expected 193 | --------------------------------------------------------------------------------