├── .gitignore ├── .travis.yml ├── CODE_OF_CONDUCT.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── grafannotate ├── __init__.py ├── annotation.py └── cli.py ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── test_annotation.py └── test_cli.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | 10 | # Distribution / packaging 11 | .Python 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | nosetests.xml 46 | coverage.xml 47 | *.cover 48 | .hypothesis/ 49 | .pytest_cache/ 50 | 51 | # Translations 52 | *.mo 53 | *.pot 54 | 55 | # Django stuff: 56 | *.log 57 | local_settings.py 58 | db.sqlite3 59 | 60 | # Flask stuff: 61 | instance/ 62 | .webassets-cache 63 | 64 | # Scrapy stuff: 65 | .scrapy 66 | 67 | # Sphinx documentation 68 | docs/_build/ 69 | 70 | # PyBuilder 71 | target/ 72 | 73 | # Jupyter Notebook 74 | .ipynb_checkpoints 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # SageMath parsed files 80 | *.sage.py 81 | 82 | # Environments 83 | .env 84 | .venv 85 | env/ 86 | venv/ 87 | ENV/ 88 | env.bak/ 89 | venv.bak/ 90 | 91 | # Spyder project settings 92 | .spyderproject 93 | .spyproject 94 | 95 | # Rope project settings 96 | .ropeproject 97 | 98 | # mkdocs documentation 99 | /site 100 | 101 | # mypy 102 | .mypy_cache/ 103 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | dist: xenial 3 | language: python 4 | python: 5 | - 3.6 6 | - 3.7 7 | - 3.8 8 | matrix: 9 | include: 10 | - python: 3.8 11 | env: TOXENV=flake8 12 | install: 13 | - pip install tox-travis 14 | - pip install coveralls 15 | script: 16 | - tox 17 | after_success: coveralls 18 | deploy: 19 | provider: pypi 20 | distributions: sdist bdist_wheel 21 | skip_existing: true 22 | user: timbirk 23 | password: 24 | secure: H1MEew3qePw0SZ5TrqVTd9agYo7t+oOOBSkyXhVx2OLwQfrB0P+6EBm4I1l/iSVdKTkoCMkB1Kprqc9pN+1uHlbVw/35WNa071DfddwoQMBWRmK6zstDEsVyJ1XQEVTB3CIhBnRzZti1qLWmY3RmKFYHdXuvJSUNQPXtgy7rPY0yhdf0fbuNPiFykD8QJgt3NSjV9jOUz0mru9XiPxlFh/ehSXb0ZeIQUK1x/4ywR+p8n6ve4U3LY1WqJf/641ysrgFqWwuPjf5fj7AbrnKj2slHq6WMNulWdo/oDe1l4/ZjKcieRu5amLv4uZoJcq4x7FpQSU/hJv75kFYXJxXhnVOiLwVosfaKmvya3K6WwaFqW13KW+GBijXxHSchFNUhd8wnWPxgRl40OT9dvYB01HaB+xQQeBuID65mF9/zBA/fBGmlTrmWVT8z+UDqB7r5mncCLkb7PhlQQNhmNIASWt5fTf5A7aII2zEQ/h/q/EABJaFwaMWftaYk0ZBIDd0Ms4UJXmyZUxmB2XPe8AdYqJIHCCUdGlUZmEQCiM+Pd/joa/Ska2UBR/bnnxUH1FAOOERqaXk+8qlTkAYv3fHF7kvDKGA7IUSeMa9wQjR61Tk4Ifd5tuHfVT1fkZnGvBDF0VybFoy5oQmnURBr/izC7iY7IVFPb4NW7eJWsIjkTZA= 25 | on: 26 | tags: true 27 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at . All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 DevOps Makers 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 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.py 2 | include README.md 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `grafannotate` 2 | 3 | [![Build Status](https://travis-ci.org/devopsmakers/python-grafannotate.svg?branch=master)](https://travis-ci.org/devopsmakers/python-grafannotate) 4 | [![Coverage Status](https://coveralls.io/repos/github/devopsmakers/python-grafannotate/badge.svg?branch=master)](https://coveralls.io/github/devopsmakers/python-grafannotate?branch=master) 5 | [![PyPI version](https://badge.fury.io/py/grafannotate.svg)](https://badge.fury.io/py/grafannotate) 6 | 7 | A CLI tool to send Grafana annotations to various destinations. 8 | 9 | ## Installation 10 | ``` 11 | pip install grafannotate 12 | ``` 13 | 14 | ## Usage 15 | 16 | ``` 17 | grafannotate --help 18 | Usage: grafannotate [OPTIONS] 19 | 20 | Send Grafana annotations to various endpoints 21 | 22 | Options: 23 | -u, --uri TEXT URI to send annotation to. Default: 24 | "http://localhost:3000/api/annotations". 25 | -k, --api-key TEXT Grafana API key to pass in Authorisation header 26 | -T, --title TEXT Event title. Default: "event". 27 | -t, --tag TEXT Event tags (can be used multiple times). 28 | -d, --description TEXT Event description body. Optional. 29 | -s, --start INTEGER Start timestamp (unix secs). Default: current 30 | timestamp. 31 | -e, --end INTEGER End timestamp (unix secs). Optional. 32 | --help Show this message and exit. 33 | ``` 34 | 35 | ### Examples 36 | - Send an annotation to Grafana API for current time 37 | ``` 38 | grafannotate --uri http://user:password@grafana:3000/api/annotations --tag my_tag --title "Event Title" 39 | ``` 40 | 41 | - Send an annotation to Grafana API for a time region 42 | ``` 43 | grafannotate --uri http://user:password@grafana:3000/api/annotations --tag my_tag --title "Event Title" --start 1557222057 --end 1557222259 44 | ``` 45 | 46 | - Send an annotation to Grafana API with an extended description 47 | ``` 48 | grafannotate --uri http://user:password@grafana:3000/api/annotations --tag my_tag --title "Event Title" --description "Some longer description
with newlines
and links" 49 | ``` 50 | 51 | - Pipe output to an annotation description 52 | ``` 53 | START_TIME=`date +%s` 54 | command_with_output | grafannotate --uri http://user:password@grafana:3000/api/annotations --tag my_tag --title "Event Title" --start $START_TIME 55 | ``` 56 | 57 | - Send an annotation to Grafana API using Authorization header 58 | ``` 59 | GRAFANA_API_TOKEN="some_generated_api_token" 60 | grafannotate --uri http://grafana:3000/api/annotations --tag my_tag --title "Event Title" --api-key $GRAFANA_API_TOKEN 61 | ``` 62 | -------------------------------------------------------------------------------- /grafannotate/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devopsmakers/python-grafannotate/ef09dc74dc97b30a8bcb2eb362f6346d539b918a/grafannotate/__init__.py -------------------------------------------------------------------------------- /grafannotate/annotation.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import time 3 | from influxdb import InfluxDBClient 4 | from urllib.parse import urlparse 5 | 6 | CURRENT_TIMESTAMP = int(time.time()) 7 | 8 | 9 | class Annotation: 10 | """ 11 | An annotation that we want to create 12 | """ 13 | def __init__(self, title, tags, description='', start=CURRENT_TIMESTAMP, end=CURRENT_TIMESTAMP): 14 | if len(tags) == 0: 15 | raise ValueError('Annotations must have at least one tag.') 16 | 17 | if end < start: 18 | raise ValueError('Annotation end time cannot be before start time.') 19 | 20 | self.title = title 21 | self.tags = tags 22 | self.description = description 23 | self.start = start 24 | self.end = end 25 | 26 | def web(self): 27 | """ 28 | Returns an annotation object formatted for grafana API 29 | """ 30 | annotation_event = {} 31 | annotation_event['text'] = '%s\n\n%s' % (self.title, self.description) 32 | annotation_event['tags'] = self.tags 33 | annotation_event['time'] = int(round(self.start * 1000)) 34 | if self.start < self.end: 35 | annotation_event['isRegion'] = True 36 | annotation_event['timeEnd'] = int(round(self.end * 1000)) 37 | return annotation_event 38 | 39 | def influxdb(self): 40 | """ 41 | Returns an annotation object formatted for InfluxDB 42 | """ 43 | tags_field = ';'.join(self.tags) 44 | annotation_event = {} 45 | annotation_event['measurement'] = 'events' 46 | annotation_event['fields'] = { 47 | 'title': self.title, 48 | 'text': self.description, 49 | 'tags': tags_field 50 | } 51 | return [annotation_event] 52 | 53 | def send(self, url, api_key): 54 | """ 55 | Send the annotation to a destination based on url 56 | """ 57 | url_parts = urlparse(url) 58 | if 'http' in url_parts.scheme: 59 | return self.send_to_web(url_parts, api_key) 60 | elif 'influx' in url_parts.scheme: 61 | return self.send_to_influxdb(url_parts) 62 | else: 63 | raise NotImplementedError('Scheme %s not recognised in uri %s' % 64 | (url_parts.scheme, url)) 65 | 66 | def send_to_web(self, url_parts, api_key): 67 | """ 68 | POST event to an endpoint in Grafana Annotations API format 69 | """ 70 | event_data = self.web() 71 | result_data = {'event_data': event_data} 72 | url = url_parts.geturl() 73 | auth_tuple = None 74 | req_headers = {} 75 | if api_key is not None: 76 | req_headers['Authorization'] = "Bearer %s" % api_key 77 | 78 | if url_parts.username and url_parts.password: 79 | auth_tuple = (url_parts.username, url_parts.password) 80 | url_host_port = url_parts.netloc.split('@')[1] 81 | url = '%s://%s%s' % (url_parts.scheme, url_host_port, url_parts.path) 82 | 83 | post_result = requests.post(url, json=event_data, auth=auth_tuple, headers=req_headers, timeout=5) 84 | 85 | if post_result.status_code > 299: 86 | raise Exception('Received %s response, sending event failed' % post_result.status_code) 87 | 88 | if 'id' in post_result.json(): 89 | result_data['id'] = post_result.json()['id'] 90 | 91 | if 'message' in post_result.json(): 92 | result_data['message'] = post_result.json()['message'] 93 | 94 | return result_data 95 | 96 | def send_to_influxdb(self, url_parts): 97 | event_data = self.influxdb() 98 | result_data = {'event_data': event_data} 99 | client = InfluxDBClient(url_parts.hostname, 100 | url_parts.port or 8086, 101 | url_parts.username or '', 102 | url_parts.password or '', 103 | url_parts.path.replace('/', '', 1) or 'events') 104 | 105 | if client.write_points(event_data): 106 | result_data['message'] = 'Annotation added' 107 | else: 108 | result_data['message'] = 'Annotation failed' 109 | 110 | return result_data 111 | -------------------------------------------------------------------------------- /grafannotate/cli.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import click 3 | import logging 4 | import time 5 | 6 | from grafannotate.annotation import Annotation 7 | 8 | CURRENT_TIMESTAMP = int(time.time()) 9 | 10 | 11 | @click.command() 12 | @click.option('-u', '--uri', 'annotate_uri', 13 | default='http://localhost:3000/api/annotations', 14 | help='URI to send annotation to. Default: "http://localhost:3000/api/annotations".') 15 | @click.option('-k', '--api-key', 'api_key', default=None, 16 | help='Grafana API key to pass in Authorisation header') 17 | @click.option('-T', '--title', 'title', default='event', help='Event title. Default: "event".') 18 | @click.option('-t', '--tag', 'tags', multiple=True, help='Event tags (can be used multiple times).') 19 | @click.option('-d', '--description', 'description', help='Event description body. Optional.') 20 | @click.option('-s', '--start', 'start_time', default=CURRENT_TIMESTAMP, 21 | help='Start timestamp (unix secs). Default: current timestamp.') 22 | @click.option('-e', '--end', 'end_time', default=CURRENT_TIMESTAMP, 23 | help='End timestamp (unix secs). Optional.') 24 | @click.option('--debug/--no-debug', default=False, 25 | help='Set debug logging on') 26 | def main(annotate_uri, api_key, title, tags, description, start_time, end_time, debug): 27 | """ 28 | Send Grafana annotations to various endpoints 29 | """ 30 | 31 | log_level = logging.INFO 32 | if debug: 33 | log_level = logging.DEBUG 34 | 35 | logging.basicConfig(format=' [%(levelname)s] %(message)s', level=log_level) 36 | 37 | try: 38 | if description is None: 39 | if not sys.stdin.isatty(): 40 | description = "".join([line for line in iter(sys.stdin.readline, '')]) 41 | else: 42 | description = "" 43 | 44 | this_annotation = Annotation(title, tags, description, start_time, end_time) 45 | result = this_annotation.send(annotate_uri, api_key) 46 | 47 | if result['event_data']: 48 | logging.debug(result['event_data']) 49 | if result['message']: 50 | logging.info(result['message']) 51 | 52 | except Exception as e: 53 | logging.exception(e) 54 | """ 55 | We could exit 1 here but we really don't want to cause a job to 56 | fail just because we couldn't send an event. 57 | """ 58 | 59 | sys.exit(0) 60 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # python-grafannotate 2 | # --------------- 3 | # A CLI tool to add annotations to Grafana 4 | # 5 | # Author: Tim Birkett 6 | # Website: https://github.com/devopsmakers/python-grafannotate 7 | # License: MIT License (see LICENSE file) 8 | 9 | import codecs 10 | from setuptools import find_packages, setup 11 | 12 | dependencies = [ 13 | 'click==7.0', 14 | 'requests==2.22.0', 15 | 'influxdb==5.2.3' 16 | ] 17 | 18 | setup( 19 | name='grafannotate', 20 | version='0.3.0', 21 | url='https://github.com/devopsmakers/python-grafannotate', 22 | license='MIT', 23 | author='Tim Birkett', 24 | author_email='tim.birkett@devopsmakers.com', 25 | description='Send annotations to Grafana', 26 | long_description=codecs.open('README.md', encoding='utf-8').read(), 27 | long_description_content_type='text/markdown', 28 | packages=find_packages(exclude=['tests']), 29 | include_package_data=True, 30 | zip_safe=False, 31 | platforms='any', 32 | install_requires=dependencies, 33 | entry_points={ 34 | 'console_scripts': [ 35 | 'grafannotate = grafannotate.cli:main', 36 | ], 37 | }, 38 | classifiers=[ 39 | # As from http://pypi.python.org/pypi?%3Aaction=list_classifiers 40 | 'Development Status :: 5 - Production/Stable', 41 | 'Topic :: Utilities', 42 | 'Environment :: Console', 43 | 'Intended Audience :: Information Technology', 44 | 'Intended Audience :: System Administrators', 45 | 'License :: OSI Approved :: MIT License', 46 | 'Operating System :: OS Independent', 47 | 'Programming Language :: Python', 48 | 'Programming Language :: Python :: 3', 49 | ] 50 | ) 51 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devopsmakers/python-grafannotate/ef09dc74dc97b30a8bcb2eb362f6346d539b918a/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_annotation.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import time 3 | import mock 4 | import requests_mock 5 | 6 | from grafannotate.annotation import Annotation 7 | 8 | CURRENT_TIMESTAMP = int(time.time()) 9 | 10 | 11 | def test_annotation_without_values(): 12 | with pytest.raises(TypeError): 13 | Annotation() 14 | 15 | 16 | def test_annotation_without_tags(): 17 | with pytest.raises(ValueError, match='must have at least one tag.'): 18 | Annotation('event', [], '') 19 | 20 | 21 | def test_annotation_without_times(): 22 | test_annotation = Annotation('event', ['events'], '') 23 | assert test_annotation.start == test_annotation.end 24 | 25 | 26 | def test_annotation_with_bad_end_time(): 27 | with pytest.raises(ValueError, match='end time cannot be before start time.'): 28 | Annotation('event', ['test'], '', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP - 300) 29 | 30 | 31 | def test_annotation_web(): 32 | start_time = CURRENT_TIMESTAMP 33 | end_time = CURRENT_TIMESTAMP + 600 34 | test_annotation = Annotation('event', ['test'], 'testing', start_time, end_time) 35 | test_web_annotation = test_annotation.web() 36 | assert test_web_annotation['time'] == int(round(start_time * 1000)) 37 | assert test_web_annotation['timeEnd'] == int(round(end_time * 1000)) 38 | assert test_web_annotation['isRegion'] is True 39 | assert test_web_annotation['tags'] == ['test'] 40 | assert test_web_annotation['text'] == 'event\n\ntesting' 41 | 42 | 43 | def test_annotation_influxdb(): 44 | start_time = CURRENT_TIMESTAMP 45 | end_time = CURRENT_TIMESTAMP + 600 46 | test_annotation = Annotation('event', ['test', 'influx'], 'testing', start_time, end_time) 47 | test_influxdb_annotation = test_annotation.influxdb() 48 | assert test_influxdb_annotation[0]['measurement'] == 'events' 49 | assert test_influxdb_annotation[0]['fields'] == { 50 | 'tags': 'test;influx', 51 | 'text': 'testing', 52 | 'title': 'event' 53 | } 54 | 55 | 56 | def test_annotation_fail_to_send_to_web(): 57 | url = "http://user:pass@localhost" 58 | test_annotation = Annotation('event', ['test'], 'testing') 59 | with pytest.raises(Exception, match='NewConnectionError'): 60 | test_annotation.send(url, None) 61 | 62 | 63 | def test_annotation_send_to_web(): 64 | url = "http://localhost:3000/api/annotations" 65 | with requests_mock.Mocker() as m: 66 | m.register_uri( 67 | requests_mock.POST, 68 | url, 69 | status_code=200, 70 | json={'message': 'Annotation added', 'id': '12345'} 71 | ) 72 | test_annotation = Annotation('event', ['test'], 'testing', 1559332960, 1559332970) 73 | assert test_annotation.send(url, None) == { 74 | 'event_data': { 75 | 'isRegion': True, 76 | 'tags': ['test'], 77 | 'text': 'event\n\ntesting', 78 | 'time': 1559332960000, 79 | 'timeEnd': 1559332970000 80 | }, 81 | 'id': '12345', 82 | 'message': 'Annotation added' 83 | } 84 | 85 | 86 | def test_annotation_send_to_web_with_api_key(): 87 | url = "http://localhost:3000/api/annotations" 88 | api_key = "307c1ac4-4e7c-4eb4-a56f-3547eeff0e4b" 89 | with requests_mock.Mocker() as m: 90 | m.register_uri( 91 | requests_mock.POST, 92 | url, 93 | request_headers={'Authorization': "Bearer %s" % api_key}, 94 | status_code=200, 95 | json={'message': 'Annotation added'} 96 | ) 97 | test_annotation = Annotation('event', ['test'], 'testing', 1559332960, 1559332970) 98 | assert test_annotation.send(url, api_key) == { 99 | 'event_data': { 100 | 'isRegion': True, 101 | 'tags': ['test'], 102 | 'text': 'event\n\ntesting', 103 | 'time': 1559332960000, 104 | 'timeEnd': 1559332970000 105 | }, 106 | 'message': 'Annotation added' 107 | } 108 | 109 | 110 | def test_annotation_error_sending_to_web(): 111 | url = "http://localhost:3000/api/annotations" 112 | with requests_mock.Mocker() as m: 113 | m.register_uri( 114 | requests_mock.POST, 115 | url, 116 | status_code=400 117 | ) 118 | test_annotation = Annotation('event', ['test'], 'testing', 1559332960, 1559332960) 119 | with pytest.raises(Exception, match='Received 400 response, sending event failed'): 120 | test_annotation.send(url, None) 121 | 122 | 123 | def test_annotation_fail_to_send_to_influxdb(): 124 | url = "influx://user:pass@localhost" 125 | test_annotation = Annotation('event', ['test'], 'testing') 126 | with pytest.raises(Exception, match='Failed to establish a new connection'): 127 | test_annotation.send(url, None) 128 | 129 | 130 | @mock.patch('influxdb.InfluxDBClient.write_points') 131 | def test_annotation_send_to_influxdb(mock_write_points): 132 | url = "influx://user:pass@localhost" 133 | test_annotation = Annotation('event', ['test'], 'testing') 134 | mock_write_points.return_value = True 135 | assert test_annotation.send(url, None) == { 136 | 'event_data': [{ 137 | 'fields': { 138 | 'tags': 'test', 139 | 'text': 'testing', 140 | 'title': 'event' 141 | }, 142 | 'measurement': 'events' 143 | }], 144 | 'message': 'Annotation added' 145 | } 146 | 147 | 148 | @mock.patch('influxdb.InfluxDBClient.write_points') 149 | def test_annotation_send_to_influxdb_fail(mock_write_points): 150 | url = "influx://user:pass@localhost" 151 | test_annotation = Annotation('event', ['test'], 'testing') 152 | mock_write_points.return_value = False 153 | assert test_annotation.send(url, None) == { 154 | 'event_data': [{ 155 | 'fields': { 156 | 'tags': 'test', 157 | 'text': 'testing', 158 | 'title': 'event' 159 | }, 160 | 'measurement': 'events' 161 | }], 162 | 'message': 'Annotation failed' 163 | } 164 | 165 | 166 | def test_annotation_send_bad_url(): 167 | url = "s3://user:pass@localhost" 168 | test_annotation = Annotation('event', ['test'], 'testing') 169 | with pytest.raises(NotImplementedError, match='Scheme s3 not recognised'): 170 | test_annotation.send(url, None) 171 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import time 3 | import mock 4 | 5 | from click.testing import CliRunner 6 | from grafannotate import cli 7 | 8 | CURRENT_TIMESTAMP = int(time.time()) 9 | 10 | 11 | @pytest.fixture 12 | def runner(): 13 | return CliRunner() 14 | 15 | 16 | def test_cli(runner, caplog): 17 | result = runner.invoke(cli.main) 18 | assert result.exit_code == 0 19 | assert 'must have at least one tag' in caplog.text 20 | 21 | 22 | def test_cli_with_tag(runner, caplog): 23 | result = runner.invoke(cli.main, ['--tag', 'event']) 24 | assert result.exit_code == 0 25 | assert 'NewConnectionError' in caplog.text 26 | 27 | 28 | @mock.patch('grafannotate.cli.Annotation.send', autospec=True) 29 | def test_cli_with_debug_mock(mock_send, runner, caplog): 30 | return_data = { 31 | 'event_data': { 32 | 'isRegion': True, 33 | 'tags': ['test'], 34 | 'text': 'event\n\ntesting', 35 | 'time': 1559332960000, 36 | 'timeEnd': 1559332970000 37 | }, 38 | 'id': '12345', 39 | 'message': 'Annotation added' 40 | } 41 | mock_send.return_value = return_data 42 | caplog.set_level('DEBUG') 43 | result = runner.invoke(cli.main, ['--tag', 'event']) 44 | assert result.exit_code == 0 45 | assert return_data['message'] in caplog.text 46 | assert str(return_data['event_data']) in caplog.text 47 | 48 | 49 | def test_cli_with_debug(runner, caplog): 50 | result = runner.invoke(cli.main, ['--tag', 'event', '--debug']) 51 | assert result.exit_code == 0 52 | assert 'NewConnectionError' in caplog.text 53 | 54 | 55 | def test_cli_with_bad_end_time(runner, caplog): 56 | result = runner.invoke(cli.main, ['--tag', 'event', '--end', 0]) 57 | assert result.exit_code == 0 58 | assert 'end time cannot be before start time' in caplog.text 59 | 60 | 61 | def test_cli_with_bad_uri(runner, caplog): 62 | result = runner.invoke(cli.main, ['--tag', 'event', '--uri', 'blob://localhost']) 63 | assert result.exit_code == 0 64 | assert 'Scheme blob not recognised' in caplog.text 65 | 66 | 67 | def test_cli_with_user_pass(runner, caplog): 68 | result = runner.invoke(cli.main, ['--tag', 'event', '--uri', 'http://user:pass@localhost']) 69 | assert result.exit_code == 0 70 | assert 'NewConnectionError' in caplog.text 71 | 72 | 73 | def test_cli_with_api_key(runner, caplog): 74 | result = runner.invoke(cli.main, ['--tag', 'event', '--uri', 'http://localhost', '--api-key', 'aTestKey']) 75 | assert result.exit_code == 0 76 | assert 'NewConnectionError' in caplog.text 77 | 78 | 79 | def test_cli_with_end_time(runner, caplog): 80 | result = runner.invoke(cli.main, ['--tag', 'event', '--end', CURRENT_TIMESTAMP + 600]) 81 | assert result.exit_code == 0 82 | assert 'NewConnectionError' in caplog.text 83 | 84 | 85 | def test_cli_with_influx(runner, caplog): 86 | result = runner.invoke(cli.main, ['--tag', 'event', '--uri', 'influx://localhost:8086']) 87 | assert result.exit_code == 0 88 | assert 'NewConnectionError' in caplog.text 89 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist=py{36,37,38}, flake8 3 | 4 | [testenv] 5 | commands=py.test --cov grafannotate -vv {posargs} 6 | deps= 7 | pytest 8 | pytest-cov 9 | mock 10 | requests-mock 11 | 12 | [testenv:flake8] 13 | basepython = python3.8 14 | deps = 15 | flake8 16 | commands = 17 | flake8 grafannotate tests --max-line-length=120 18 | --------------------------------------------------------------------------------