Skip to content

Log error messages with a callback instead of file #188

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Jun 16, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .stickler.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
linters:
flake8:
python: 3
enable: true
ignore: E203, E266, E501, W503, F401, E741
max-line-length: 88
Expand Down
62 changes: 30 additions & 32 deletions .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,7 @@ env:
- BUILD_DOCS=false
- DEPLOY_DOCS=false
- DEPLOY_PYPI=false
# Need the dev channel to get development builds of GMT 6
- CONDA_FLAGS="--yes --quiet -c conda-forge -c conda-forge/label/dev"
- CONDA_REQUIREMENTS="requirements.txt"

matrix:
# Build under the following configurations
Expand All @@ -40,25 +39,24 @@ matrix:
- BUILD_DOCS=true
- DEPLOY_DOCS=true
- DEPLOY_PYPI=true
#- os: osx
#env:
#- PYTHON=3.5
#- os: osx
#env:
#- PYTHON=3.6
#- COVERAGE=true
#- BUILD_DOCS=true
- os: osx
env:
- PYTHON=3.5
- BUILD_DOCS=true
- os: osx
env:
- PYTHON=3.6
- COVERAGE=true
- BUILD_DOCS=true

before_install:
# Get Miniconda from Continuum
# Need to source the script to set the PATH variable in this environment
# (not the scripts environment)
- source ci/install-miniconda.sh
- conda update conda $CONDA_FLAGS
- conda create --name testenv python=$PYTHON pip $CONDA_FLAGS
- source activate testenv
# Install dependencies
- conda install --file requirements.txt $CONDA_FLAGS
# Get the Fatiando CI scripts
- git clone https://github.com/fatiando/continuous-integration.git
# Download and install miniconda and setup dependencies
# Need to source the script to set the PATH variable globaly
- source continuous-integration/travis/setup-miniconda.sh
# Install GMT from the dev channel to get development builds of GMT 6
- conda install --yes --quiet -c conda-forge/label/dev gmt=6.0.0a*
- if [ "$COVERAGE" == "true" ]; then
pip install codecov codacy-coverage codeclimate-test-reporter;
fi
Expand All @@ -75,11 +73,11 @@ script:
- if [ "$CHECK" == "true" ]; then
make check;
fi
# Run the test suite
# Run the test suite. Make pytest report any captured output on stdout or stderr.
- if [ "$COVERAGE" == "true" ]; then
make coverage;
make coverage PYTEST_EXTRA="-r P";
else
make test;
make test PYTEST_EXTRA="-r P";
fi
# Build the documentation
- if [ "$BUILD_DOCS" == "true" ]; then
Expand All @@ -98,26 +96,26 @@ after_success:
fi

deploy:
# Push the built docs to Github pages
# Make a release on PyPI
- provider: script
script: ci/deploy-docs.sh
script: continuous-integration/travis/deploy-pypi.sh
on:
tags: true
condition: '$DEPLOY_PYPI == "true"'
# Push the built HTML in doc/_build/html to the gh-pages branch
- provider: script
script: continuous-integration/travis/deploy-gh-pages.sh
skip_cleanup: true
on:
branch: master
condition: '$DEPLOY_DOCS == "true"'
# Push docs when building tags as well
# Push HTML when building tags as well
- provider: script
script: ci/deploy-docs.sh
script: continuous-integration/travis/deploy-gh-pages.sh
skip_cleanup: true
on:
tags: true
condition: '$DEPLOY_DOCS == "true"'
# Make a release on PyPI
- provider: script
script: ci/deploy-pypi.sh
on:
tags: true
condition: '$DEPLOY_PYPI == "true"'

notifications:
email: false
Expand Down
55 changes: 0 additions & 55 deletions ci/deploy-docs.sh

This file was deleted.

27 changes: 0 additions & 27 deletions ci/deploy-pypi.sh

This file was deleted.

23 changes: 0 additions & 23 deletions ci/install-miniconda.sh

This file was deleted.

135 changes: 38 additions & 97 deletions gmt/clib/core.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
"""
ctypes wrappers for core functions from the C API
"""
import os
import sys
import ctypes
from tempfile import NamedTemporaryFile
from contextlib import contextmanager

from packaging.version import Version
Expand Down Expand Up @@ -254,10 +253,26 @@ def create_session(self, session_name):
restype=ctypes.c_void_p,
)

# None is passed in place of the print function pointer. It becomes the
# NULL pointer when passed to C, prompting the C API to use the default
# print function.
print_func = None
# Capture the output printed by GMT into this list. Will use it later to
# generate error messages for the exceptions raised by API calls.
self._log = []

@ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p, ctypes.c_char_p)
def print_func(file_pointer, message): # pylint: disable=unused-argument
"""
Callback function that GMT uses to print log and error messages.
We'll capture the message and print it to stderr so that it will show up on
the Jupyter notebook.
"""
message = message.decode().strip()
self._log.append(message)
print(message, file=sys.stderr)
return 0

# Need to store a copy of the function because ctypes doesn't and it will be
# garbage collected otherwise
self._print_callback = print_func

padding = self.get_constant("GMT_PAD_DEFAULT")
session_type = self.get_constant("GMT_SESSION_EXTERNAL")
session = c_create_session(
Expand All @@ -269,6 +284,16 @@ def create_session(self, session_name):

return session

def _get_error_message(self):
"""
Return a string with error messages emitted by GMT.
Only includes messages with the string "[ERROR]" in them.
"""
msg = ""
if hasattr(self, "_log"):
msg = "\n".join(line for line in self._log if "[ERROR]" in line)
return msg

def destroy_session(self, session):
"""
Terminate and free the memory of a registered ``GMTAPI_CTRL`` session.
Expand Down Expand Up @@ -382,73 +407,6 @@ def get_default(self, name):

return value.value.decode()

@contextmanager
def log_to_file(self, logfile=None):
"""
Set the next API call in this session to log to a file.

Use it as a context manager (in a ``with`` block) to capture the error
messages from GMT API calls. Mostly useful with ``GMT_Call_Module``
because most others don't print error messages.

The log file will be deleted when exiting the ``with`` block.

Calls the GMT API function ``GMT_Handle_Messages`` using
``GMT_LOG_ONCE`` mode (to only log errors from the next API call).
Only works for a **single API call**, so make calls like
``get_constant`` outside of the ``with`` block.

Parameters
----------
* logfile : str or None
The name of the logfile. If ``None`` (default),
the file name is automatically generated by the tempfile module.

Examples
--------

>>> with LibGMT() as lib:
... mode = lib.get_constant('GMT_MODULE_CMD')
... with lib.log_to_file() as logfile:
... call_module = lib.get_libgmt_func('GMT_Call_Module')
... status = call_module(lib.current_session, 'info'.encode(),
... mode, 'bogus-file.bla'.encode())
... with open(logfile) as flog:
... print(flog.read().strip())
gmtinfo [ERROR]: Error for input file: No such file (bogus-file.bla)

"""
c_handle_messages = self.get_libgmt_func(
"GMT_Handle_Messages",
argtypes=[ctypes.c_void_p, ctypes.c_uint, ctypes.c_uint, ctypes.c_char_p],
restype=ctypes.c_int,
)

if logfile is None:
tmp_file = NamedTemporaryFile(
prefix="gmt-python-", suffix=".log", delete=False
)
logfile = tmp_file.name
tmp_file.close()

status = c_handle_messages(
self.current_session,
self.get_constant("GMT_LOG_ONCE"),
self.get_constant("GMT_IS_FILE"),
logfile.encode(),
)
if status != 0:
msg = "Failed to set logging to file '{}' (error: {}).".format(
logfile, status
)
raise GMTCLibError(msg)

# The above is called when entering a 'with' statement
yield logfile

# Clean up when exiting the 'with' statement
os.remove(logfile)

def call_module(self, module, args):
"""
Call a GMT module with the given arguments.
Expand Down Expand Up @@ -479,30 +437,15 @@ def call_module(self, module, args):
)

mode = self.get_constant("GMT_MODULE_CMD")
# If there is no open session, this will raise an exception. Can' let
# it happen inside the 'with' otherwise the logfile won't be deleted.
session = self.current_session
with self.log_to_file() as logfile:
status = c_call_module(session, module.encode(), mode, args.encode())
# Get the error message inside the with block before the log file
# is deleted
with open(logfile) as flog:
log = flog.read().strip()
# Raise the exception outside the log 'with' to make sure the logfile
# is cleaned.
status = c_call_module(
self.current_session, module.encode(), mode, args.encode()
)
if status != 0:
if log == "":
msg = "Invalid GMT module name '{}'.".format(module)
else:
msg = "\n".join(
[
"Command '{}' failed:".format(module),
"---------- Error log ----------",
log,
"-------------------------------",
]
raise GMTCLibError(
"Module '{}' failed with status code {}:\n{}".format(
module, status, self._get_error_message()
)
raise GMTCLibError(msg)
)

def create_data(self, family, geometry, mode, **kwargs):
"""
Expand Down Expand Up @@ -1324,8 +1267,6 @@ def extract_region(self):
)

wesn = np.empty(4, dtype=np.float64)
# Use NaNs so that we can know if GMT didn't change the array
wesn[:] = np.nan
wesn_pointer = wesn.ctypes.data_as(ctypes.POINTER(ctypes.c_double))
# The second argument to GMT_Extract_Region is a file pointer to a
# PostScript file. It's only valid in classic mode. Use None to get a
Expand Down
Loading