diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4e3864a..cec0f74 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,34 +2,40 @@ name: 'CI' on: push: - branches: [ master ] + branches: [master] pull_request: jobs: flake8: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: set up python - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: - python-version: 3.9 + python-version: '3.12' - name: install dependencies - run: python -m pip install flake8 + run: make install - name: lint with flake8 - run: flake8 --statistics shentry.py + run: make lint run-tests: runs-on: ubuntu-latest strategy: matrix: - pythonversion: ['2.7', '3.5', '3.6', '3.7', '3.8', '3.9'] + pythonversion: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12'] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: set up python - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.pythonversion }} - name: install dependencies - run: "python -m pip install -r requirements-tests.txt -e ." + run: make install - name: test with pytest - run: py.test --cov-fail-under=50 --cov=shentry --cov-report=term-missing tests/ + run: make coverage + - name: Coveralls + if: github.ref == 'refs/heads/master' + uses: coverallsapp/github-action@master + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + path-to-lcov: './coverage.lcov' diff --git a/.gitignore b/.gitignore index cfb5456..aaa3094 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ test-*.xml venv/ env/ .coverage +__pycache__ +*.lcov diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 2744eb9..0000000 --- a/.travis.yml +++ /dev/null @@ -1,15 +0,0 @@ -dist: precise -language: python -python: - - "2.6" - - "2.7" - - "3.5" - - "3.6" -install: - - "pip install -r requirements-tests.txt" - - "pip install ." -script: - - "py.test --cov-fail-under=50 --cov=shentry --cov-report=term-missing tests/" - - "if python -c 'import sys; exit(1 if sys.version_info < (2, 7) else 0)' ; then flake8 shentry.py ; else echo 'flake8 is broken on py26'; fi" -env: - - TZ=UTC diff --git a/CHANGES.md b/CHANGELOG.md similarity index 71% rename from CHANGES.md rename to CHANGELOG.md index 23c37a2..d3033f6 100644 --- a/CHANGES.md +++ b/CHANGELOG.md @@ -1,20 +1,28 @@ -0.4.0 ------ +# CHANGELOG + +## v1.0.0 FUTURE + +- Drops support for Python earlier than 3.7 +- Adds support for Python 3.10-3.12 +- Overhauls dev tooling + +## v0.4.0 + - pass through SIGTERM, SIGQUIT, and SIGINT to the child process - do not send empty tags to Sentry - (internal) switch tests from circleci to travisci -0.3.2 ------ +## v0.3.2 + - Move `level`, `server_name` and `sdk` from `tags` to top-level - Add Python 3.6 to tox tests -0.3.1 ------ +## v0.3.1 + - Fix bug with loading large command outputs - Add more tests -0.3.0 ------ +## v0.3.0 + - Add support for using `requests` if it's importable - Add support for outbound proxies via `$SHELL_SENTRY_PROXY` or `/etc/shentry_proxy` diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ef82df0 --- /dev/null +++ b/Makefile @@ -0,0 +1,40 @@ +PYTHON_BINARY := python3 +VIRTUAL_ENV := venv +VIRTUAL_BIN := $(VIRTUAL_ENV)/bin +PROJECT_NAME := shentry +TEST_DIR := tests + +## help - Display help about make targets for this Makefile +help: + @cat Makefile | grep '^## ' --color=never | cut -c4- | sed -e "`printf 's/ - /\t- /;'`" | column -s "`printf '\t'`" -t + +## clean - Clean the project +clean: + rm -rf $(VIRTUAL_ENV) dist/ *.egg-info/ .*cache htmlcov *.lcov .coverage + find . -name '*.pyc' -delete + +## coverage - Test the project and generate an HTML coverage report +coverage: + $(VIRTUAL_BIN)/pytest --cov=$(PROJECT_NAME) --cov-branch --cov-report=html --cov-report=lcov --cov-report=term-missing --cov-fail-under=50 + +## flake8 - Lint the project with flake8 +flake8: + $(VIRTUAL_BIN)/flake8 $(PROJECT_NAME).py $(TEST_DIR)/ + +## install - Install the project locally +install: + $(PYTHON_BINARY) -m venv $(VIRTUAL_ENV) + $(VIRTUAL_BIN)/pip install -r requirements-tests.txt -e . + +## lint - Run linters on the project +lint: flake8 + +## publish - Publish the project to PyPI +publish: + $(VIRTUAL_BIN)/twine upload dist/* + +## test - Test the project +test: + $(VIRTUAL_BIN)/pytest + +.PHONY: help clean coverage flake8 install lint publish test diff --git a/README.md b/README.md index 0281c28..479e6d0 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,9 @@ +# Shentry + +[![Build Status](https://travis-ci.com/EasyPost/shentry.svg?branch=master)](https://travis-ci.com/EasyPost/shentry) +[![Coverage Status](https://coveralls.io/repos/github/EasyPost/shentry/badge.svg)](https://coveralls.io/github/EasyPost/shentry) +[![Version](https://img.shields.io/github/v/tag/EasyPost/shentry)](https://github.com/EasyPost/shentry/releases) + **Shentry** is a single-file Python script which will run the wrapped command and, if it fails, post an event to Sentry. By default, if the wrapped script succeeds (exists with code 0), stdout/stderr are squashed, @@ -5,25 +11,6 @@ similarly to [shuck](https://github.com/thwarted/shuck) or [chronic](https://joeyh.name/code/moreutils/). It also always exits with status 0 if events are able to be sent to Sentry. -It reads its configuration from the environment variable `$SHELL_SENTRY_DSN` -and, if such a variable is found, removes it from the environment before -calling the wrapped program. If that environment variable is not present, shentry will look -for `$SENTRY_DSN` (and similarly remove it from the environment). -If you need to use SENTRY_DSN inside your project code, make sure to set both. -You may also in that case want to put a top-level try/except around your whole -program to prevent uncaught exceptions from trigging both your in-process sentry sdk -and also your extra-process shentry, since you very likely only want one or the other. -If neither of the environment variables are present or both -are empty, shentry will try to read a DSN from `/etc/shentry_dsn`. If no DSN -can be found, the wrapped will have normal behavior (stdout/stderr will go -to their normal file descriptors, exit code will be passed through, etc). - -This software should be compatible with Python 2.6, 2.7, and 3.5+; -that is to say, you should be able to run it just about anywhere. - -[![Build Status](https://travis-ci.com/EasyPost/shentry.svg?branch=master)](https://travis-ci.com/EasyPost/shentry) - - ## Installation Put the file [`shentry.py`](shentry.py) anywhere in your `$PATH` under the @@ -34,27 +21,46 @@ If the `requests` library is available, it will be used; otherwise, the standard either `$SHELL_SENTRY_PROXY` or the contents of `/etc/shentry_proxy` can be used to configure an outbound proxy. -The `setup.py` in this directory only exists for `tox` to work (and run unit -tests). Don't bother using it; just copy `shentry.py` wherever you want it. +The `setup.py` in this directory only exists for this project's dev tooling. To get +Shentry working on your machine, simply copy `shentry.py` wherever you need it. ## Usage You might want a crontab that looks something like the following: - SHELL_SENTRY_DSN=https://pub:priv@app.getsentry.com/id +```sh +SHELL_SENTRY_DSN=https://pub:priv@app.getsentry.com/id - 15 * * * * /usr/local/bin/shentry /usr/local/bin/run-periodic-scripts +15 * * * * /usr/local/bin/shentry /usr/local/bin/run-periodic-scripts +``` You can also make shentry your `$SHELL` and wrap all commands in it: - SHELL_SENTRY_DSN=https://pub:priv@app.getsentry.com/id - SHELL=/usr/local/bin/shentry +```sh +SHELL_SENTRY_DSN=https://pub:priv@app.getsentry.com/id +SHELL=/usr/local/bin/shentry - 15 * * * * /usr/local/bin/run-periodic-scripts - 7 1 * * * /usr/local/bin/run-daily-scripts +15 * * * * /usr/local/bin/run-periodic-scripts +7 1 * * * /usr/local/bin/run-daily-scripts +``` In this case, it will run the wrapped commands through `/bin/sh` (otherwise, it will honor `$SHELL`). +### Environment Variables + +Shentry reads its configuration from the environment variable `$SHELL_SENTRY_DSN` +and, if such a variable is found, removes it from the environment before +calling the wrapped program. If that environment variable is not present, shentry will look +for `$SENTRY_DSN` (and similarly remove it from the environment). +If you need to use `SENTRY_DSN` inside your project code, make sure to set both. +You may also in that case want to put a top-level try/except around your whole +program to prevent uncaught exceptions from trigging both your in-process sentry sdk +and also your extra-process shentry, since you very likely only want one or the other. +If neither of the environment variables are present or both +are empty, shentry will try to read a DSN from `/etc/shentry_dsn`. If no DSN +can be found, the wrapped will have normal behavior (stdout/stderr will go +to their normal file descriptors, exit code will be passed through, etc). + ## License This software is licensed under the ISC License, the full text of which can be found at [LICENSE.txt](LICENSE.txt). diff --git a/requirements-tests.txt b/requirements-tests.txt index 76e7a33..b5385d5 100644 --- a/requirements-tests.txt +++ b/requirements-tests.txt @@ -1,7 +1,4 @@ -# pytest can't go above 3.2 because we need python 2.6 compat -pytest==3.2.* -# pytest 2.6 also breaks python 2.6 compatibility -pytest-cov==2.5.* -mock==2.0.0 -pytest-mock==1.* -flake8==3.* +flake8==5.* # TODO: flake8 v6 requires Python 3.8.1+ +pytest-cov==4.* +pytest-mock==3.* +pytest==7.* diff --git a/setup.py b/setup.py index 09efe57..6d61e7c 100755 --- a/setup.py +++ b/setup.py @@ -2,31 +2,30 @@ from setuptools import setup - setup( name="shentry", - version="0.4.0", + version="1.0.0", author="EasyPost", author_email="oss@easypost.com", url="https://github.com/easypost/shentry", license="ISC", packages=[], - scripts=['shentry.py'], + scripts=["shentry.py"], keywords=["logging"], description="Wrap a program in sentry!", - python_requires='>=2.6, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4', + python_requires=">=3.7, <4", classifiers=[ "Development Status :: 3 - Alpha", "Environment :: Console", "Programming Language :: Python", - "Programming Language :: Python :: 2.6", - "Programming Language :: Python :: 2.7", - "Programming Language :: Python :: 3.5", - "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "Operating System :: POSIX", "Intended Audience :: Developers", "License :: OSI Approved :: ISC License (ISCL)", - ] + ], ) diff --git a/shentry.py b/shentry.py index 764c3be..a4ffc02 100755 --- a/shentry.py +++ b/shentry.py @@ -11,22 +11,19 @@ # AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR # PERFORMANCE OF THIS SOFTWARE. - -# NOTE: This code should work with Python 2.6, 2.7, 3.2, 3.3, 3.4, 3.5, and 3.6 - from __future__ import print_function try: - from urllib.parse import urlparse - from urllib.request import urlopen, Request from urllib.error import HTTPError, URLError + from urllib.parse import urlparse + from urllib.request import Request, urlopen except ImportError: + from urllib2 import HTTPError, Request, URLError, urlopen from urlparse import urlparse - from urllib2 import urlopen, Request - from urllib2 import HTTPError, URLError try: import requests + has_requests = True except ImportError: has_requests = False @@ -36,33 +33,32 @@ import json import os import pwd -import sys -import time -import uuid -import subprocess -import tempfile import shutil import signal import socket - +import subprocess +import sys +import tempfile +import time +import uuid from contextlib import closing -VERSION = '0.4.0' +VERSION = "1.0.0" def read_systemwide_config(): try: - with open('/etc/shentry_dsn', 'r') as f: + with open("/etc/shentry_dsn", "r") as f: return f.read().strip() except Exception: return None def _get_proxy_url(): - if 'SHELL_SENTRY_PROXY' in os.environ: - return os.environ['SHELL_SENTRY_PROXY'] + if "SHELL_SENTRY_PROXY" in os.environ: + return os.environ["SHELL_SENTRY_PROXY"] try: - with open('/etc/shentry_proxy', 'r') as f: + with open("/etc/shentry_proxy", "r") as f: return f.read().strip() except Exception: pass @@ -76,11 +72,11 @@ def _send_urllib2(uri, headers, data, timeout): f.read() return True except HTTPError as e: - print('Error {0} sending to Sentry'.format(e.code), file=sys.stderr) + print("Error {0} sending to Sentry".format(e.code), file=sys.stderr) print(e.read(), file=sys.stderr) return False except URLError as e: - print('Error {0} sending to Sentry'.format(e.reason), file=sys.stderr) + print("Error {0} sending to Sentry".format(e.reason), file=sys.stderr) return False @@ -89,18 +85,12 @@ def _send_requests(uri, headers, data, timeout): kwargs = {} proxy_url = _get_proxy_url() if proxy_url is not None: - kwargs['proxies'] = { - 'http': proxy_url, - 'https': proxy_url - } - resp = requests.post( - uri, headers=headers, data=data, timeout=timeout, - **kwargs - ) + kwargs["proxies"] = {"http": proxy_url, "https": proxy_url} + resp = requests.post(uri, headers=headers, data=data, timeout=timeout, **kwargs) resp.raise_for_status() return True except requests.exceptions.RequestException as e: - print('Error {0!r} sending to Sentry'.format(e), file=sys.stderr) + print("Error {0!r} sending to Sentry".format(e), file=sys.stderr) return False @@ -113,7 +103,7 @@ def _send_requests(uri, headers, data, timeout): class SimpleSentryClient(object): TIMEOUT = 5 SENTRY_VERSION = 5 - USER_AGENT = 'shentry/{0}'.format(VERSION) + USER_AGENT = "shentry/{0}".format(VERSION) def __init__(self, dsn, uri, public, secret, project_id): self.dsn = dsn @@ -124,9 +114,9 @@ def __init__(self, dsn, uri, public, secret, project_id): @classmethod def new_from_environment(cls): - dsn = os.environ.pop('SHELL_SENTRY_DSN', '') + dsn = os.environ.pop("SHELL_SENTRY_DSN", "") if not dsn: - dsn = os.environ.pop('SENTRY_DSN', '') + dsn = os.environ.pop("SENTRY_DSN", "") if not dsn: dsn = read_systemwide_config() if not dsn: @@ -134,89 +124,97 @@ def new_from_environment(cls): else: try: dsn_fields = urlparse(dsn) - keys, netloc = dsn_fields.netloc.split('@', 1) - if ':' in keys: - public, private = keys.split(':', 1) + keys, netloc = dsn_fields.netloc.split("@", 1) + if ":" in keys: + public, private = keys.split(":", 1) else: public = keys - private = '' - project_id = dsn_fields.path.lstrip('/') - uri = '{proto}://{netloc}/api/{project_id}/store/'.format( - proto=dsn_fields.scheme, netloc=netloc, + private = "" + project_id = dsn_fields.path.lstrip("/") + uri = "{proto}://{netloc}/api/{project_id}/store/".format( + proto=dsn_fields.scheme, + netloc=netloc, project_id=project_id, ) return cls(dsn, uri, public, private, project_id) except Exception as e: - print('Error parsing sentry DSN {0}: {1}'.format(dsn, e), file=sys.stderr) + print( + "Error parsing sentry DSN {0}: {1}".format(dsn, e), file=sys.stderr + ) - def send_event(self, message, level, fingerprint, logger='', culprit=None, extra_context={}): + def send_event( + self, message, level, fingerprint, logger="", culprit=None, extra_context={} + ): event_id = uuid.uuid4().hex now = int(time.time()) uname = os.uname() event = { - 'event_id': event_id, - 'timestamp': datetime.datetime.utcnow().isoformat().split('.', 1)[0], - 'message': message, - 'level': level, - 'server_name': socket.gethostname(), - 'sdk': { - 'name': 'shentry', - 'version': VERSION, - }, - 'fingerprint': fingerprint, - 'platform': 'other', - 'device': { - 'name': uname[0], - 'version': uname[2], - 'build': uname[3] + "event_id": event_id, + "timestamp": datetime.datetime.utcnow().isoformat().split(".", 1)[0], + "message": message, + "level": level, + "server_name": socket.gethostname(), + "sdk": { + "name": "shentry", + "version": VERSION, }, - 'extra': {} + "fingerprint": fingerprint, + "platform": "other", + "device": {"name": uname[0], "version": uname[2], "build": uname[3]}, + "extra": {}, } if logger: - event['logger'] = logger + event["logger"] = logger if culprit is not None: - event['culprit'] = culprit - event['extra'].update(extra_context) + event["culprit"] = culprit + event["extra"].update(extra_context) headers = { - 'X-Sentry-Auth': ( - 'Sentry sentry_version={self.SENTRY_VERSION}, ' - 'sentry_client={self.USER_AGENT}, ' - 'sentry_timestamp={now}, ' - 'sentry_key={self.public}, ' - 'sentry_secret={self.secret}' + "X-Sentry-Auth": ( + "Sentry sentry_version={self.SENTRY_VERSION}, " + "sentry_client={self.USER_AGENT}, " + "sentry_timestamp={now}, " + "sentry_key={self.public}, " + "sentry_secret={self.secret}" ).format(now=now, self=self), - 'User-Agent': self.USER_AGENT, - 'Content-Type': 'application/json', + "User-Agent": self.USER_AGENT, + "Content-Type": "application/json", } - if os.environ.get('SHELL_SENTRY_VERBOSE', '0') == '1': - print('Sending to shentry', file=sys.stderr) + if os.environ.get("SHELL_SENTRY_VERBOSE", "0") == "1": + print("Sending to shentry", file=sys.stderr) print(event, file=sys.stderr) - data = json.dumps(event).encode('utf-8') - return send_to_sentry(uri=self.uri, headers=headers, data=data, timeout=self.TIMEOUT) + data = json.dumps(event).encode("utf-8") + return send_to_sentry( + uri=self.uri, headers=headers, data=data, timeout=self.TIMEOUT + ) def get_command(argv): # get the command i_am_shell = False command = argv - if command[0] == '-c': + if command[0] == "-c": i_am_shell = True command = command[1:] - if command[0] == '--': + if command[0] == "--": command = command[1:] - shell = os.environ.get('SHELL', '/bin/sh') - if i_am_shell or 'shentry' in shell: - shell = '/bin/sh' - command_ws = ' '.join(command) - full_command = [shell, '-c', command_ws] + shell = os.environ.get("SHELL", "/bin/sh") + if i_am_shell or "shentry" in shell: + shell = "/bin/sh" + command_ws = " ".join(command) + full_command = [shell, "-c", command_ws] return full_command, command_ws, shell def show_usage(): - print('Usage: shentry [-c] command [...]', file=sys.stderr) - print('', file=sys.stderr) - print('Runs COMMAND, sending the output to Sentry if it exits non-0', file=sys.stderr) - print('Takes sentry DSN from $SHELL_SENTRY_DSN, $SENTRY_DSN, or /etc/shentry_dsn', file=sys.stderr) + print("Usage: shentry [-c] command [...]", file=sys.stderr) + print("", file=sys.stderr) + print( + "Runs COMMAND, sending the output to Sentry if it exits non-0", file=sys.stderr + ) + print( + "Takes sentry DSN from $SHELL_SENTRY_DSN, $SENTRY_DSN, or /etc/shentry_dsn", + file=sys.stderr, + ) def read_snippet(fo, max_length): @@ -228,17 +226,17 @@ def read_snippet(fo, max_length): if length > max_length: top = int(max_length / 2) - 8 bottom = max_length - top - top = fo.read(top).decode('utf-8', 'ignore') + top = fo.read(top).decode("utf-8", "ignore") rv.append(top) - if not top.endswith('\n'): - rv.append('\n') - rv.append('\n[snip]\n') + if not top.endswith("\n"): + rv.append("\n") + rv.append("\n[snip]\n") fo.seek(-1 * bottom, os.SEEK_END) - rv.append(fo.read(bottom).decode('utf-8', 'ignore')) + rv.append(fo.read(bottom).decode("utf-8", "ignore")) else: - rv.append(fo.read().decode('utf-8', 'ignore')) + rv.append(fo.read().decode("utf-8", "ignore")) read_all = True - return ''.join(rv), read_all + return "".join(rv), read_all def main(argv=None): @@ -248,20 +246,23 @@ def main(argv=None): show_usage() return 2 extra_context = { - 'PATH': os.environ.get('PATH', ''), - 'username': pwd.getpwuid(os.getuid()).pw_name + "PATH": os.environ.get("PATH", ""), + "username": pwd.getpwuid(os.getuid()).pw_name, } - if 'TZ' in os.environ: - extra_context['TZ'] = os.environ['TZ'] + if "TZ" in os.environ: + extra_context["TZ"] = os.environ["TZ"] client = SimpleSentryClient.new_from_environment() full_command, command_ws, shell = get_command(argv[1:]) - extra_context['command'] = command_ws - extra_context['shell'] = shell + extra_context["command"] = command_ws + extra_context["shell"] = shell # if we couldn't configure sentry, just pass through if client is None: signal.signal(signal.SIGPIPE, signal.SIG_DFL) os.execv(shell, full_command) - print('Unable to execv({0}, {1})'.format(shell, repr(full_command)), file=sys.stderr) + print( + "Unable to execv({0}, {1})".format(shell, repr(full_command)), + file=sys.stderr, + ) return 1 working_dir = None @@ -271,7 +272,7 @@ def passthrough(signum, frame): if p is not None: p.send_signal(signum) else: - raise ValueError('received signal %d without a child; bailing' % signum) + raise ValueError("received signal %d without a child; bailing" % signum) def reset_signals(): for sig in (signal.SIGTERM, signal.SIGQUIT, signal.SIGINT, signal.SIGPIPE): @@ -281,38 +282,49 @@ def reset_signals(): signal.signal(sig, passthrough) try: working_dir = tempfile.mkdtemp() - with open(os.path.join(working_dir, 'stdout'), 'w+b') as stdout: - with open(os.path.join(working_dir, 'stderr'), 'w+b') as stderr: + with open(os.path.join(working_dir, "stdout"), "w+b") as stdout: + with open(os.path.join(working_dir, "stderr"), "w+b") as stderr: start_time = time.time() - p = subprocess.Popen(full_command, stdout=stdout, stderr=stderr, shell=False, - preexec_fn=reset_signals) - extra_context['start_time'] = start_time - extra_context['load_average_at_exit'] = ' '.join(map(str, os.getloadavg())) - extra_context['working_directory'] = os.getcwd() - extra_context['_sent_with'] = send_to_sentry.__name__ + p = subprocess.Popen( + full_command, + stdout=stdout, + stderr=stderr, + shell=False, + preexec_fn=reset_signals, + ) + extra_context["start_time"] = start_time + extra_context["load_average_at_exit"] = " ".join( + map(str, os.getloadavg()) + ) + extra_context["working_directory"] = os.getcwd() + extra_context["_sent_with"] = send_to_sentry.__name__ if p.wait() != 0: end_time = time.time() - extra_context['duration'] = end_time - start_time + extra_context["duration"] = end_time - start_time code = p.returncode - extra_context['returncode'] = code + extra_context["returncode"] = code stderr_head, stderr_is_all = read_snippet(stderr, 700) - message = 'Command `{0}` failed with code {1}.\n'.format(command_ws, code) + message = "Command `{0}` failed with code {1}.\n".format( + command_ws, code + ) if stderr_head: if stderr_is_all: - message += '\nstderr:\n' + message += "\nstderr:\n" else: - message += '\nExcerpt of stderr:\n' + message += "\nExcerpt of stderr:\n" message += stderr_head - stdout_head, stdout_is_all = read_snippet(stdout, 200 + (700 - len(stderr_head))) + stdout_head, stdout_is_all = read_snippet( + stdout, 200 + (700 - len(stderr_head)) + ) if stdout_head: if stdout_is_all: - message += '\nstdout:\n' + message += "\nstdout:\n" else: - message += '\nExcerpt of stdout:\n' + message += "\nExcerpt of stdout:\n" message += stdout_head client.send_event( message=message, - level='error', + level="error", fingerprint=[socket.gethostname(), command_ws], extra_context=extra_context, ) @@ -321,5 +333,5 @@ def reset_signals(): shutil.rmtree(working_dir) -if __name__ == '__main__': +if __name__ == "__main__": sys.exit(main()) diff --git a/tests/test_basic.py b/tests/test_basic.py index c52394c..af93fe7 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -1,9 +1,9 @@ import os -import tempfile import socket import subprocess +import tempfile +from unittest.mock import Mock -import mock import pytest import shentry @@ -20,71 +20,97 @@ def __ne__(self, other): ANY = _Any() -@pytest.mark.parametrize('argv,expected_full_command,expected_command_ws,expected_shell', ( - (['/foo/bar'], ['/bin/bash', '-c', '/foo/bar'], '/foo/bar', '/bin/bash'), - (['/foo/bar', 'arg1', 'arg2'], ['/bin/bash', '-c', '/foo/bar arg1 arg2'], '/foo/bar arg1 arg2', '/bin/bash'), - (['-c', 'ls | head'], ['/bin/sh', '-c', 'ls | head'], 'ls | head', '/bin/sh'), -)) -def test_get_command(mocker, argv, expected_full_command, expected_command_ws, expected_shell): - mocker.patch('os.environ', autospec=True) - os.environ.get.return_value = '/bin/bash' - assert shentry.get_command(argv) == (expected_full_command, expected_command_ws, expected_shell) - os.environ.get.assert_called_once_with('SHELL', '/bin/sh') +@pytest.mark.parametrize( + "argv,expected_full_command,expected_command_ws,expected_shell", + ( + (["/foo/bar"], ["/bin/bash", "-c", "/foo/bar"], "/foo/bar", "/bin/bash"), + ( + ["/foo/bar", "arg1", "arg2"], + ["/bin/bash", "-c", "/foo/bar arg1 arg2"], + "/foo/bar arg1 arg2", + "/bin/bash", + ), + (["-c", "ls | head"], ["/bin/sh", "-c", "ls | head"], "ls | head", "/bin/sh"), + ), +) +def test_get_command( + mocker, argv, expected_full_command, expected_command_ws, expected_shell +): + mocker.patch("os.environ", autospec=True) + os.environ.get.return_value = "/bin/bash" + assert shentry.get_command(argv) == ( + expected_full_command, + expected_command_ws, + expected_shell, + ) + os.environ.get.assert_called_once_with("SHELL", "/bin/sh") class TestSimpleSentryClient(object): def test_new_from_environment(self, mocker): - mocker.patch.dict('os.environ', {'SHELL_SENTRY_DSN': 'https://pub:priv@sentry.test/1'}) + mocker.patch.dict( + "os.environ", {"SHELL_SENTRY_DSN": "https://pub:priv@sentry.test/1"} + ) client = shentry.SimpleSentryClient.new_from_environment() - assert client.uri == 'https://sentry.test/api/1/store/' - assert client.public == 'pub' - assert client.secret == 'priv' - assert client.project_id == '1' + assert client.uri == "https://sentry.test/api/1/store/" + assert client.public == "pub" + assert client.secret == "priv" + assert client.project_id == "1" def test_new_from_environment_regular_dsn(self, mocker): - mocker.patch.dict('os.environ', {'SENTRY_DSN': 'https://pub:priv@sentry.test/3'}) + mocker.patch.dict( + "os.environ", {"SENTRY_DSN": "https://pub:priv@sentry.test/3"} + ) client = shentry.SimpleSentryClient.new_from_environment() - assert client.uri == 'https://sentry.test/api/3/store/' - assert client.public == 'pub' - assert client.secret == 'priv' - assert client.project_id == '3' + assert client.uri == "https://sentry.test/api/3/store/" + assert client.public == "pub" + assert client.secret == "priv" + assert client.project_id == "3" def test_new_from_environment_with_file(self, mocker): - mocker.patch.dict('os.environ', {'SHELL_SENTRY_DSN': ''}) - mocker.patch.object(shentry, 'read_systemwide_config', return_value='https://pub:priv@sentry.test/2') + mocker.patch.dict("os.environ", {"SHELL_SENTRY_DSN": ""}) + mocker.patch.object( + shentry, + "read_systemwide_config", + return_value="https://pub:priv@sentry.test/2", + ) client = shentry.SimpleSentryClient.new_from_environment() - assert client.uri == 'https://sentry.test/api/2/store/' - assert client.public == 'pub' - assert client.secret == 'priv' - assert client.project_id == '2' + assert client.uri == "https://sentry.test/api/2/store/" + assert client.public == "pub" + assert client.secret == "priv" + assert client.project_id == "2" def test_main(mocker, tmpdir): - mock_client = mock.Mock(autospec=shentry.SimpleSentryClient) - mocker.patch('shentry.SimpleSentryClient.new_from_environment', return_value=mock_client) - mocker.patch('tempfile.mkdtemp', return_value=str(tmpdir)) - mocker.patch.dict('os.environ', {'SHELL': '/bin/fish', 'PATH': 'A_PATH', 'TZ': 'UTC'}) - mock_popen = mock.Mock(autospec=subprocess.Popen) - mocker.patch('subprocess.Popen', return_value=mock_popen) + mock_client = Mock(autospec=shentry.SimpleSentryClient) + mocker.patch( + "shentry.SimpleSentryClient.new_from_environment", return_value=mock_client + ) + mocker.patch("tempfile.mkdtemp", return_value=str(tmpdir)) + mocker.patch.dict( + "os.environ", {"SHELL": "/bin/fish", "PATH": "A_PATH", "TZ": "UTC"} + ) + mock_popen = Mock(autospec=subprocess.Popen) + mocker.patch("subprocess.Popen", return_value=mock_popen) mock_popen.wait.return_value = 1 mock_popen.returncode = 1 - shentry.main(['shentry', '/bin/ls']) + shentry.main(["shentry", "/bin/ls"]) tempfile.mkdtemp.assert_called_once_with() mock_client.send_event.assert_called_once_with( - message='Command `/bin/ls` failed with code 1.\n', - level='error', - fingerprint=[socket.gethostname(), '/bin/ls'], + message="Command `/bin/ls` failed with code 1.\n", + level="error", + fingerprint=[socket.gethostname(), "/bin/ls"], extra_context={ - 'username': ANY, - 'shell': '/bin/fish', - 'load_average_at_exit': ANY, - 'start_time': ANY, - 'command': '/bin/ls', - 'duration': ANY, - 'PATH': 'A_PATH', - 'TZ': 'UTC', - 'returncode': 1, - 'working_directory': ANY, - '_sent_with': ANY, - } + "username": ANY, + "shell": "/bin/fish", + "load_average_at_exit": ANY, + "start_time": ANY, + "command": "/bin/ls", + "duration": ANY, + "PATH": "A_PATH", + "TZ": "UTC", + "returncode": 1, + "working_directory": ANY, + "_sent_with": ANY, + }, ) diff --git a/tests/test_integration.py b/tests/test_integration.py index a883c26..f331634 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -1,31 +1,29 @@ import collections -import threading -import subprocess -import os import json -import mock +import os import pwd import socket +import subprocess import sys +import threading +from unittest.mock import ANY import pytest try: - from BaseHTTPServer import HTTPServer - from BaseHTTPServer import BaseHTTPRequestHandler + from BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer except ImportError: - from http.server import HTTPServer - from http.server import BaseHTTPRequestHandler + from http.server import BaseHTTPRequestHandler, HTTPServer -Request = collections.namedtuple('Request', ['command', 'path', 'headers', 'body']) +Request = collections.namedtuple("Request", ["command", "path", "headers", "body"]) class SentryHTTPServer(HTTPServer): timeout = 0.1 def __init__(self, *args, **kwargs): - requests = kwargs.pop('requests') + requests = kwargs.pop("requests") HTTPServer.__init__(self, *args, **kwargs) self.requests = requests @@ -35,14 +33,19 @@ def handle_timeout(self): class SentryHTTPRequestHandler(BaseHTTPRequestHandler): def do_POST(self): - body_len = int(self.headers.get('Content-Length', '0')) + body_len = int(self.headers.get("Content-Length", "0")) body = self.rfile.read(body_len) - request = Request(command=self.command, path=self.path, headers=dict(self.headers.items()), body=body) + request = Request( + command=self.command, + path=self.path, + headers=dict(self.headers.items()), + body=body, + ) self.server.requests.append(request) self.send_response(200) - self.send_header('Content-Type', 'application/json') - body = json.dumps({'status': 'ok'}).encode('utf-8') - self.send_header('Content-Length', str(len(body))) + self.send_header("Content-Type", "application/json") + body = json.dumps({"status": "ok"}).encode("utf-8") + self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) @@ -57,11 +60,13 @@ def __init__(self): @property def uri(self): - return 'http://sentry:password@{0}/'.format(':'.join(map(str, self.address))) + return "http://sentry:password@{0}/".format(":".join(map(str, self.address))) def run(self): self.running = True - httpd = SentryHTTPServer(('127.0.0.1', 0), SentryHTTPRequestHandler, requests=self.requests) + httpd = SentryHTTPServer( + ("127.0.0.1", 0), SentryHTTPRequestHandler, requests=self.requests + ) self.address = httpd.server_address self._started.acquire() self._started.notify_all() @@ -91,12 +96,12 @@ def http_server(): t_s.stop() -FAIL_NO_OUTPUT = '''#!/bin/bash +FAIL_NO_OUTPUT = """#!/bin/bash exit 1 -''' +""" -FAIL_LONG_OUTPUT = '''#!/bin/bash +FAIL_LONG_OUTPUT = """#!/bin/bash for i in $(seq 1 4000) do @@ -104,14 +109,14 @@ def http_server(): done exit 1 -''' +""" @pytest.fixture def scripts(tmpdir): paths = {} - for script in ('FAIL_NO_OUTPUT', 'FAIL_LONG_OUTPUT'): - with open(os.path.join(str(tmpdir), script), 'w') as f: + for script in ("FAIL_NO_OUTPUT", "FAIL_LONG_OUTPUT"): + with open(os.path.join(str(tmpdir), script), "w") as f: f.write(globals()[script]) os.fchmod(f.fileno(), 0o700) paths[script] = os.path.join(str(tmpdir), script) @@ -120,87 +125,91 @@ def scripts(tmpdir): def test_no_output(http_server, scripts): subprocess.check_call( - [sys.executable, 'shentry.py', scripts['FAIL_NO_OUTPUT']], + [sys.executable, "shentry.py", scripts["FAIL_NO_OUTPUT"]], env={ - 'SHELL_SENTRY_DSN': http_server.uri, - 'TZ': 'UTC', - } + "SHELL_SENTRY_DSN": http_server.uri, + "TZ": "UTC", + }, ) # ensure that the http server has processed all requests http_server.stop() assert len(http_server.requests) == 1 req = http_server.requests[0] - assert req.command == 'POST' - body = json.loads(req.body.decode('utf-8')) + assert req.command == "POST" + body = json.loads(req.body.decode("utf-8")) assert body == { - 'device': mock.ANY, - 'event_id': mock.ANY, - 'extra': { - 'PATH': mock.ANY, - 'TZ': 'UTC', - '_sent_with': mock.ANY, - 'command': scripts['FAIL_NO_OUTPUT'], - 'duration': mock.ANY, - 'load_average_at_exit': mock.ANY, - 'returncode': 1, - 'shell': '/bin/sh', - 'start_time': mock.ANY, - 'username': pwd.getpwuid(os.getuid()).pw_name, - 'working_directory': mock.ANY, + "device": ANY, + "event_id": ANY, + "extra": { + "PATH": ANY, + "TZ": "UTC", + "_sent_with": ANY, + "command": scripts["FAIL_NO_OUTPUT"], + "duration": ANY, + "load_average_at_exit": ANY, + "returncode": 1, + "shell": "/bin/sh", + "start_time": ANY, + "username": pwd.getpwuid(os.getuid()).pw_name, + "working_directory": ANY, }, - 'fingerprint': mock.ANY, - 'message': 'Command `{0}` failed with code 1.\n'.format(scripts['FAIL_NO_OUTPUT']), - 'platform': 'other', - 'server_name': socket.gethostname(), - 'level': 'error', - 'sdk': { - 'name': 'shentry', - 'version': mock.ANY, + "fingerprint": ANY, + "message": "Command `{0}` failed with code 1.\n".format( + scripts["FAIL_NO_OUTPUT"] + ), + "platform": "other", + "server_name": socket.gethostname(), + "level": "error", + "sdk": { + "name": "shentry", + "version": ANY, }, - 'timestamp': mock.ANY, + "timestamp": ANY, } def test_multi_kb_output(http_server, scripts): subprocess.check_call( - [sys.executable, 'shentry.py', scripts['FAIL_LONG_OUTPUT']], + [sys.executable, "shentry.py", scripts["FAIL_LONG_OUTPUT"]], env={ - 'SHELL_SENTRY_DSN': http_server.uri, - 'TZ': 'UTC', - } + "SHELL_SENTRY_DSN": http_server.uri, + "TZ": "UTC", + }, ) # ensure that the http server has processed all requests http_server.stop() assert len(http_server.requests) == 1 req = http_server.requests[0] - assert req.command == 'POST' - body = json.loads(req.body.decode('utf-8')) + assert req.command == "POST" + body = json.loads(req.body.decode("utf-8")) assert body == { - 'device': mock.ANY, - 'event_id': mock.ANY, - 'extra': { - 'PATH': mock.ANY, - 'TZ': 'UTC', - '_sent_with': mock.ANY, - 'command': scripts['FAIL_LONG_OUTPUT'], - 'duration': mock.ANY, - 'load_average_at_exit': mock.ANY, - 'returncode': 1, - 'shell': '/bin/sh', - 'start_time': mock.ANY, - 'username': pwd.getpwuid(os.getuid()).pw_name, - 'working_directory': mock.ANY, + "device": ANY, + "event_id": ANY, + "extra": { + "PATH": ANY, + "TZ": "UTC", + "_sent_with": ANY, + "command": scripts["FAIL_LONG_OUTPUT"], + "duration": ANY, + "load_average_at_exit": ANY, + "returncode": 1, + "shell": "/bin/sh", + "start_time": ANY, + "username": pwd.getpwuid(os.getuid()).pw_name, + "working_directory": ANY, }, - 'fingerprint': mock.ANY, - 'message': mock.ANY, - 'platform': 'other', - 'server_name': socket.gethostname(), - 'level': 'error', - 'sdk': { - 'name': 'shentry', - 'version': mock.ANY, + "fingerprint": ANY, + "message": ANY, + "platform": "other", + "server_name": socket.gethostname(), + "level": "error", + "sdk": { + "name": "shentry", + "version": ANY, }, - 'timestamp': mock.ANY, + "timestamp": ANY, } - expected = 'Command `{0}` failed with code 1.\n\nExcerpt of stderr:\n'.format(scripts['FAIL_LONG_OUTPUT']) - assert body['message'].startswith(expected) + expected = "Command `{0}` failed with code 1.\n\nExcerpt of stderr:\n".format( + scripts["FAIL_LONG_OUTPUT"] + ) + assert body["message"].startswith(expected)