Skip to content

Commit

Permalink
Add cli to pre-compile c sources for deployments (#65)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
mityax authored Mar 19, 2022
1 parent a3ac34b commit 0edf927
Show file tree
Hide file tree
Showing 4 changed files with 148 additions and 7 deletions.
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
56 changes: 51 additions & 5 deletions cppimport/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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"]
Expand All @@ -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
Expand Down
64 changes: 64 additions & 0 deletions cppimport/__main__.py
Original file line number Diff line number Diff line change
@@ -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)
10 changes: 8 additions & 2 deletions cppimport/importer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down

0 comments on commit 0edf927

Please sign in to comment.