From 0edf927f10d614751f7eaa58a3f7372ec298bd65 Mon Sep 17 00:00:00 2001 From: mityax <18172993+mityax@users.noreply.github.com> Date: Sat, 19 Mar 2022 23:35:33 +0530 Subject: [PATCH] Add cli to pre-compile c sources for deployments (#65) * Added CLI for building files and directory trees * Add setting to skip checksum check; Allow specifying multiple paths to build command * Changed "skip_checksum_check" to "release_mode"; Add documentation; Fix opposite logic in is_build_needed * Improved readme * Moved cli to __main__.py * Add flag to force rebuild * Further improve readme * Harden cli * Revert changed test files --- README.md | 25 +++++++++++++++++ cppimport/__init__.py | 56 +++++++++++++++++++++++++++++++++---- cppimport/__main__.py | 64 +++++++++++++++++++++++++++++++++++++++++++ cppimport/importer.py | 10 +++++-- 4 files changed, 148 insertions(+), 7 deletions(-) create mode 100644 cppimport/__main__.py diff --git a/README.md b/README.md index a0c4906..aae28ca 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,31 @@ This region surrounded by `<%` and `%>` is a [Mako](https://www.makotemplates.or Note that because of the Mako pre-processing, the comments around the configuration block may be omitted. Putting the configuration block at the end of the file, while optional, ensures that line numbers remain correct in compilation error messages. +## Building for production +In production deployments you usually don't want to include a c/c++ compiler, all the sources and compile at runtime. Therefore, a simple cli utility for pre-compiling all source files is provided. This utility may, for example, be used in CI/CD pipelines. + +Usage is as simple as + +```commandline +python -m cppimport build +``` + +This will build all `*.c` and `*.cpp` files in the current directory (and it's subdirectories) if they are eligible to be imported (i.e. contain the `// cppimport` comment in the first line). + +Alternatively, you may specifiy one or more root directories or source files to be built: + +```commandline +python -m cppimport build ./my/directory/ ./my/single/file.cpp +``` +_Note: When specifying a path to a file, the header check (`// cppimport`) is skipped for that file._ + +### Fine-tuning for production +To further improve startup performance for production builds, you can opt-in to skip the checksum and compiled binary existence checks during importing by either setting the environment variable `CPPIMPORT_RELEASE_MODE` to `true` or setting the configuration from within Python: +```python +cppimport.settings['release_mode'] = True +``` +**Warning:** Make sure to have all binaries pre-compiled when in release mode, as importing any missing ones will cause exceptions. + ## Frequently asked questions ### What's actually going on? diff --git a/cppimport/__init__.py b/cppimport/__init__.py index 01e5b85..aef1141 100644 --- a/cppimport/__init__.py +++ b/cppimport/__init__.py @@ -2,14 +2,20 @@ See CONTRIBUTING.md for a description of the project structure and the internal logic. """ import ctypes +import logging import os +from cppimport.find import _check_first_line_contains_cppimport + settings = dict( force_rebuild=False, file_exts=[".cpp", ".c"], rtld_flags=ctypes.RTLD_LOCAL, remove_strict_prototypes=True, + release_mode=os.getenv("CPPIMPORT_RELEASE_MODE", "0").lower() + in ("true", "yes", "1"), ) +_logger = logging.getLogger("cppimport") def imp(fullname, opt_in=False): @@ -61,7 +67,7 @@ def imp_from_filepath(filepath, fullname=None): if fullname is None: fullname = os.path.splitext(os.path.basename(filepath))[0] module_data = setup_module_data(fullname, filepath) - if not is_build_needed(module_data) or not try_load(module_data): + if is_build_needed(module_data) or not try_load(module_data): template_and_build(filepath, module_data) load_module(module_data) return module_data["module"] @@ -81,23 +87,63 @@ def build(fullname): ext_path : the path to the compiled extension. """ from cppimport.find import find_module_cpppath + + # Search through sys.path to find a file that matches the module + filepath = find_module_cpppath(fullname) + return build_filepath(filepath, fullname=fullname) + + +def build_filepath(filepath, fullname=None): + """ + `build_filepath` builds a extension module like `build` but allows + to directly specify a file path. + + Parameters + ---------- + filepath : the filepath to the C++ file to build. + fullname : the name of the module to build. + + Returns + ------- + ext_path : the path to the compiled extension. + """ from cppimport.importer import ( is_build_needed, setup_module_data, template_and_build, ) - # Search through sys.path to find a file that matches the module - filepath = find_module_cpppath(fullname) - + if fullname is None: + fullname = os.path.splitext(os.path.basename(filepath))[0] module_data = setup_module_data(fullname, filepath) - if not is_build_needed(module_data): + if is_build_needed(module_data): template_and_build(filepath, module_data) # Return the path to the built module return module_data["ext_path"] +def build_all(root_directory): + """ + `build_all` builds a extension module like `build` for each eligible (that is, + containing the "cppimport" header) source file within the given `root_directory`. + + Parameters + ---------- + root_directory : the root directory to search for cpp source files in. + """ + for directory, _, files in os.walk(root_directory): + for file in files: + if ( + not file.startswith(".") + and os.path.splitext(file)[1] in settings["file_exts"] + ): + full_path = os.path.join(directory, file) + if _check_first_line_contains_cppimport(full_path): + _logger.info(f"Building: {full_path}") + build_filepath(full_path) + + ######## BACKWARDS COMPATIBILITY ######### # Below here, we pay penance for mistakes. # TODO: Add DeprecationWarning diff --git a/cppimport/__main__.py b/cppimport/__main__.py new file mode 100644 index 0000000..2577db0 --- /dev/null +++ b/cppimport/__main__.py @@ -0,0 +1,64 @@ +import argparse +import logging +import os +import sys + +from cppimport import build_all, build_filepath, settings + + +def _run_from_commandline(raw_args): + parser = argparse.ArgumentParser("cppimport") + + parser.add_argument( + "--verbose", "-v", action="store_true", help="Increase log verbosity." + ) + parser.add_argument( + "--quiet", "-q", action="store_true", help="Only print critical log messages." + ) + + subparsers = parser.add_subparsers(dest="action", required=True) + + build_parser = subparsers.add_parser( + "build", + help="Build one or more cpp source files.", + ) + build_parser.add_argument( + "root", + help="The file or directory to build. If a directory is given, " + "cppimport walks it recursively to build all eligible source " + "files.", + nargs="*", + ) + build_parser.add_argument( + "--force", "-f", action="store_true", help="Force rebuild." + ) + + args = parser.parse_args(raw_args[1:]) + + if args.quiet: + logging.basicConfig(level=logging.CRITICAL) + elif args.verbose: + logging.basicConfig(level=logging.DEBUG) + else: + logging.basicConfig(level=logging.INFO) + + if args.action == "build": + if args.force: + settings["force_rebuild"] = True + + for path in args.root or ["."]: + path = os.path.abspath(os.path.expandvars(path)) + if os.path.isfile(path): + build_filepath(path) + elif os.path.isdir(path): + build_all(path or os.getcwd()) + else: + raise FileNotFoundError( + f'The given root path "{path}" could not be found.' + ) + else: + parser.print_usage() + + +if __name__ == "__main__": + _run_from_commandline(sys.argv) diff --git a/cppimport/importer.py b/cppimport/importer.py index 4726e2a..cf04f1a 100644 --- a/cppimport/importer.py +++ b/cppimport/importer.py @@ -65,11 +65,17 @@ def load_module(module_data): def is_build_needed(module_data): if cppimport.settings["force_rebuild"]: + return True + if cppimport.settings["release_mode"]: + logger.debug( + f"Release mode is enabled. Thus, file {module_data['filepath']} is " + f"not being compiled." + ) return False if not is_checksum_valid(module_data): - return False + return True logger.debug(f"Matching checksum for {module_data['filepath']} --> not compiling") - return True + return False def try_load(module_data):