├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── Makefile ├── README ├── README.md ├── development.md ├── librato ├── __init__.py ├── aggregator.py ├── alerts.py ├── annotations.py ├── exceptions.py ├── metrics.py ├── queue.py ├── spaces.py └── streams.py ├── setup.py ├── sh ├── beautify_json.sh ├── check_coverage.sh ├── common.sh ├── publish.sh ├── run_tests.sh └── watch_and_run_tests.sh ├── tests ├── README ├── integration.py ├── mock_connection.py ├── test_aggregator.py ├── test_alerts.py ├── test_annotations.py ├── test_charts.py ├── test_connection.py ├── test_exceptions.py ├── test_metrics.py ├── test_param_url_encoding.py ├── test_process_response.py ├── test_queue.py ├── test_retry_logic.py ├── test_sanitization.py ├── test_spaces.py └── test_streams.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | curl 2 | *.vim 3 | *.swp 4 | 5 | htmlcov 6 | play.py 7 | playground 8 | 9 | *.py[cod] 10 | 11 | # C extensions 12 | *.so 13 | 14 | # Packages 15 | *.egg 16 | *.egg-info 17 | dist 18 | build 19 | eggs 20 | parts 21 | bin 22 | var 23 | sdist 24 | develop-eggs 25 | .installed.cfg 26 | lib 27 | lib64 28 | 29 | # Installer logs 30 | pip-log.txt 31 | 32 | # Unit test / coverage reports 33 | .coverage 34 | .tox 35 | nosetests.xml 36 | 37 | # Translations 38 | *.mo 39 | 40 | # Mr Developer 41 | .mr.developer.cfg 42 | .project 43 | .pydevproject 44 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | dist: trusty 3 | python: 4 | - "2.7" 5 | - "3.3" 6 | - "3.4" 7 | - "3.5" 8 | - "pypy" 9 | - "pypy3" 10 | install: 11 | - "pip install ." 12 | - "pip install six" 13 | - "pip install mock" 14 | - "pip install pep8" 15 | script: 16 | - nosetests tests/ 17 | - pep8 --max-line-length=120 18 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## Changelog 2 | 3 | ### Version 3.1.0 4 | Added the ability to inherit tags 5 | Readme updates for tag usage 6 | Added ability to use envvars when creating a connection 7 | Added generators for pagination 8 | 9 | ### Version 3.0.1 10 | * Submit in tag mode if default tags present in queue 11 | 12 | ### Version 3.0.0 13 | * (!) Deprecated Dashboards and Instruments in favor of Spaces and Charts 14 | * Allow custom user agent 15 | * Minor bug fixes 16 | 17 | ### Version 2.1.2 18 | * Allow hash argument when querying tagged data 19 | 20 | ### Version 2.1.1 21 | * Allow creation of Tagged spaces 22 | 23 | ### Version 2.1.0 24 | * Transparent Multidimensional support. 25 | 26 | ### Version 2.0.1 27 | * Fix Alert issues in #142 28 | 29 | ### Version 2.0.0 30 | * Multi Dimension support 31 | * pep8 compliant 32 | * All that thanks to @vaidy4github great work 33 | 34 | ### Version 1.0.7 35 | * Better response handling (Thanks @jhaegg). 36 | 37 | ### Version 1.0.6 38 | * Better param encoding 39 | 40 | ### Version 1.0.5 41 | * Add new property for streams 42 | 43 | ### Version 1.0.4 44 | * Fix issue loading streams with gap_detection set; fix 403 error parsing 45 | 46 | ### Version 1.0.3 47 | * Adds missing property in streams 48 | 49 | ### Version 1.0.2 50 | * Added support for for Service attributes 51 | 52 | ### Version 1.0.1 53 | * Stream model supports all stream properties 54 | 55 | ### Version 1.0.0 56 | * Spaces API support 57 | 58 | ### Version 0.8.6 59 | * Allow http for local dev (thanks @vaidy4github) 60 | 61 | ### Version 0.8.5 62 | * Same as 0.8.4. Resubmitting to pypi. 63 | 64 | ### Version 0.8.4 65 | * Add timeout support. 66 | * Various Bug fixes. Thanks @marcelocure 67 | 68 | ### Version 0.8.3 69 | * Persisting composite metrics. 70 | 71 | ### Version 0.8.2 72 | * New method to retrieve all metrics with pagination. Thanks @Bachmann1234. 73 | 74 | ### Version 0.8.1 75 | * Return `rearm_seconds` and `active` properties for alerts 76 | 77 | ### Version 0.8.0 78 | * Release support for Alerts 79 | 80 | ### Version 0.7.0 81 | * Release 0.7.0: allow "composite" to be specified when adding a new Stream to an Instrument. Also allow a new Instrument object to be saved directly. 82 | 83 | ### Version 0.6.0 84 | * New release: client-side aggregation support 85 | 86 | ### Version 0.5.1 87 | * Tweak behavior of optional metric name sanitizer; pypy support 88 | 89 | ### Version 0.5.0 90 | * Release 0.5.0 - adds the option to sanitize metric names; other minor changes 91 | 92 | ### Version 0.4.14 93 | * Fix issues in Gauge#add and Counter#add per #69 94 | 95 | ### Version 0.4.13 96 | * Update setup.py to include supported Python versions 97 | 98 | ### Version 0.4.12 99 | * Releasing new version. Auto submit in queue. 100 | 101 | ### Version 0.4.11 102 | * Preliminary support for Annotations (retrieve only). 103 | 104 | ### Version 0.4.10 105 | * Separate deleting a single metric from deleting a batch of metrics. 106 | Thanks @Bachmann1234. 107 | 108 | ### Version 0.4.9 109 | * Adding dashboard and instrument support. Thanks @sargun. 110 | 111 | ### Version 0.4.8 112 | * More explicit exception if user provides non-ascii data for the credentials. 113 | 114 | ### Version 0.4.5 115 | * Same as 0.4.4. Just making sure there are no distribution issues after 116 | changing the Hosting Mode in pypi. 117 | 118 | ### Version 0.4.4 119 | * Consolidates parameter name in queue. Thanks to @stevepeak to point this out. 120 | 121 | ### Version 0.4.3 122 | * Adding support to update metric attributes 123 | 124 | ### Version 0.4.2 125 | * Fixing reading the charset of a response in python2. 126 | 127 | ### Version 0.4.1 128 | * python3 support thanks to @jacobian fantastic work. 129 | 130 | ### Version 0.2.7 131 | * Update User-Agent string to follow standards. 132 | 133 | ### Version 0.2.6 134 | * Refactoring _mexe(). 135 | * Setting User-Agent header. 136 | 137 | ### Version 0.2.5 138 | * Fixing authorship in pypi. 139 | 140 | ### Version 0.2.4 141 | * Fixing packaging issues. 142 | 143 | ### Version 0.2.3 144 | * New library entry points to reflect latest API endpoints. 145 | 146 | ### Version 0.2.2 147 | * Support for sending measurements in batch mode. 148 | 149 | ### Version 0.2.1 150 | * Unit Testing infrastructure. 151 | * Mocking librato API. 152 | * Improve integration tests. 153 | 154 | ### Version 0.2.0 155 | * Initial release. 156 | * Chris Moyer's (AKA @kopertop) code moved from bitbucket to github. 157 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013. Librato, Inc. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | * Redistributions of source code must retain the above copyright 7 | notice, this list of conditions and the following disclaimer. 8 | * Redistributions in binary form must reproduce the above copyright 9 | notice, this list of conditions and the following disclaimer in the 10 | documentation and/or other materials provided with the distribution. 11 | * Neither the name of Librato, Inc. nor the names of project contributors 12 | may be used to endorse or promote products derived from this software 13 | without specific prior written permission. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 16 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 17 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 18 | DISCLAIMED. IN NO EVENT SHALL LIBRATO, INC. BE LIABLE FOR ANY 19 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 20 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 21 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 22 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 23 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 24 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SHELL := /bin/bash 2 | .PHONY: targets utests integration clean coverage publish tox 3 | 4 | targets: 5 | @echo "make utests : Unit testing" 6 | @echo "make integration: Integration tests " 7 | @echo "make coverage : Generate coverage stats" 8 | @echo "make tox : run tox (runs unit tests using different python versions)" 9 | @echo "make publish : publish a new version of the package" 10 | @echo "make clean : Clean garbage" 11 | 12 | utests: 13 | @for f in tests/test*.py; do python $$f; done 14 | 15 | integration: 16 | python tests/integration.py 17 | 18 | coverage: 19 | nosetests --cover-package=librato --cover-erase --cover-html --with-coverage 20 | @echo ">> open "file:///"`pwd`/cover/index.html" 21 | 22 | tox: 23 | tox 24 | 25 | publish: 26 | @sh/publish.sh 27 | 28 | clean: 29 | find . -name "*.pyc" | xargs rm -f 30 | rm -rf tests/__pycache__ librato_metrics.egg-info htmlcov .coverage dist cover 31 | -------------------------------------------------------------------------------- /README: -------------------------------------------------------------------------------- 1 | http://github.com/librato/python-librato 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | python-librato 2 | ============== 3 | 4 | [![Build Status](https://secure.travis-ci.org/librato/python-librato.png?branch=master)](http://travis-ci.org/librato/python-librato) 5 | 6 | A Python wrapper for the Librato Metrics API. 7 | 8 | ## Documentation Notes 9 | 10 | - New accounts 11 | - Refer to [master](https://github.com/librato/python-librato/tree/master) for the latest documentation. 12 | - Legacy (source-based) Librato users 13 | - Please see the [legacy documentation](https://github.com/librato/python-librato/tree/v2.1.2) 14 | 15 | ## Installation 16 | 17 | In your shell: 18 | 19 | ```$ easy_install librato-metrics``` 20 | 21 | or 22 | 23 | ```$ pip install librato-metrics``` 24 | 25 | From your application or script: 26 | 27 | ```import librato``` 28 | 29 | ## Authentication 30 | 31 | Assuming you have 32 | [a Librato account](https://metrics.librato.com/), go to your 33 | [account settings page](https://metrics.librato.com/account) and get your 34 | username (email address) and token (long hexadecimal string). 35 | 36 | ```python 37 | api = librato.connect('email', 'token') 38 | ``` 39 | 40 | ### Metric name sanitization 41 | 42 | When creating your connection you may choose to provide a sanitization function. 43 | This will be applied to any metric name you pass in. For example we provide a 44 | sanitization function that will ensure your metrics are legal librato names. 45 | This can be set as such: 46 | 47 | ```python 48 | api = librato.connect('email', 'token', sanitizer=librato.sanitize_metric_name) 49 | ``` 50 | 51 | By default no sanitization is done. 52 | 53 | ## Basic Usage 54 | 55 | To iterate over your metrics: 56 | 57 | ```python 58 | for m in api.list_metrics(): 59 | print m.name 60 | ``` 61 | 62 | or use `list_all_metrics()` to iterate over all your metrics with 63 | transparent pagination. 64 | 65 | Let's now create a metric: 66 | 67 | ```python 68 | api.submit("temperature", 80, tags={"city": "sf"}) 69 | ``` 70 | 71 | View your metric names: 72 | 73 | ```python 74 | for m in api.list_metrics(): 75 | print(m.name) 76 | ``` 77 | 78 | To retrieve a metric: 79 | 80 | ```python 81 | # Retrieve metric metadata ONLY 82 | gauge = api.get("temperature") 83 | gauge.name # "temperature" 84 | 85 | # Retrieve measurements from last 15 minutes 86 | resp = api.get_measurements("temperature", duration=900, resolution=1) 87 | # {u'name': u'temperature', 88 | # u'links': [], 89 | # u'series': [{u'measurements': [ 90 | # {u'value': 80.0, u'time': 1502917147} 91 | # ], 92 | # u'tags': {u'city': u'sf'}}], 93 | # u'attributes': {u'created_by_ua': u'python-librato/2.0.0...' 94 | # , u'aggregate': False}, u'resolution': 1} 95 | ``` 96 | 97 | To retrieve a composite metric: 98 | 99 | ```python 100 | # Get average temperature across all cities for last 8 hours 101 | compose = 'mean(s("temperature", "*", {function: "mean", period: "3600"}))' 102 | import time 103 | start_time = int(time.time()) - 8 * 3600 104 | 105 | # For tag-based (new) accounts. 106 | # Will be deprecated in favor of `get_composite` in a future tags-only release 107 | resp = api.get_composite_tagged(compose, start_time=start_time) 108 | resp['series'] 109 | # [ 110 | # { 111 | # u'query': {u'metric': u'temperature', u'tags': {}}, 112 | # u'metric': {u'attributes': {u'created_by_ua': u'statsd-librato-backend/0.1.7'}, 113 | # u'type': u'gauge', 114 | # u'name': u'temperature'}, 115 | # u'measurements': [{u'value': 42.0, u'time': 1504719992}], 116 | # u'tags': {u'one': u'1'}}], 117 | # u'compose': u's("foo", "*")', 118 | # u'resolution': 1 119 | # } 120 | # ] 121 | 122 | # For backward compatibility in legacy Librato (source-based) 123 | resp = api.get_composite(compose, start_time=start_time) 124 | ``` 125 | 126 | To create a saved composite metric: 127 | 128 | ```python 129 | api.create_composite('composite.humidity', 'sum(s("humidity", "*"))', 130 | description='a test composite') 131 | ``` 132 | 133 | Delete a metric: 134 | 135 | ```python 136 | api.delete("temperature") 137 | ``` 138 | 139 | ## Sending measurements in batch mode 140 | 141 | Sending a measurement in a single HTTP request is inefficient. The overhead 142 | both at protocol and backend level is very high. That's why we provide an 143 | alternative method to submit your measurements. The idea is to send measurements 144 | in batch mode. We push measurements that are stored and when we are 145 | ready, they will be submitted in an efficient manner. Here is an example: 146 | 147 | ```python 148 | api = librato.connect('email', 'token') 149 | q = api.new_queue() 150 | q.add('temperature', 22.1, tags={'location': 'downstairs'}) 151 | q.add('temperature', 23.1, tags={'location': 'upstairs'}) 152 | q.submit() 153 | ``` 154 | 155 | Queues can also be used as context managers. Once the context block is complete the queue 156 | is submitted automatically. This is true even if an exception interrupts flow. In the 157 | example below if ```potentially_dangerous_operation``` causes an exception the queue will 158 | submit the first measurement as it was the only one successfully added. 159 | If the operation succeeds both measurements will be submitted. 160 | 161 | ```python 162 | with api.new_queue() as q: 163 | q.add('temperature', 22.1, tags={'location': 'downstairs'}) 164 | potentially_dangerous_operation() 165 | q.add('num_requests', 100, tags={'host': 'server1') 166 | ``` 167 | 168 | Queues by default will collect metrics until they are told to submit. You may create a queue 169 | that autosubmits based on metric volume. 170 | 171 | 172 | ```python 173 | # Submit when the 400th metric is queued 174 | q = api.new_queue(auto_submit_count=400) 175 | ``` 176 | 177 | ## Tag Inheritance 178 | 179 | Tags can be inherited from the queue or connection object if `inherit_tags=True` is passed as 180 | an attribute. If inherit_tags is not passed, but tags are added to the measurement, the measurement 181 | tags will be the only tags added to that measurement. 182 | 183 | When there are tag collisions, the measurement, then the batch, then the connection is the order of 184 | priority. 185 | 186 | ```python 187 | api = librato.connect('email', 'token', tags={'company': 'librato', 'service': 'app'}) 188 | 189 | # tags will be {'city': 'sf'} 190 | api.submit('temperature', 80, tags={'city': 'sf'}) 191 | 192 | # tags will be {'city': 'sf', 'company': 'librato', 'service': 'app'} 193 | api.submit('temperature', 80, tags={'city': 'sf'}, inherit_tags=True) 194 | 195 | q = api.new_queue(tags={'service':'api'}) 196 | 197 | # tags will be {'location': 'downstairs'} 198 | q.add('temperature', 22.1, tags={'location': 'downstairs'}) 199 | 200 | # tags will be {'company': 'librato', 'service':'api'} 201 | q.add('temperature', 23.1) 202 | 203 | # tags will be {'location': 'downstairs', 'company': 'librato', 'service': 'api'} 204 | q.add('temperature', 22.1, tags={'location': 'downstairs'}, inherit_tags=True) 205 | q.submit() 206 | ``` 207 | 208 | ## Updating Metric Attributes 209 | 210 | You can update the information for a metric by using the `update` method, 211 | for example: 212 | 213 | ```python 214 | for metric in api.list_metrics(name="abc*"): 215 | attrs = metric.attributes 216 | attrs['display_units_short'] = 'ms' 217 | api.update(metric.name, attributes=attrs) 218 | ``` 219 | 220 | ## Annotations 221 | 222 | List Annotation all annotation streams: 223 | 224 | ```python 225 | for stream in api.list_annotation_streams(): 226 | print("%s: %s" % (stream.name, stream.display_name)) 227 | ``` 228 | 229 | View the metadata on a named annotation stream: 230 | 231 | ```python 232 | stream = api.get_annotation_stream("api.pushes") 233 | print stream 234 | ``` 235 | 236 | Retrieve all of the events inside a named annotation stream, by adding a 237 | start_time parameter to the get_annotation_stream() call: 238 | 239 | ```python 240 | stream=api.get_annotation_stream("api.pushes",start_time="1386050400") 241 | for source in stream.events: 242 | print source 243 | events=stream.events[source] 244 | for event in events: 245 | print event['id'] 246 | print event['title'] 247 | print event['description'] 248 | ``` 249 | 250 | Submit a new annotation to a named annotation stream (creates the stream if it 251 | doesn't exist). Title is a required parameter, and all other parameters are optional 252 | 253 | ```python 254 | api.post_annotation("testing",title="foobarbiz") 255 | 256 | api.post_annotation("TravisCI",title="build %s"%travisBuildID, 257 | source="SystemSource", 258 | description="Application %s, Travis build %s"%(appName,travisBuildID), 259 | links=[{'rel': 'travis', 'href': 'http://travisci.com/somebuild'}]) 260 | ``` 261 | 262 | Delete a named annotation stream: 263 | 264 | ```python 265 | api.delete_annotation_stream("testing") 266 | ``` 267 | 268 | ## Spaces API 269 | ### List Spaces 270 | ```python 271 | # List spaces 272 | spaces = api.list_spaces() 273 | ``` 274 | 275 | ### Create a Space 276 | ```python 277 | # Create a new Space directly via API 278 | space = api.create_space("space_name") 279 | print("Created '%s'" % space.name) 280 | 281 | # Create a new Space via the model, passing the connection 282 | space = Space(api, 'Production') 283 | space.save() 284 | ``` 285 | 286 | ### Find a Space 287 | ```python 288 | space = api.find_space('Production') 289 | ``` 290 | 291 | ### Delete a Space 292 | ```python 293 | space = api.create_space('Test') 294 | api.delete_space(space.id) 295 | # or 296 | space.delete() 297 | ``` 298 | 299 | ### Create a Chart 300 | 301 | ```python 302 | # Create a line chart with various metric streams including their tags(s) and group/summary functions: 303 | space = api.get_space(123) 304 | linechart = api.create_chart( 305 | 'cities MD line chart', 306 | space, 307 | streams=[ 308 | { 309 | "metric": "librato.cpu.percent.idle", 310 | "tags": [{"name": "environment", "values": ["*"]] 311 | }, 312 | { 313 | "metric": "librato.cpu.percent.user", 314 | "tags": [{"name": "environment", 'dynamic': True}] 315 | } 316 | ] 317 | ) 318 | ``` 319 | 320 | ### Find a Chart 321 | ```python 322 | # Takes either space_id or a space object 323 | chart = api.get_chart(chart_id, space_id) 324 | chart = api.get_chart(chart_id, space) 325 | ``` 326 | 327 | ### Update a Chart 328 | 329 | ```python 330 | space = api.get_space(123) 331 | charts = space.chart_ids 332 | chart = api.get_chart(charts[0], space.id) 333 | chart.name = 'Your chart name' 334 | chart.save() 335 | ``` 336 | 337 | ### Rename a Chart 338 | ```python 339 | chart = api.get_chart(chart_id, space_id) 340 | # save() gets called automatically here 341 | chart.rename('new chart name') 342 | ``` 343 | 344 | ### Delete a Chart 345 | ```python 346 | chart = api.get_chart(chart_id, space_id) 347 | chart.delete() 348 | ``` 349 | 350 | ## Alerts 351 | 352 | List all alerts: 353 | 354 | ```python 355 | for alert in api.list_alerts(): 356 | print(alert.name) 357 | ``` 358 | 359 | Create an alert with an _above_ condition: 360 | ```python 361 | alert = api.create_alert('my.alert') 362 | alert.add_condition_for('metric_name').above(1) # trigger immediately 363 | alert.add_condition_for('metric_name').above(1).duration(60) # trigger after a set duration 364 | alert.add_condition_for('metric_name').above(1, 'sum') # custom summary function 365 | alert.save() 366 | ``` 367 | 368 | Create an alert with a _below_ condition: 369 | ```python 370 | alert = api.create_alert('my.alert', description='An alert description') 371 | alert.add_condition_for('metric_name').below(1) # the same syntax as above conditions 372 | alert.save() 373 | ``` 374 | 375 | Create an alert with an _absent_ condition: 376 | ```python 377 | alert = api.create_alert('my.alert') 378 | alert.add_condition_for('metric_name').stops_reporting_for(5) # duration in minutes of the threshold to trigger the alert 379 | alert.save() 380 | ``` 381 | 382 | View all outbound services for the current user 383 | ```python 384 | for service in api.list_services(): 385 | print(service._id, service.title, service.settings) 386 | ``` 387 | 388 | Create an alert with Service IDs 389 | ```python 390 | alert = api.create_alert('my.alert', services=[1234, 5678]) 391 | ``` 392 | 393 | Create an alert with Service objects 394 | ```python 395 | s = api.list_services() 396 | alert = api.create_alert('my.alert', services=[s[0], s[1]]) 397 | ``` 398 | 399 | Add an outbound service to an alert: 400 | ```python 401 | alert = api.create_alert('my.alert') 402 | alert.add_service(1234) 403 | alert.save() 404 | ``` 405 | 406 | Put it all together: 407 | ```python 408 | cond = {'metric_name': 'cpu', 'type': 'above', 'threshold': 42} 409 | s = api.list_services() 410 | api.create_alert('my.alert', conditions=[cond], services=[s[0], s[1]]) 411 | # We have an issue at the API where conditions and services are not returned 412 | # when creating. So, retrieve back from API 413 | alert = api.get_alert('my.alert') 414 | print(alert.conditions) 415 | print(alert.services) 416 | ``` 417 | 418 | ## Misc 419 | 420 | ### Timeouts 421 | 422 | Timeouts are provided by the underlying http client. By default we timeout at 10 seconds. You can change 423 | that by using `api.set_timeout(timeout)`. 424 | 425 | ## Contribution 426 | 427 | Want to contribute? Need a new feature? Please open an 428 | [issue](https://github.com/librato/python-librato/issues). 429 | 430 | ## Contributors 431 | 432 | The original version of `python-librato` was conceived/authored/released by Chris Moyer (AKA [@kopertop](https://github.com/kopertop)). He's 433 | graciously handed over maintainership of the project to us and we're super-appreciative of his efforts. 434 | 435 | ## Copyright 436 | 437 | Copyright (c) 2011-2017 [Librato Inc.](http://librato.com) See LICENSE for details. 438 | -------------------------------------------------------------------------------- /development.md: -------------------------------------------------------------------------------- 1 | Development 2 | ============== 3 | 4 | ## Steps to run tests 5 | 6 | ### Install pyenv 7 | 8 | ``` 9 | brew update 10 | brew install pyenv 11 | ``` 12 | 13 | ### Add required versions 14 | 15 | ``` 16 | pyenv install 3.3.6 17 | pyenv install 3.4.5 18 | pyenv install pypy-5.3.1 19 | ``` 20 | 21 | ### Add the following lines to bashrc 22 | 23 | ``` 24 | export PYENV_ROOT="$HOME/.pyenv" 25 | export PATH="$PYENV_ROOT/bin:$PATH" 26 | eval "$(pyenv init -)" 27 | ``` 28 | 29 | ### Add the versions globally 30 | 31 | ``` 32 | pyenv global system 3.4.5 33 | pyenv global system 3.3.6 34 | pyenv global system pypy-5.3.1 35 | ``` 36 | -------------------------------------------------------------------------------- /librato/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2013. Librato, Inc. 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or without 5 | # modification, are permitted provided that the following conditions are met: 6 | # * Redistributions of source code must retain the above copyright 7 | # notice, this list of conditions and the following disclaimer in the 8 | # documentation and/or other materials provided with the distribution. 9 | # * Neither the name of Librato, Inc. nor the names of project contributors 10 | # may be used to endorse or promote products derived from this software 11 | # without specific prior written permission. 12 | # 13 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 14 | # ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 15 | # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 16 | # DISCLAIMED. IN NO EVENT SHALL LIBRATO, INC. BE LIABLE FOR ANY 17 | # DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 18 | # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 19 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 20 | # ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 21 | # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 22 | # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 23 | 24 | import re 25 | import six 26 | import platform 27 | import time 28 | import logging 29 | import os 30 | from six.moves import http_client 31 | from six.moves import map 32 | from six import string_types 33 | import urllib 34 | import base64 35 | import json 36 | import email.message 37 | from librato import exceptions 38 | from librato.queue import Queue 39 | from librato.metrics import Gauge, Counter, Metric 40 | from librato.alerts import Alert, Service 41 | from librato.annotations import Annotation 42 | from librato.spaces import Space, Chart 43 | 44 | __version__ = "3.1.0" 45 | 46 | # Defaults 47 | HOSTNAME = "metrics-api.librato.com" 48 | BASE_PATH = "/v1/" 49 | DEFAULT_TIMEOUT = 10 50 | 51 | log = logging.getLogger("librato") 52 | 53 | # Alias HTTPSConnection so the tests can mock it out. 54 | HTTPSConnection = http_client.HTTPSConnection 55 | HTTPConnection = http_client.HTTPConnection 56 | 57 | # Alias urlencode, it moved between py2 and py3. 58 | try: 59 | urlencode = urllib.parse.urlencode # py3 60 | except AttributeError: 61 | urlencode = urllib.urlencode # py2 62 | 63 | 64 | def sanitize_metric_name(metric_name): 65 | disallowed_character_pattern = r"(([^A-Za-z0-9.:\-_]|[\[\]]|\s)+)" 66 | max_metric_name_length = 255 67 | return re.sub(disallowed_character_pattern, '-', metric_name)[:max_metric_name_length] 68 | 69 | 70 | def sanitize_no_op(metric_name): 71 | """ 72 | Default behavior, some people want the error 73 | """ 74 | return metric_name 75 | 76 | 77 | class LibratoConnection(object): 78 | """Librato API Connection. 79 | Usage: 80 | >>> conn = LibratoConnection(username, api_key) 81 | >>> conn.list_metrics() 82 | [...] 83 | """ 84 | 85 | def __init__(self, username, api_key, hostname=HOSTNAME, base_path=BASE_PATH, sanitizer=sanitize_no_op, 86 | protocol="https", tags={}): 87 | """Create a new connection to Librato Metrics. 88 | Doesn't actually connect yet or validate until you make a request. 89 | 90 | :param username: The username (email address) of the user to connect as 91 | :type username: str 92 | :param api_key: The API Key (token) to use to authenticate 93 | :type api_key: str 94 | """ 95 | try: 96 | self.username = username.encode('ascii') 97 | self.api_key = api_key.encode('ascii') 98 | except: 99 | raise TypeError("Librato only supports ascii for the credentials") 100 | 101 | if protocol not in ["http", "https"]: 102 | raise ValueError("Unsupported protocol: {}".format(protocol)) 103 | 104 | self.custom_ua = None 105 | self.protocol = protocol 106 | self.hostname = hostname 107 | self.base_path = base_path 108 | # these two attributes ared used to control fake server errors when doing 109 | # unit testing. 110 | self.fake_n_errors = 0 111 | self.backoff_logic = lambda backoff: backoff * 2 112 | self.sanitize = sanitizer 113 | self.timeout = DEFAULT_TIMEOUT 114 | self.tags = dict(tags) 115 | 116 | def _compute_ua(self): 117 | if self.custom_ua: 118 | return self.custom_ua 119 | else: 120 | # http://en.wikipedia.org/wiki/User_agent#Format 121 | # librato-metrics/1.0.3 (ruby; 1.9.3p385; x86_64-darwin11.4.2) direct-faraday/0.8.4 122 | ua_chunks = [] # Set user agent 123 | ua_chunks.append("python-librato/" + __version__) 124 | p = platform 125 | system_info = (p.python_version(), p.machine(), p.system(), p.release()) 126 | ua_chunks.append("(python; %s; %s-%s%s)" % system_info) 127 | return ' '.join(ua_chunks) 128 | 129 | def __getattr__(self, attr): 130 | def handle_undefined_method(*args): 131 | if re.search('dashboard|instrument', attr): 132 | print("We have deprecated support for instruments and dashboards.") 133 | print("https://github.com/librato/python-librato") 134 | print("") 135 | raise NotImplementedError() 136 | return handle_undefined_method 137 | 138 | def _set_headers(self, headers): 139 | """ set headers for request """ 140 | if headers is None: 141 | headers = {} 142 | headers['Authorization'] = b"Basic " + base64.b64encode(self.username + b":" + self.api_key).strip() 143 | headers['User-Agent'] = self._compute_ua() 144 | return headers 145 | 146 | def _url_encode_params(self, params={}): 147 | if not isinstance(params, dict): 148 | raise Exception("You must pass in a dictionary!") 149 | params_list = [] 150 | for k, v in params.items(): 151 | if isinstance(v, list): 152 | params_list.extend([(k + '[]', x) for x in v]) 153 | else: 154 | params_list.append((k, v)) 155 | return urlencode(params_list) 156 | 157 | def _make_request(self, conn, path, headers, query_props, method): 158 | """ Perform the an https request to the server """ 159 | uri = self.base_path + path 160 | body = None 161 | if query_props: 162 | if method == "POST" or method == "DELETE" or method == "PUT": 163 | body = json.dumps(query_props) 164 | headers['Content-Type'] = "application/json" 165 | else: 166 | uri += "?" + self._url_encode_params(query_props) 167 | 168 | log.info("method=%s uri=%s" % (method, uri)) 169 | log.info("body(->): %s" % body) 170 | conn.request(method, uri, body=body, headers=headers) 171 | 172 | return conn.getresponse() 173 | 174 | def _process_response(self, resp, backoff): 175 | """ Process the response from the server """ 176 | success = True 177 | resp_data = None 178 | not_a_server_error = resp.status < 500 179 | 180 | if not_a_server_error: 181 | resp_data = _decode_body(resp) 182 | a_client_error = resp.status >= 400 183 | if a_client_error: 184 | raise exceptions.get(resp.status, resp_data) 185 | return resp_data, success, backoff 186 | else: # A server error, wait and retry 187 | backoff = self.backoff_logic(backoff) 188 | log.info("%s: waiting %s before re-trying" % (resp.status, backoff)) 189 | time.sleep(backoff) 190 | return None, not success, backoff 191 | 192 | def _parse_tags_params(self, tags): 193 | result = {} 194 | for k, v in tags.items(): 195 | result["tags[%s]" % k] = v 196 | return result 197 | 198 | def _mexe(self, path, method="GET", query_props=None, p_headers=None): 199 | """Internal method for executing a command. 200 | If we get server errors we exponentially wait before retrying 201 | """ 202 | conn = self._setup_connection() 203 | headers = self._set_headers(p_headers) 204 | success = False 205 | backoff = 1 206 | resp_data = None 207 | while not success: 208 | resp = self._make_request(conn, path, headers, query_props, method) 209 | try: 210 | resp_data, success, backoff = self._process_response(resp, backoff) 211 | except http_client.ResponseNotReady: 212 | conn.close() 213 | conn = self._setup_connection() 214 | conn.close() 215 | return resp_data 216 | 217 | def _do_we_want_to_fake_server_errors(self): 218 | return self.fake_n_errors > 0 219 | 220 | def _setup_connection(self): 221 | connection_class = HTTPSConnection if self.protocol == "https" else HTTPConnection 222 | 223 | if self._do_we_want_to_fake_server_errors(): 224 | return connection_class(self.hostname, fake_n_errors=self.fake_n_errors) 225 | else: 226 | return connection_class(self.hostname, timeout=self.timeout) 227 | 228 | def _parse(self, resp, name, cls): 229 | """Parse to an object""" 230 | if name in resp: 231 | return [cls.from_dict(self, m) for m in resp[name]] 232 | else: 233 | return resp 234 | 235 | # Get a shallow copy of the top-level tag set 236 | def get_tags(self): 237 | return dict(self.tags) 238 | 239 | # Define the top-level tag set for posting measurements 240 | def set_tags(self, d): 241 | self.tags = dict(d) # Create a copy 242 | 243 | # Add to the top-level tag set 244 | def add_tags(self, d): 245 | self.tags.update(d) 246 | 247 | # Return all items for a "list" request 248 | def _get_paginated_results(self, entity, klass, **query_props): 249 | resp = self._mexe(entity, query_props=query_props) 250 | 251 | results = self._parse(resp, entity, klass) 252 | for result in results: 253 | yield result 254 | 255 | length = resp.get('query', {}).get('length', 0) 256 | offset = query_props.get('offset', 0) + length 257 | total = resp.get('query', {}).get('total', length) 258 | if offset < total and length > 0: 259 | query_props.update({'offset': offset}) 260 | for result in self._get_paginated_results(entity, klass, **query_props): 261 | yield result 262 | 263 | # 264 | # Metrics 265 | # 266 | def list_metrics(self, **query_props): 267 | """List a page of metrics""" 268 | resp = self._mexe("metrics", query_props=query_props) 269 | return self._parse(resp, "metrics", Metric) 270 | 271 | def list_all_metrics(self, **query_props): 272 | return self._get_paginated_results("metrics", Metric, **query_props) 273 | 274 | def submit(self, name, value, type="gauge", **query_props): 275 | if 'tags' in query_props or self.get_tags(): 276 | self.submit_tagged(name, value, **query_props) 277 | else: 278 | payload = {'gauges': [], 'counters': []} 279 | metric = {'name': self.sanitize(name), 'value': value} 280 | for k, v in query_props.items(): 281 | metric[k] = v 282 | payload[type + 's'].append(metric) 283 | self._mexe("metrics", method="POST", query_props=payload) 284 | 285 | def submit_tagged(self, name, value, **query_props): 286 | payload = {'measurements': []} 287 | payload['measurements'].append(self.create_tagged_payload(name, value, **query_props)) 288 | self._mexe("measurements", method="POST", query_props=payload) 289 | 290 | def create_tagged_payload(self, name, value, **query_props): 291 | """Create the measurement for forwarding to Librato""" 292 | measurement = { 293 | 'name': self.sanitize(name), 294 | 'value': value 295 | } 296 | if 'tags' in query_props: 297 | inherit_tags = query_props.pop('inherit_tags', False) 298 | if inherit_tags: 299 | tags = query_props.pop('tags', {}) 300 | measurement['tags'] = dict(self.get_tags(), **tags) 301 | elif self.tags: 302 | measurement['tags'] = self.tags 303 | 304 | for k, v in query_props.items(): 305 | measurement[k] = v 306 | return measurement 307 | 308 | def get(self, name, **query_props): 309 | resp = self._mexe("metrics/%s" % self.sanitize(name), method="GET", query_props=query_props) 310 | if resp['type'] == 'gauge': 311 | return Gauge.from_dict(self, resp) 312 | elif resp['type'] == 'counter': 313 | return Counter.from_dict(self, resp) 314 | else: 315 | raise Exception('The server sent me something that is not a Gauge nor a Counter.') 316 | 317 | def get_tagged(self, name, **query_props): 318 | """Fetches multi-dimensional metrics""" 319 | if 'resolution' not in query_props: 320 | # Default to raw resolution 321 | query_props['resolution'] = 1 322 | if 'start_time' not in query_props and 'duration' not in query_props: 323 | raise Exception("You must provide 'start_time' or 'duration'") 324 | if 'start_time' in query_props and 'end_time' in query_props and 'duration' in query_props: 325 | raise Exception("It is an error to set 'start_time', 'end_time' and 'duration'") 326 | 327 | if 'tags' in query_props: 328 | parsed_tags = self._parse_tags_params(query_props.pop('tags')) 329 | query_props.update(parsed_tags) 330 | 331 | return self._mexe("measurements/%s" % self.sanitize(name), method="GET", query_props=query_props) 332 | 333 | def get_measurements(self, name, **query_props): 334 | return self.get_tagged(name, **query_props) 335 | 336 | def get_composite(self, compose, **query_props): 337 | if self.get_tags(): 338 | return self.get_composite_tagged(compose, **query_props) 339 | else: 340 | if 'resolution' not in query_props: 341 | # Default to raw resolution 342 | query_props['resolution'] = 1 343 | if 'start_time' not in query_props: 344 | raise Exception("You must provide a 'start_time'") 345 | query_props['compose'] = compose 346 | return self._mexe('metrics', method="GET", query_props=query_props) 347 | 348 | def get_composite_tagged(self, compose, **query_props): 349 | if 'resolution' not in query_props: 350 | # Default to raw resolution 351 | query_props['resolution'] = 1 352 | if 'start_time' not in query_props: 353 | raise Exception("You must provide a 'start_time'") 354 | query_props['compose'] = compose 355 | return self._mexe('measurements', method="GET", query_props=query_props) 356 | 357 | def create_composite(self, name, compose, **query_props): 358 | query_props['composite'] = compose 359 | query_props['type'] = 'composite' 360 | return self.update(name, **query_props) 361 | 362 | def update(self, name, **query_props): 363 | return self._mexe("metrics/%s" % self.sanitize(name), method="PUT", query_props=query_props) 364 | 365 | def delete(self, names): 366 | if isinstance(names, six.string_types): 367 | names = self.sanitize(names) 368 | else: 369 | names = list(map(self.sanitize, names)) 370 | path = "metrics/%s" % names 371 | payload = {} 372 | if not isinstance(names, string_types): 373 | payload = {'names': names} 374 | path = "metrics" 375 | return self._mexe(path, method="DELETE", query_props=payload) 376 | 377 | # 378 | # Annotations 379 | # 380 | def list_annotation_streams(self, **query_props): 381 | """List all annotation streams""" 382 | return self._get_paginated_results('annotations', Annotation, **query_props) 383 | 384 | def get_annotation_stream(self, name, **query_props): 385 | """Get an annotation stream (add start_date to query props for events)""" 386 | resp = self._mexe("annotations/%s" % name, method="GET", query_props=query_props) 387 | return Annotation.from_dict(self, resp) 388 | 389 | def get_annotation(self, name, id, **query_props): 390 | """Get a specific annotation event by ID""" 391 | resp = self._mexe("annotations/%s/%s" % (name, id), method="GET", query_props=query_props) 392 | return Annotation.from_dict(self, resp) 393 | 394 | def update_annotation_stream(self, name, **query_props): 395 | """Update an annotation streams metadata""" 396 | payload = Annotation(self, name).get_payload() 397 | for k, v in query_props.items(): 398 | payload[k] = v 399 | resp = self._mexe("annotations/%s" % name, method="PUT", query_props=payload) 400 | return Annotation.from_dict(self, resp) 401 | 402 | def post_annotation(self, name, **query_props): 403 | """ Create an annotation event on :name. """ 404 | """ If the annotation stream does not exist, it will be created automatically. """ 405 | resp = self._mexe("annotations/%s" % name, method="POST", query_props=query_props) 406 | return resp 407 | 408 | def delete_annotation_stream(self, name, **query_props): 409 | """delete an annotation stream """ 410 | resp = self._mexe("annotations/%s" % name, method="DELETE", query_props=query_props) 411 | return resp 412 | 413 | # 414 | # Alerts 415 | # 416 | def create_alert(self, name, **query_props): 417 | """Create a new alert""" 418 | payload = Alert(self, name, **query_props).get_payload() 419 | resp = self._mexe("alerts", method="POST", query_props=payload) 420 | return Alert.from_dict(self, resp) 421 | 422 | def update_alert(self, alert, **query_props): 423 | """Update an existing alert""" 424 | payload = alert.get_payload() 425 | for k, v in query_props.items(): 426 | payload[k] = v 427 | resp = self._mexe("alerts/%s" % alert._id, 428 | method="PUT", query_props=payload) 429 | return resp 430 | 431 | # Delete an alert by name (not by id) 432 | def delete_alert(self, name): 433 | """delete an alert""" 434 | alert = self.get_alert(name) 435 | if alert is None: 436 | return None 437 | resp = self._mexe("alerts/%s" % alert._id, method="DELETE") 438 | return resp 439 | 440 | def get_alert(self, name): 441 | """Get specific alert""" 442 | resp = self._mexe("alerts", query_props={'name': name}) 443 | alerts = self._parse(resp, "alerts", Alert) 444 | if len(alerts) > 0: 445 | return alerts[0] 446 | return None 447 | 448 | def list_alerts(self, active_only=True, **query_props): 449 | """List all alerts (default to active only)""" 450 | return self._get_paginated_results("alerts", Alert, **query_props) 451 | 452 | def list_services(self, **query_props): 453 | # Note: This API currently does not have the ability to 454 | # filter by title, type, etc 455 | return self._get_paginated_results("services", Service, **query_props) 456 | 457 | # 458 | # Spaces 459 | # 460 | def list_spaces(self, **query_props): 461 | """List all spaces""" 462 | return self._get_paginated_results("spaces", Space, **query_props) 463 | 464 | def get_space(self, id, **query_props): 465 | """Get specific space by ID""" 466 | resp = self._mexe("spaces/%s" % id, 467 | method="GET", query_props=query_props) 468 | return Space.from_dict(self, resp) 469 | 470 | def find_space(self, name): 471 | if type(name) is int: 472 | raise ValueError("This method expects name as a parameter, %s given" % name) 473 | """Find specific space by Name""" 474 | spaces = self.list_spaces(name=name) 475 | # Find the Space by name (case-insensitive) 476 | # This returns the first space found matching the name 477 | for space in spaces: 478 | if space.name and space.name.lower() == name.lower(): 479 | # Now use the ID to hydrate the space attributes (charts) 480 | return self.get_space(space.id) 481 | 482 | return None 483 | 484 | def update_space(self, space, **query_props): 485 | """Update an existing space (API currently only allows update of name""" 486 | payload = space.get_payload() 487 | for k, v in query_props.items(): 488 | payload[k] = v 489 | resp = self._mexe("spaces/%s" % space.id, 490 | method="PUT", query_props=payload) 491 | return resp 492 | 493 | def create_space(self, name, **query_props): 494 | payload = Space(self, name).get_payload() 495 | for k, v in query_props.items(): 496 | payload[k] = v 497 | resp = self._mexe("spaces", method="POST", query_props=payload) 498 | return Space.from_dict(self, resp) 499 | 500 | def delete_space(self, id): 501 | """delete a space""" 502 | resp = self._mexe("spaces/%s" % id, method="DELETE") 503 | return resp 504 | 505 | # 506 | # Charts 507 | # 508 | def list_charts_in_space(self, space, **query_props): 509 | """List all charts from space""" 510 | resp = self._mexe("spaces/%s/charts" % space.id, query_props=query_props) 511 | # "charts" is not in the response, but make this 512 | # actually return Chart objects 513 | charts = self._parse({"charts": resp}, "charts", Chart) 514 | # Populate space ID 515 | for chart in charts: 516 | chart.space_id = space.id 517 | return charts 518 | 519 | def get_chart(self, chart_id, space_or_space_id, **query_props): 520 | """Get specific chart by ID from Space""" 521 | space_id = None 522 | if type(space_or_space_id) is int: 523 | space_id = space_or_space_id 524 | elif type(space_or_space_id) is Space: 525 | space_id = space_or_space_id.id 526 | else: 527 | raise ValueError("Space parameter is invalid") 528 | # TODO: Add better handling around 404s 529 | resp = self._mexe("spaces/%s/charts/%s" % (space_id, chart_id), method="GET", query_props=query_props) 530 | resp['space_id'] = space_id 531 | return Chart.from_dict(self, resp) 532 | 533 | # Find a chart by name in a space. Return the first match, so if multiple 534 | # charts have the same name, you'll only get the first one 535 | def find_chart(self, name, space): 536 | charts = self.list_charts_in_space(space) 537 | for chart in charts: 538 | if chart.name and chart.name.lower() == name.lower(): 539 | # Now use the ID to hydrate the chart attributes (streams) 540 | return self.get_chart(chart.id, space) 541 | return None 542 | 543 | def create_chart(self, name, space, **query_props): 544 | """Create a new chart in space""" 545 | payload = Chart(self, name).get_payload() 546 | for k, v in query_props.items(): 547 | payload[k] = v 548 | resp = self._mexe("spaces/%s/charts" % space.id, method="POST", query_props=payload) 549 | resp['space_id'] = space.id 550 | return Chart.from_dict(self, resp) 551 | 552 | def update_chart(self, chart, space, **query_props): 553 | """Update an existing chart""" 554 | payload = chart.get_payload() 555 | for k, v in query_props.items(): 556 | payload[k] = v 557 | resp = self._mexe("spaces/%s/charts/%s" % (space.id, chart.id), 558 | method="PUT", 559 | query_props=payload) 560 | return resp 561 | 562 | def delete_chart(self, chart_id, space_id, **query_props): 563 | """delete a chart from a space""" 564 | resp = self._mexe("spaces/%s/charts/%s" % (space_id, chart_id), method="DELETE") 565 | return resp 566 | 567 | # 568 | # Queue 569 | # 570 | def new_queue(self, **kwargs): 571 | return Queue(self, **kwargs) 572 | 573 | # 574 | # misc 575 | # 576 | def set_timeout(self, timeout): 577 | self.timeout = timeout 578 | 579 | 580 | def connect(username=None, api_key=None, hostname=HOSTNAME, base_path=BASE_PATH, sanitizer=sanitize_no_op, 581 | protocol="https", tags={}): 582 | """ 583 | Connect to Librato Metrics 584 | """ 585 | 586 | username = username if username else os.getenv('LIBRATO_USER', '') 587 | api_key = api_key if api_key else os.getenv('LIBRATO_TOKEN', '') 588 | 589 | return LibratoConnection(username, api_key, hostname, base_path, sanitizer=sanitizer, protocol=protocol, tags=tags) 590 | 591 | 592 | def _decode_body(resp): 593 | """ 594 | Read and decode HTTPResponse body based on charset and content-type 595 | """ 596 | body = resp.read() 597 | log.info("body(<-): %s" % body) 598 | if not body: 599 | return None 600 | 601 | decoded_body = body.decode(_getcharset(resp)) 602 | content_type = _get_content_type(resp) 603 | 604 | if content_type == "application/json": 605 | resp_data = json.loads(decoded_body) 606 | else: 607 | resp_data = decoded_body 608 | 609 | return resp_data 610 | 611 | 612 | def _getcharset(resp, default='utf-8'): 613 | """ 614 | Extract the charset from an HTTPResponse. 615 | """ 616 | # In Python 3, HTTPResponse is a subclass of email.message.Message, so we 617 | # can use get_content_chrset. In Python 2, however, it's not so we have 618 | # to be "clever". 619 | if hasattr(resp, 'headers'): 620 | return resp.headers.get_content_charset(default) 621 | else: 622 | m = email.message.Message() 623 | m['content-type'] = resp.getheader('content-type') 624 | return m.get_content_charset(default) 625 | 626 | 627 | def _get_content_type(resp): 628 | """ 629 | Get Content-Type header ignoring parameters 630 | """ 631 | parts = resp.getheader('content-type', "application/json").split(";") 632 | return parts[0] 633 | -------------------------------------------------------------------------------- /librato/aggregator.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2013. Librato, Inc. 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or without 5 | # modification, are permitted provided that the following conditions are met: 6 | # * Redistributions of source code must retain the above copyright 7 | # notice, this list of conditions and the following disclaimer. 8 | # * Redistributions in binary form must reproduce the above copyright 9 | # notice, this list of conditions and the following disclaimer in the 10 | # documentation and/or other materials provided with the distribution. 11 | # * Neither the name of Librato, Inc. nor the names of project contributors 12 | # may be used to endorse or promote products derived from this software 13 | # without specific prior written permission. 14 | # 15 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 16 | # ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 17 | # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 18 | # DISCLAIMED. IN NO EVENT SHALL LIBRATO, INC. BE LIABLE FOR ANY 19 | # DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 20 | # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 21 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 22 | # ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 23 | # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 24 | # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25 | 26 | import time 27 | 28 | 29 | class Aggregator(object): 30 | """ Implements client-side *gauge* aggregation to reduce the number of measurements 31 | submitted. 32 | Specify a period (default: None) and the aggregator will automatically 33 | floor the measure_times to that interval. 34 | """ 35 | 36 | def __init__(self, connection, **args): 37 | self.connection = connection 38 | # Global source for all 'legacy' metrics sent into the aggregator 39 | self.source = args.get('source') 40 | # Global tags, which apply to MD metrics only 41 | self.tags = dict(args.get('tags', {})) 42 | self.measurements = {} 43 | self.tagged_measurements = {} 44 | self.period = args.get('period') 45 | self.measure_time = args.get('measure_time') 46 | 47 | # Get a shallow copy of the top-level tag set 48 | def get_tags(self): 49 | return dict(self.tags) 50 | 51 | # Define the top-level tag set for posting measurements 52 | def set_tags(self, d): 53 | self.tags = dict(d) # Create a copy 54 | 55 | # Add one or more top-level tags for posting measurements 56 | def add_tags(self, d): 57 | self.tags.update(d) 58 | 59 | def add(self, name, value): 60 | if name not in self.measurements: 61 | self.measurements[name] = { 62 | 'count': 1, 63 | 'sum': value, 64 | 'min': value, 65 | 'max': value 66 | } 67 | else: 68 | m = self.measurements[name] 69 | m['sum'] += value 70 | m['count'] += 1 71 | if value < m['min']: 72 | m['min'] = value 73 | if value > m['max']: 74 | m['max'] = value 75 | 76 | return self.measurements 77 | 78 | def add_tagged(self, name, value): 79 | if name not in self.tagged_measurements: 80 | self.tagged_measurements[name] = { 81 | 'count': 1, 82 | 'sum': value, 83 | 'min': value, 84 | 'max': value 85 | } 86 | else: 87 | m = self.tagged_measurements[name] 88 | m['sum'] += value 89 | m['count'] += 1 90 | if value < m['min']: 91 | m['min'] = value 92 | if value > m['max']: 93 | m['max'] = value 94 | 95 | return self.tagged_measurements 96 | 97 | def to_payload(self): 98 | # Map measurements into Librato POST (array) format 99 | # { 100 | # 'gauges': [ 101 | # {'count': 1, 'max': 42, 'sum': 42, 'name': 'foo', 'min': 42} 102 | # ] 103 | # 'measure_time': 1418838418 (optional) 104 | # 'source': 'mysource' (optional) 105 | # } 106 | # Note: hash format would work too, but the mocks aren't currently set up 107 | # for the hash format :-( 108 | # i.e. result = {'gauges': dict(self.measurements)} 109 | 110 | body = [] 111 | for metric_name in self.measurements: 112 | # Create a clone so we don't change self.measurements 113 | vals = dict(self.measurements[metric_name]) 114 | vals["name"] = metric_name 115 | body.append(vals) 116 | 117 | result = {'gauges': body} 118 | if self.source: 119 | result['source'] = self.source 120 | 121 | mt = self.floor_measure_time() 122 | if mt: 123 | result['measure_time'] = mt 124 | 125 | return result 126 | 127 | def to_md_payload(self): 128 | # Map measurements into Librato MD POST format 129 | # { 130 | # 'measures': [ 131 | # {'count': 1, 'max': 42, 'sum': 42, 'name': 'foo', 'min': 42} 132 | # ] 133 | # 'time': 1418838418 (optional) 134 | # 'tags': {'hostname': 'myhostname'} (optional) 135 | # } 136 | 137 | body = [] 138 | for metric_name in self.tagged_measurements: 139 | # Create a clone so we don't change self.tagged_measurements 140 | vals = dict(self.tagged_measurements[metric_name]) 141 | vals["name"] = metric_name 142 | body.append(vals) 143 | 144 | result = {'measurements': body} 145 | if self.tags: 146 | result['tags'] = self.tags 147 | 148 | mt = self.floor_measure_time() 149 | if mt: 150 | result['time'] = mt 151 | 152 | return result 153 | 154 | # Get/set the measure time if it is ever queried, that way you'll know the measure_time 155 | # that was submitted, and we'll guarantee the same measure_time for all measurements 156 | # extracted into a queue 157 | def get_measure_time(self): 158 | mt = self.floor_measure_time() 159 | if mt: 160 | self.measure_time = mt 161 | return self.measure_time 162 | 163 | # Return floored measure time if period is set 164 | # otherwise return user specified value if set 165 | # otherwise return none 166 | def floor_measure_time(self): 167 | if self.period: 168 | mt = None 169 | if self.measure_time: 170 | # Use user-specified time 171 | mt = self.measure_time 172 | else: 173 | # Grab wall time 174 | mt = int(time.time()) 175 | return mt - (mt % self.period) 176 | elif self.measure_time: 177 | # Use the user-specified value with no flooring 178 | return self.measure_time 179 | 180 | def clear(self): 181 | self.measurements = {} 182 | self.tagged_measurements = {} 183 | self.measure_time = None 184 | 185 | def submit(self): 186 | # Submit any legacy or tagged measurements to API 187 | # This will actually return an empty 200 response (no body) 188 | if self.measurements: 189 | self.connection._mexe("metrics", 190 | method="POST", 191 | query_props=self.to_payload()) 192 | if self.tagged_measurements: 193 | self.connection._mexe("measurements", 194 | method="POST", 195 | query_props=self.to_md_payload()) 196 | # Clear measurements 197 | self.clear() 198 | -------------------------------------------------------------------------------- /librato/alerts.py: -------------------------------------------------------------------------------- 1 | class Alert(object): 2 | """Librato Alert Base class""" 3 | 4 | def __init__(self, connection, name, _id=None, description=None, version=2, md=False, 5 | conditions=[], services=[], attributes={}, active=True, rearm_seconds=None): 6 | self.connection = connection 7 | self.name = name 8 | self.description = description 9 | self.version = version 10 | self.conditions = [] 11 | for c in conditions: 12 | if isinstance(c, Condition): 13 | self.conditions.append(c) 14 | elif isinstance(c, dict): 15 | self.conditions.append(Condition.from_dict(c)) 16 | else: 17 | self.conditions.append(Condition(*c)) 18 | self.services = [] 19 | for s in services: 20 | if isinstance(s, Service): 21 | self.services.append(s) 22 | elif isinstance(s, dict): 23 | self.services.append(Service.from_dict(connection, s)) 24 | elif isinstance(s, int): 25 | self.services.append(Service(s)) 26 | else: 27 | self.services.append(Service(*s)) 28 | self.attributes = attributes 29 | self.active = active 30 | self.rearm_seconds = rearm_seconds 31 | self._id = _id 32 | self.md = md 33 | 34 | def add_condition_for(self, metric_name, source='*'): 35 | condition = Condition(metric_name, source) 36 | self.conditions.append(condition) 37 | return condition 38 | 39 | def add_service(self, service_id): 40 | self.services.append(Service(service_id)) 41 | 42 | def __repr__(self): 43 | return "%s<%s>" % (self.__class__.__name__, self.name) 44 | 45 | @classmethod 46 | def from_dict(cls, connection, data): 47 | """Returns an alert object from a dictionary item, 48 | which is usually from librato's API""" 49 | obj = cls(connection, 50 | data['name'], 51 | version=data['version'], 52 | description=data['description'], 53 | conditions=data['conditions'], 54 | services=data['services'], 55 | _id=data['id'], 56 | active=data['active'], 57 | rearm_seconds=data['rearm_seconds'], 58 | attributes=data['attributes'], 59 | md=data['md']) 60 | return obj 61 | 62 | def get_payload(self): 63 | return {'name': self.name, 64 | 'md': self.md, 65 | 'attributes': self.attributes, 66 | 'version': self.version, 67 | 'description': self.description, 68 | 'rearm_seconds': self.rearm_seconds, 69 | 'active': self.active, 70 | 'services': [x._id for x in self.services], 71 | 'conditions': [x.get_payload() for x in self.conditions]} 72 | 73 | def save(self): 74 | self.connection.update_alert(self) 75 | 76 | 77 | class Condition(object): 78 | ABOVE = 'above' 79 | BELOW = 'below' 80 | ABSENT = 'absent' 81 | 82 | # Note this is 'average' not 'mean' 83 | SUMMARY_FUNCTION_AVERAGE = 'average' 84 | 85 | def __init__(self, metric_name, source='*', tags=None): 86 | self.metric_name = metric_name 87 | self.source = source 88 | self.tags = tags or {} 89 | self.summary_function = None 90 | 91 | def above(self, threshold, summary_function=SUMMARY_FUNCTION_AVERAGE): 92 | self.condition_type = self.ABOVE 93 | self.summary_function = summary_function 94 | self.threshold = threshold 95 | # This implies an immediate trigger 96 | self._duration = None 97 | return self 98 | 99 | def below(self, threshold, summary_function=SUMMARY_FUNCTION_AVERAGE): 100 | self.condition_type = self.BELOW 101 | self.summary_function = summary_function 102 | self.threshold = threshold 103 | # This implies an immediate trigger 104 | self._duration = None 105 | return self 106 | 107 | # Stops reporting for a duration (in seconds) 108 | def stops_reporting_for(self, duration): 109 | self.condition_type = self.ABSENT 110 | self.summary_function = None 111 | self._duration = duration 112 | return self 113 | 114 | def duration(self, duration): 115 | self._duration = duration 116 | 117 | # An alert condition is either "immediate" or "time windowed" 118 | def immediate(self): 119 | if self._duration is None or self._duration == 0: 120 | return True 121 | else: 122 | return False 123 | 124 | @classmethod 125 | def from_dict(cls, data): 126 | obj = cls(metric_name=data['metric_name'], 127 | source=data.get('source', '*'), 128 | tags=data.get('tags', {})) 129 | if data['type'] == Condition.ABOVE: 130 | obj.above(data.get('threshold'), data.get('summary_function')) 131 | obj.duration(data.get('duration')) 132 | elif data['type'] == Condition.BELOW: 133 | obj.below(data.get('threshold'), data.get('summary_function')) 134 | obj.duration(data.get('duration')) 135 | elif data['type'] == Condition.ABSENT: 136 | obj.stops_reporting_for(data.get('duration')) 137 | return obj 138 | 139 | def get_payload(self): 140 | obj = { 141 | 'type': self.condition_type, 142 | 'metric_name': self.metric_name, 143 | 'source': self.source, 144 | 'tags': self.tags, 145 | 'summary_function': self.summary_function, 146 | 'duration': self._duration 147 | } 148 | if self.condition_type in [self.ABOVE, self.BELOW]: 149 | obj['threshold'] = self.threshold 150 | return obj 151 | 152 | 153 | class Service(object): 154 | def __init__(self, _id, title=None, type=None, settings=None): 155 | self._id = _id 156 | self.title = title 157 | self.type = type 158 | self.settings = settings 159 | 160 | @classmethod 161 | def from_dict(cls, connection, data): 162 | obj = cls(data['id'], data['title'], data['type'], data['settings']) 163 | return obj 164 | 165 | def get_payload(self): 166 | return { 167 | 'id': self._id, 168 | 'title': self.title, 169 | 'type': self.type, 170 | 'settings': self.settings 171 | } 172 | 173 | def __repr__(self): 174 | return "%s<%s><%s>" % (self.__class__.__name__, self.type, self.title) 175 | -------------------------------------------------------------------------------- /librato/annotations.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2013. Librato, Inc. 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or without 5 | # modification, are permitted provided that the following conditions are met: 6 | # * Redistributions of source code must retain the above copyright 7 | # notice, this list of conditions and the following disclaimer. 8 | # * Redistributions in binary form must reproduce the above copyright 9 | # notice, this list of conditions and the following disclaimer in the 10 | # documentation and/or other materials provided with the distribution. 11 | # * Neither the name of Librato, Inc. nor the names of project contributors 12 | # may be used to endorse or promote products derived from this software 13 | # without specific prior written permission. 14 | # 15 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 16 | # ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 17 | # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 18 | # DISCLAIMED. IN NO EVENT SHALL LIBRATO, INC. BE LIABLE FOR ANY 19 | # DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 20 | # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 21 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 22 | # ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 23 | # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 24 | # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25 | 26 | 27 | class Annotation(object): 28 | """Librato Annotation Stream Base class""" 29 | 30 | def __init__(self, connection, name, display_name=None): 31 | self.connection = connection 32 | self.name = name 33 | self.display_name = display_name 34 | self.events = {} 35 | self.query = {} 36 | 37 | def __repr__(self): 38 | return "%s<%s>" % (self.__class__.__name__, self.name) 39 | 40 | @classmethod 41 | def from_dict(cls, connection, data): 42 | """Returns a metric object from a dictionary item, 43 | which is usually from librato's API""" 44 | obj = cls(connection, data['name']) 45 | obj.display_name = data['display_name'] if 'display_name' in data else None 46 | obj.events = data['events'] if 'events' in data else None 47 | obj.query = data['query'] if 'query' in data else {} 48 | return obj 49 | 50 | def get_payload(self): 51 | return {'name': self.name, 'display_name': self.display_name} 52 | -------------------------------------------------------------------------------- /librato/exceptions.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2013. Librato, Inc. 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or without 5 | # modification, are permitted provided that the following conditions are met: 6 | # * Redistributions of source code must retain the above copyright 7 | # notice, this list of conditions and the following disclaimer. 8 | # * Redistributions in binary form must reproduce the above copyright 9 | # notice, this list of conditions and the following disclaimer in the 10 | # documentation and/or other materials provided with the distribution. 11 | # * Neither the name of Librato, Inc. nor the names of project contributors 12 | # may be used to endorse or promote products derived from this software 13 | # without specific prior written permission. 14 | # 15 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 16 | # ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 17 | # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 18 | # DISCLAIMED. IN NO EVENT SHALL LIBRATO, INC. BE LIABLE FOR ANY 19 | # DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 20 | # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 21 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 22 | # ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 23 | # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 24 | # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25 | 26 | 27 | class ClientError(Exception): 28 | """4xx client exceptions""" 29 | def __init__(self, code, error_payload=None): 30 | self.code = code 31 | self.error_payload = error_payload 32 | Exception.__init__(self, self.error_message()) 33 | 34 | def error_message(self): 35 | return "[%s] %s" % (self.code, self._parse_error_message()) 36 | 37 | # See http://dev.librato.com/v1/responses-errors 38 | # Examples: 39 | # { 40 | # "errors": { 41 | # "params": { 42 | # "name":["is not present"], 43 | # "start_time":["is not a number"] 44 | # } 45 | # } 46 | # } 47 | # 48 | # 49 | # { 50 | # "errors": { 51 | # "request": [ 52 | # "Please use secured connection through https!", 53 | # "Please provide credentials for authentication." 54 | # ] 55 | # } 56 | # } 57 | # 58 | # 59 | # { 60 | # "errors": { 61 | # "request": "The requested data resolution is unavailable for the 62 | # given time range. Please try a different resolution." 63 | # } 64 | # } 65 | # 66 | # 67 | # Rate limiting example: 68 | # { 69 | # u'request_time': 1467306906, 70 | # u'error': u'You have hit the API limit for measurements 71 | # [measure:raw_rate]. Contact: support@librato.com to adjust this limit.' 72 | # } 73 | def _parse_error_message(self): 74 | if isinstance(self.error_payload, str): 75 | # Payload is just a string 76 | return self.error_payload 77 | elif isinstance(self.error_payload, dict): 78 | # The API could return 'errors' or just 'error' with a flat message 79 | if 'error' in self.error_payload: 80 | return self.error_payload['error'] 81 | elif 'message' in self.error_payload: 82 | return self.error_payload['message'] 83 | else: 84 | payload = self.error_payload['errors'] 85 | messages = [] 86 | if isinstance(payload, list): 87 | return payload 88 | for key in payload: 89 | error_list = payload[key] 90 | if isinstance(error_list, str): 91 | # The error message is a scalar string, just tack it on 92 | msg = "%s: %s" % (key, error_list) 93 | messages.append(msg) 94 | elif isinstance(error_list, list): 95 | for error_message in error_list: 96 | msg = "%s: %s" % (key, error_message) 97 | messages.append(msg) 98 | elif isinstance(error_list, dict): 99 | for k in error_list: 100 | # e.g. "params: measure_time: " 101 | msg = "%s: %s: " % (key, k) 102 | msg += self._flatten_error_message(error_list[k]) 103 | messages.append(msg) 104 | return ", ".join(messages) 105 | 106 | def _flatten_error_message(self, error_msg): 107 | if isinstance(error_msg, str): 108 | return error_msg 109 | elif isinstance(error_msg, list): 110 | # Join with commas 111 | return ", ".join(error_msg) 112 | elif isinstance(error_msg, dict): 113 | # Flatten out the dict 114 | for k in error_msg: 115 | messages = ", ".join(error_msg[k]) 116 | return "%s: %s" % (k, messages) 117 | 118 | 119 | class BadRequest(ClientError): 120 | """400 Forbidden""" 121 | def __init__(self, msg=None): 122 | ClientError.__init__(self, 400, msg) 123 | 124 | 125 | class Unauthorized(ClientError): 126 | """401 Unauthorized""" 127 | def __init__(self, msg=None): 128 | ClientError.__init__(self, 401, msg) 129 | 130 | 131 | class Forbidden(ClientError): 132 | """403 Forbidden""" 133 | def __init__(self, msg=None): 134 | ClientError.__init__(self, 403, msg) 135 | 136 | 137 | class NotFound(ClientError): 138 | """404 Forbidden""" 139 | def __init__(self, msg=None): 140 | ClientError.__init__(self, 404, msg) 141 | 142 | CODES = { 143 | 400: BadRequest, 144 | 401: Unauthorized, 145 | 403: Forbidden, 146 | 404: NotFound 147 | } 148 | 149 | 150 | # http://dev.librato.com/v1/responses-errors 151 | def get(code, resp_data): 152 | if code in CODES: 153 | return CODES[code](resp_data) 154 | else: 155 | return ClientError(code, resp_data) 156 | -------------------------------------------------------------------------------- /librato/metrics.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2013. Librato, Inc. 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or without 5 | # modification, are permitted provided that the following conditions are met: 6 | # * Redistributions of source code must retain the above copyright 7 | # notice, this list of conditions and the following disclaimer. 8 | # * Redistributions in binary form must reproduce the above copyright 9 | # notice, this list of conditions and the following disclaimer in the 10 | # documentation and/or other materials provided with the distribution. 11 | # * Neither the name of Librato, Inc. nor the names of project contributors 12 | # may be used to endorse or promote products derived from this software 13 | # without specific prior written permission. 14 | # 15 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 16 | # ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 17 | # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 18 | # DISCLAIMED. IN NO EVENT SHALL LIBRATO, INC. BE LIABLE FOR ANY 19 | # DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 20 | # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 21 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 22 | # ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 23 | # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 24 | # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25 | 26 | 27 | class Metric(object): 28 | """Librato Metric Base class""" 29 | 30 | def __init__(self, connection, name, attributes=None, period=None, description=None): 31 | self.connection = connection 32 | self.name = name 33 | self.attributes = attributes or {} 34 | self.period = period 35 | self.description = description 36 | self.measurements = {} 37 | self.query = {} 38 | self.composite = None 39 | 40 | def __getitem__(self, name): 41 | return self.attributes[name] 42 | 43 | def get(self, name, default=None): 44 | return self.attributes.get(name, default) 45 | 46 | @classmethod 47 | def from_dict(cls, connection, data): 48 | """Returns a metric object from a dictionary item, 49 | which is usually from librato's API""" 50 | metric_type = data.get('type') 51 | if metric_type == "gauge": 52 | cls = Gauge 53 | elif metric_type == "counter": 54 | cls = Counter 55 | elif metric_type == "composite": 56 | # Since we don't have a formal Composite class, use Gauge for now 57 | cls = Gauge 58 | 59 | obj = cls(connection, data['name']) 60 | obj.period = data['period'] 61 | obj.attributes = data['attributes'] 62 | obj.description = data['description'] if 'description' in data else None 63 | obj.measurements = data['measurements'] if 'measurements' in data else {} 64 | obj.query = data['query'] if 'query' in data else {} 65 | obj.composite = data.get('composite', None) 66 | obj.source_lag = data.get('source_lag', None) 67 | 68 | return obj 69 | 70 | def __repr__(self): 71 | return "%s<%s>" % (self.__class__.__name__, self.name) 72 | 73 | 74 | class Gauge(Metric): 75 | """Librato Gauge metric""" 76 | def add(self, value, source=None, **params): 77 | """Add a new measurement to this gauge""" 78 | if source: 79 | params['source'] = source 80 | return self.connection.submit(self.name, value, type="gauge", **params) 81 | 82 | def what_am_i(self): 83 | return 'gauges' 84 | 85 | 86 | class Counter(Metric): 87 | """Librato Counter metric""" 88 | def add(self, value, source=None, **params): 89 | if source: 90 | params['source'] = source 91 | 92 | return self.connection.submit(self.name, value, type="counter", **params) 93 | 94 | def what_am_i(self): 95 | return 'counters' 96 | -------------------------------------------------------------------------------- /librato/queue.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2013. Librato, Inc. 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or without 5 | # modification, are permitted provided that the following conditions are met: 6 | # * Redistributions of source code must retain the above copyright 7 | # notice, this list of conditions and the following disclaimer. 8 | # * Redistributions in binary form must reproduce the above copyright 9 | # notice, this list of conditions and the following disclaimer in the 10 | # documentation and/or other materials provided with the distribution. 11 | # * Neither the name of Librato, Inc. nor the names of project contributors 12 | # may be used to endorse or promote products derived from this software 13 | # without specific prior written permission. 14 | # 15 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 16 | # ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 17 | # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 18 | # DISCLAIMED. IN NO EVENT SHALL LIBRATO, INC. BE LIABLE FOR ANY 19 | # DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 20 | # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 21 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 22 | # ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 23 | # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 24 | # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25 | 26 | 27 | class Queue(object): 28 | """Sending small amounts of measurements in a single HTTP request 29 | is inefficient. The payload is small and the overhead in the server 30 | for storing a single measurement is not worth it. 31 | 32 | This class allows the user to queue measurements which will be sent in a 33 | efficient manner. It allows both legacy and tagged measurements to be 34 | sent to Librato. 35 | 36 | Chunks are dicts of JSON objects for POST /metrics request. 37 | Legacy measurements have two keys 'gauges' and 'counters'. The value of these keys 38 | are lists of dict measurements. 39 | Tagged measurements have a 'measurements' key, whose value is a list of dict measurements. 40 | 41 | When the user sends a .submit() we iterate over the list of chunks and 42 | send one at a time. 43 | """ 44 | MAX_MEASUREMENTS_PER_CHUNK = 300 # based docs; on POST /metrics 45 | 46 | def __init__(self, connection, auto_submit_count=None, tags={}): 47 | self.connection = connection 48 | self.tags = dict(tags) 49 | self.chunks = [] 50 | self.tagged_chunks = [] 51 | self.auto_submit_count = auto_submit_count 52 | 53 | # Get a shallow copy of the top-level tag set 54 | def get_tags(self): 55 | return dict(self.tags) 56 | 57 | # Define the top-level tag set for posting measurements 58 | def set_tags(self, d): 59 | self.tags = dict(d) # Create a copy 60 | 61 | # Add one or more top-level tags for posting measurements 62 | def add_tags(self, d): 63 | self.tags.update(d) 64 | 65 | def add(self, name, value, type='gauge', **query_props): 66 | """add measurements to the Q""" 67 | if 'tags' in query_props or len(self.tags) > 0 or len(self.connection.get_tags()) > 0: 68 | self.add_tagged(name, value, **query_props) 69 | else: 70 | nm = {} # new measurement 71 | nm['name'] = self.connection.sanitize(name) 72 | nm['value'] = value 73 | 74 | for pn, v in query_props.items(): 75 | nm[pn] = v 76 | 77 | self._add_measurement(type, nm) 78 | self._auto_submit_if_necessary() 79 | 80 | def add_tagged(self, name, value, **query_props): 81 | nm = {} # new measurement 82 | nm['name'] = self.connection.sanitize(name) 83 | nm['sum'] = value 84 | nm['count'] = 1 85 | 86 | # must remove the inherit_tags key for compliance with json 87 | inherit_tags = query_props.pop('inherit_tags', False) 88 | tags = query_props.get('tags', {}) 89 | if inherit_tags or tags == {}: 90 | inheritted_tags = dict(self.connection.get_tags(), **self.get_tags()) 91 | query_props['tags'] = dict(inheritted_tags, **tags) 92 | 93 | for pn, v in query_props.items(): 94 | nm[pn] = v 95 | 96 | self._add_tagged_measurement(nm) 97 | self._auto_submit_if_necessary() 98 | 99 | def add_aggregator(self, aggregator): 100 | cloned_measurements = dict(aggregator.measurements) 101 | 102 | # Find measure_time, if any 103 | mt = aggregator.get_measure_time() 104 | 105 | for name in cloned_measurements: 106 | nm = cloned_measurements[name] 107 | # Set metric name 108 | nm['name'] = name 109 | # Set measure_time 110 | if mt: 111 | nm['measure_time'] = mt 112 | # Set source 113 | if aggregator.source: 114 | nm['source'] = aggregator.source 115 | self._add_measurement('gauge', nm) 116 | 117 | tagged_measurements = dict(aggregator.tagged_measurements) 118 | for name in tagged_measurements: 119 | nm = tagged_measurements[name] 120 | 121 | nm['name'] = name 122 | if mt: 123 | nm['time'] = mt 124 | 125 | if aggregator.tags: 126 | if 'tags' not in nm: 127 | nm['tags'] = {} 128 | nm['tags'].update(aggregator.tags) 129 | 130 | self._add_tagged_measurement(nm) 131 | 132 | # Clear measurements from aggregator 133 | aggregator.clear() 134 | 135 | self._auto_submit_if_necessary() 136 | 137 | def submit(self): 138 | for c in self.chunks: 139 | self.connection._mexe("metrics", method="POST", query_props=c) 140 | self.chunks = [] 141 | 142 | for chunk in self.tagged_chunks: 143 | self.connection._mexe("measurements", method="POST", query_props=chunk) 144 | self.tagged_chunks = [] 145 | 146 | def __enter__(self): 147 | return self 148 | 149 | def __exit__(self, type, value, traceback): 150 | self.submit() 151 | 152 | # Private, sort of. 153 | # 154 | def _auto_submit_if_necessary(self): 155 | if self.auto_submit_count and self._num_measurements_in_queue() >= self.auto_submit_count: 156 | self.submit() 157 | 158 | def _add_measurement(self, type, nm): 159 | if not self.chunks or self._num_measurements_in_current_chunk() == self.MAX_MEASUREMENTS_PER_CHUNK: 160 | self.chunks.append({'gauges': [], 'counters': []}) 161 | self.chunks[-1][type + 's'].append(nm) 162 | 163 | def _add_tagged_measurement(self, nm): 164 | if (not self.tagged_chunks or 165 | self._num_measurements_in_current_chunk(tagged=True) == self.MAX_MEASUREMENTS_PER_CHUNK): 166 | self.tagged_chunks.append({'measurements': []}) 167 | self.tagged_chunks[-1]['measurements'].append(nm) 168 | 169 | def _current_chunk(self, tagged=False): 170 | if tagged: 171 | return self.tagged_chunks[-1] if self.tagged_chunks else None 172 | else: 173 | return self.chunks[-1] if self.chunks else None 174 | 175 | def _num_measurements_in_current_chunk(self, tagged=False): 176 | if tagged: 177 | if self.tagged_chunks: 178 | return len(self.tagged_chunks[-1]['measurements']) 179 | else: 180 | return 0 181 | else: 182 | if self.chunks: 183 | cc = self.chunks[-1] 184 | return len(cc['gauges']) + len(cc['counters']) 185 | else: 186 | return 0 187 | 188 | def _num_measurements_in_queue(self): 189 | num = 0 190 | if self.chunks: 191 | num += self._num_measurements_in_current_chunk() + self.MAX_MEASUREMENTS_PER_CHUNK * (len(self.chunks) - 1) 192 | if self.tagged_chunks: 193 | num += (self._num_measurements_in_current_chunk(tagged=True) + 194 | self.MAX_MEASUREMENTS_PER_CHUNK * (len(self.tagged_chunks) - 1)) 195 | return num 196 | -------------------------------------------------------------------------------- /librato/spaces.py: -------------------------------------------------------------------------------- 1 | from librato.streams import Stream 2 | 3 | 4 | class Space(object): 5 | """Librato Space Base class""" 6 | 7 | def __init__(self, 8 | connection, 9 | name, 10 | id=None, 11 | chart_dicts=None, 12 | tags=False): 13 | self.connection = connection 14 | self.name = name 15 | self.chart_ids = [] 16 | self._charts = None 17 | self.tags = tags 18 | for c in (chart_dicts or []): 19 | self.chart_ids.append(c['id']) 20 | self.id = id 21 | 22 | @classmethod 23 | def from_dict(cls, connection, data): 24 | """ 25 | Returns a Space object from a dictionary item, 26 | which is usually from librato's API 27 | """ 28 | obj = cls(connection, 29 | data['name'], 30 | id=data['id'], 31 | chart_dicts=data.get('charts'), 32 | tags=data.get('tags')) 33 | return obj 34 | 35 | def get_payload(self): 36 | return {'name': self.name} 37 | 38 | def persisted(self): 39 | return self.id is not None 40 | 41 | def charts(self): 42 | if self._charts is None or self._charts == []: 43 | self._charts = self.connection.list_charts_in_space(self) 44 | return self._charts[:] 45 | 46 | # New up a chart 47 | def new_chart(self, name, **kwargs): 48 | return Chart(self.connection, name, space_id=self.id, **kwargs) 49 | 50 | # New up a chart and save it 51 | def add_chart(self, name, **kwargs): 52 | chart = self.new_chart(name, **kwargs) 53 | return chart.save() 54 | 55 | def add_line_chart(self, name, streams=[]): 56 | return self.add_chart(name, streams=streams) 57 | 58 | def add_single_line_chart(self, name, metric=None, source='*', 59 | group_function=None, summary_function=None): 60 | stream = {'metric': metric, 'source': source} 61 | 62 | if group_function: 63 | stream['group_function'] = group_function 64 | if summary_function: 65 | stream['summary_function'] = summary_function 66 | return self.add_line_chart(name, streams=[stream]) 67 | 68 | def add_stacked_chart(self, name, streams=[]): 69 | return self.add_chart(name, type='stacked', streams=streams) 70 | 71 | def add_single_stacked_chart(self, name, metric, source='*'): 72 | stream = {'metric': metric, 'source': source} 73 | return self.add_stacked_chart(name, streams=[stream]) 74 | 75 | def add_bignumber_chart(self, name, metric, source='*', 76 | group_function='average', 77 | summary_function='average', use_last_value=True): 78 | stream = { 79 | 'metric': metric, 80 | 'source': source, 81 | 'group_function': group_function, 82 | 'summary_function': summary_function 83 | } 84 | chart = self.add_chart(name, 85 | type='bignumber', 86 | use_last_value=use_last_value, 87 | streams=[stream]) 88 | return chart 89 | 90 | # This currently only updates the name of the Space 91 | def save(self): 92 | if self.persisted(): 93 | return self.connection.update_space(self) 94 | else: 95 | s = self.connection.create_space(self.name, tags=self.tags) 96 | self.id = s.id 97 | return s 98 | 99 | def rename(self, new_name): 100 | self.name = new_name 101 | self.save() 102 | 103 | def delete(self): 104 | return self.connection.delete_space(self.id) 105 | 106 | 107 | class Chart(object): 108 | # Payload example from /spaces/123/charts/456 API 109 | # { 110 | # "id": 1723352, 111 | # "name": "Hottest City", 112 | # "type": "line", 113 | # "streams": [ 114 | # { 115 | # "id": 19261984, 116 | # "metric": "apparent_temperature", 117 | # "type": "gauge", 118 | # "source": "*", 119 | # "group_function": "max", 120 | # "summary_function": "max" 121 | # } 122 | # ], 123 | # "max": 105, 124 | # "min": 0, 125 | # "related_space": 96893, 126 | # "label": "The y axis label", 127 | # "use_log_yaxis": true 128 | # } 129 | def __init__(self, connection, name=None, id=None, type='line', 130 | space_id=None, streams=[], 131 | min=None, max=None, 132 | label=None, 133 | use_log_yaxis=None, 134 | use_last_value=None, 135 | related_space=None): 136 | self.connection = connection 137 | self.name = name 138 | self.type = type 139 | self.space_id = space_id 140 | self._space = None 141 | self.streams = [] 142 | self.label = label 143 | self.min = min 144 | self.max = max 145 | self.use_log_yaxis = use_log_yaxis 146 | self.use_last_value = use_last_value 147 | self.related_space = related_space 148 | for i in streams: 149 | if isinstance(i, Stream): 150 | self.streams.append(i) 151 | elif isinstance(i, dict): # Probably parsing JSON here 152 | # dict 153 | self.streams.append(Stream(**i)) 154 | else: 155 | # list? 156 | self.streams.append(Stream(*i)) 157 | self.id = id 158 | 159 | @classmethod 160 | def from_dict(cls, connection, data): 161 | """ 162 | Returns a Chart object from a dictionary item, 163 | which is usually from librato's API 164 | """ 165 | obj = cls(connection, 166 | data['name'], 167 | id=data['id'], 168 | type=data.get('type', 'line'), 169 | space_id=data.get('space_id'), 170 | streams=data.get('streams'), 171 | min=data.get('min'), 172 | max=data.get('max'), 173 | label=data.get('label'), 174 | use_log_yaxis=data.get('use_log_yaxis'), 175 | use_last_value=data.get('use_last_value'), 176 | related_space=data.get('related_space')) 177 | return obj 178 | 179 | def space(self): 180 | if self._space is None and self.space_id is not None: 181 | # Find the Space 182 | self._space = self.connection.get_space(self.space_id) 183 | return self._space 184 | 185 | def known_attributes(self): 186 | return ['min', 'max', 'label', 'use_log_yaxis', 'use_last_value', 187 | 'related_space'] 188 | 189 | def get_payload(self): 190 | # Set up the things that we aren't considering just "attributes" 191 | payload = { 192 | 'name': self.name, 193 | 'type': self.type, 194 | 'streams': self.streams_payload() 195 | } 196 | for attr in self.known_attributes(): 197 | if getattr(self, attr) is not None: 198 | payload[attr] = getattr(self, attr) 199 | return payload 200 | 201 | def streams_payload(self): 202 | return [s.get_payload() for s in self.streams] 203 | 204 | def new_stream(self, metric=None, source='*', **kwargs): 205 | stream = Stream(metric, source, **kwargs) 206 | self.streams.append(stream) 207 | return stream 208 | 209 | def persisted(self): 210 | return self.id is not None 211 | 212 | def save(self): 213 | if self.persisted(): 214 | return self.connection.update_chart(self, self.space()) 215 | else: 216 | payload = self.get_payload() 217 | # Don't include name twice 218 | payload.pop('name') 219 | resp = self.connection.create_chart(self.name, self.space(), 220 | **payload) 221 | self.id = resp.id 222 | return resp 223 | 224 | def rename(self, new_name): 225 | self.name = new_name 226 | self.save() 227 | 228 | def delete(self): 229 | return self.connection.delete_chart(self.id, self.space_id) 230 | -------------------------------------------------------------------------------- /librato/streams.py: -------------------------------------------------------------------------------- 1 | class Stream(object): 2 | def __init__(self, metric=None, source='*', composite=None, 3 | name=None, type=None, id=None, 4 | group_function=None, summary_function=None, 5 | transform_function=None, downsample_function=None, 6 | period=None, split_axis=None, gap_detection=None, 7 | min=None, max=None, 8 | units_short=None, units_long=None, color=None, 9 | position=None, 10 | # deprecated 11 | composite_function=None, **kwargs 12 | ): 13 | self.metric = metric 14 | self.source = source 15 | # Spaces API 16 | self.composite = composite 17 | # For instrument compatibility 18 | self.name = name 19 | self.type = type 20 | self.id = id 21 | # average, sum, min, max, breakout 22 | self.group_function = group_function 23 | # average, sum, min, max, count (or derivative if counter) 24 | self.summary_function = summary_function 25 | self.transform_function = transform_function 26 | self.downsample_function = downsample_function 27 | self.period = period 28 | self.split_axis = split_axis 29 | self.min = min 30 | self.max = max 31 | self.units_short = units_short 32 | self.units_long = units_long 33 | self.color = color 34 | self.gap_detection = gap_detection 35 | self.position = position 36 | 37 | # Pick up any attributes that are not explicitly defined 38 | for attr in kwargs: 39 | setattr(self, attr, kwargs[attr]) 40 | 41 | # Can't have a composite and source 42 | if self.composite: 43 | self.source = None 44 | 45 | def _attrs(self): 46 | return ['metric', 'source', 'composite', 'name', 47 | 'type', 'id', 'group_function', 'summary_function', 'transform_function', 'downsample_function', 48 | 'period', 'split_axis', 'min', 'max', 'units_short', 'units_long'] 49 | 50 | def get_payload(self): 51 | payload = {} 52 | for attr in self._attrs(): 53 | if getattr(self, attr) is not None: 54 | payload[attr] = getattr(self, attr) 55 | return payload 56 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # 3 | import os 4 | import sys 5 | from setuptools import setup 6 | 7 | if sys.argv[-1] == 'publish': 8 | os.system('python setup.py sdist upload') 9 | sys.exit() 10 | 11 | setup( 12 | name="librato-metrics", 13 | version="3.1.0", # Update also in __init__ ; look into zest.releaser to avoid having two versions 14 | description="Python API Wrapper for Librato", 15 | long_description="Python Wrapper for the Librato Metrics API: http://dev.librato.com/v1/metrics", 16 | author="Librato", 17 | author_email="support@librato.com", 18 | url='http://github.com/librato/python-librato', 19 | license='https://github.com/librato/python-librato/blob/master/LICENSE', 20 | packages=['librato'], 21 | package_data={'': ['LICENSE', 'README.md', 'CHANGELOG.md']}, 22 | package_dir={'librato': 'librato'}, 23 | include_package_data=True, 24 | platforms='Posix; MacOS X; Windows', 25 | classifiers=[ 26 | 'Development Status :: 3 - Alpha', 27 | 'Intended Audience :: Developers', 28 | 'License :: OSI Approved :: MIT License', 29 | 'Operating System :: OS Independent', 30 | 'Topic :: Internet', 31 | 'Programming Language :: Python', 32 | 'Programming Language :: Python :: 3', 33 | ], 34 | dependency_links=[], 35 | install_requires=['six'], 36 | ) 37 | -------------------------------------------------------------------------------- /sh/beautify_json.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # -t fd True if file descriptor fd is open and refers to a terminal. 4 | if [ -t 0 ] 5 | then 6 | echo "No data in stdin." 2>&1 7 | exit 1 8 | else 9 | cat - | python -mjson.tool 10 | fi 11 | -------------------------------------------------------------------------------- /sh/check_coverage.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | script_dir="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 4 | source $script_dir/common.sh 5 | 6 | check_requirements 7 | export PYTHONPATH=$PYTHONPATH:$script_dir/../drio.py 8 | cd $script_dir/.. 9 | rm -rf .coverage* htmlcov 10 | for f in tests/test*.py; do 11 | coverage run -p $f 12 | done 13 | coverage combine 14 | coverage report -m 15 | coverage html 16 | cd .. 17 | -------------------------------------------------------------------------------- /sh/common.sh: -------------------------------------------------------------------------------- 1 | check_requirements() { 2 | REQUIREMENTS="filewatcher nosetests coverage" 3 | for r in $REQUIREMENTS 4 | do 5 | hash $r 2>/dev/null || { 6 | echo >&2 "I require $r but it's not installed. Aborting." 7 | exit 1; 8 | } 9 | done 10 | } 11 | 12 | -------------------------------------------------------------------------------- /sh/publish.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # Ask the necessary questions to the user and publish a new version 4 | # of the package in pypi 5 | # 6 | which vim > /dev/null 7 | if [ $? -ne 0 ];then 8 | echo 'Missing vim. Seriously?' 9 | exit 1 10 | fi 11 | 12 | which ruby > /dev/null 13 | if [ $? -ne 0 ];then 14 | echo 'Missing ruby.' 15 | exit 1 16 | fi 17 | 18 | ONE=`cat setup.py | grep "version" | ruby -ne 'puts $_.match(/([\d\.]+)\"/)[1]'` 19 | TWO=`cat librato/__init__.py | grep "^__version__" | ruby -ne 'puts $_.match(/([\d\.]+)\"/)[1]'` 20 | THREE=`cat CHANGELOG.md | grep Version | head -1 | awk '{print $3}'` 21 | 22 | echo "Current version detected (setup|init|changelog): $ONE $TWO $THREE" 23 | echo -ne "Introduce new version: " 24 | read NEW 25 | export _NEW=$NEW 26 | 27 | echo "* Introduce your message here." > _tmp 28 | vim _tmp 29 | MSG=`cat _tmp` 30 | rm _tmp 31 | 32 | CHL_MSG=< _tmp 38 | mv _tmp setup.py 39 | cat librato/__init__.py | ruby -ne 'puts $_.gsub(/__version__ = \"[\d\.]+\"/, "__version__ = \"" + ENV["_NEW"] + "\"")' > _tmp 40 | mv _tmp librato/__init__.py 41 | ( echo -e "## Changelog\n" ; echo "### Version $NEW"; echo "$MSG"; cat CHANGELOG.md | grep -v Change) > _tmp 42 | mv _tmp CHANGELOG.md 43 | rm -f _tmp 44 | 45 | echo "" 46 | git diff 47 | echo "" 48 | echo -ne "Hit to send new package to pypi; to cancel..." 49 | read 50 | 51 | python setup.py sdist upload 52 | git tag -a v$_NEW -m "new version $_NEW" 53 | git push origin --tags 54 | -------------------------------------------------------------------------------- /sh/run_tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | script_dir="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 4 | source $script_dir/common.sh 5 | 6 | check_requirements 7 | export PYTHONPATH=$PYTHONPATH:$script_dir/../librato 8 | cd $script_dir/.. 9 | nosetests tests/ 10 | cd .. 11 | -------------------------------------------------------------------------------- /sh/watch_and_run_tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | script_dir="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 4 | source $script_dir/common.sh 5 | 6 | check_requirements 7 | export PYTHONPATH=$PYTHONPATH:$script_dir/../librato 8 | cd $script_dir/.. 9 | 10 | test_files="tests/test_basic.py tests/test_queue.py tests/test_retry_logic.py" 11 | filewatcher "tests/*.py librato/*.py" "nosetests --nocapture $test_files; echo" 12 | cd .. 13 | -------------------------------------------------------------------------------- /tests/README: -------------------------------------------------------------------------------- 1 | To test single tests: 2 | 3 | python tests/test_spaces.py TestSpaceModel.test_save_creates_space 4 | -------------------------------------------------------------------------------- /tests/integration.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2012 Chris Moyer http://coredumped.org 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a 4 | # copy of this software and associated documentation files (the 5 | # "Software"), to deal in the Software without restriction, including 6 | # without limitation the rights to use, copy, modify, merge, publish, dis- 7 | # tribute, sublicense, and/or sell copies of the Software, and to permit 8 | # persons to whom the Software is furnished to do so, subject to the fol- 9 | # lowing conditions: 10 | # 11 | # The above copyright notice and this permission notice shall be included 12 | # in all copies or substantial portions of the Software. 13 | # 14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 15 | # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABIL- 16 | # ITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 17 | # SHALL THE AUTHOR BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 18 | # WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 20 | # IN THE SOFTWARE. 21 | 22 | import logging 23 | from contextlib import contextmanager 24 | import nose 25 | import unittest 26 | from librato.exceptions import BadRequest 27 | import librato 28 | import os 29 | from random import randint 30 | import time 31 | logging.basicConfig(level=logging.INFO) 32 | 33 | 34 | class TestLibratoBase(unittest.TestCase): 35 | @classmethod 36 | def setUpClass(cls): 37 | """ Auth """ 38 | user = os.environ.get('LIBRATO_USER') 39 | token = os.environ.get('LIBRATO_TOKEN') 40 | assert user and token, "Must set LIBRATO_USER and LIBRATO_TOKEN to run tests" 41 | print "%s and %s" % (user, token) 42 | 43 | """ Ensure user really wants to run these tests """ 44 | are_you_sure = os.environ.get('LIBRATO_ALLOW_INTEGRATION_TESTS') 45 | assert are_you_sure == 'Y', "INTEGRATION TESTS WILL DELETE METRICS " \ 46 | "IN YOUR ACCOUNT!!! " \ 47 | "If you are absolutely sure that you want to run tests "\ 48 | "against %s, please set LIBRATO_ALLOW_INTEGRATION_TESTS "\ 49 | "to 'Y'" % user 50 | 51 | """Initialize the Librato Connection""" 52 | cls.conn = librato.connect(user, token) 53 | cls.conn_sanitize = librato.connect(user, token, sanitizer=librato.sanitize_metric_name) 54 | 55 | # Since these are live tests, I'm adding this to account for the slight 56 | # delay in RDS replication lag at the API (if needed). 57 | # Otherwise we get semi-random failures. 58 | def wait_for_replication(self): 59 | time.sleep(1) 60 | 61 | 62 | class TestLibratoBasic(TestLibratoBase): 63 | 64 | def test_list_metrics(self): 65 | metrics = self.conn.list_metrics() 66 | 67 | def _add_and_verify_metric(self, name, value, desc, connection=None, type='gauge'): 68 | if not connection: 69 | connection = self.conn 70 | connection.submit(name, value, type=type, description=desc) 71 | self.wait_for_replication() 72 | metric = connection.get(name) 73 | assert metric and metric.name == connection.sanitize(name) 74 | assert metric.description == desc 75 | return metric 76 | 77 | def _delete_and_verify_metric(self, names, connection=None): 78 | if not connection: 79 | connection = self.conn 80 | connection.delete(names) 81 | time.sleep(2) 82 | # Make sure it's not there anymore 83 | try: 84 | metric = connection.get(names) 85 | except: 86 | metric = None 87 | assert(metric is None) 88 | 89 | def test_long_sanitized_metric(self): 90 | name, desc = 'a' * 256, 'Too long, will error' 91 | with self.assertRaises(BadRequest): 92 | self._add_and_verify_metric(name, 10, desc, self.conn) 93 | self._add_and_verify_metric(name, 10, desc, self.conn_sanitize) 94 | self._delete_and_verify_metric(name, self.conn_sanitize) 95 | 96 | def test_invalid_sanitized_metric(self): 97 | name, desc = r'I AM #*@#@983221 CRazy((\\\\] invalid', 'Crazy invalid' 98 | with self.assertRaises(BadRequest): 99 | self._add_and_verify_metric(name, 10, desc, self.conn) 100 | self._add_and_verify_metric(name, 10, desc, self.conn_sanitize) 101 | self._delete_and_verify_metric(name, self.conn_sanitize) 102 | 103 | def test_create_and_delete_gauge(self): 104 | name, desc = 'Test', 'Test Gauge to be removed' 105 | self._add_and_verify_metric(name, 10, desc) 106 | self._delete_and_verify_metric(name) 107 | 108 | def test_create_and_delete_counter(self): 109 | name, desc = 'Test_counter', 'Test Counter to be removed' 110 | self._add_and_verify_metric(name, 10, desc, type='counter') 111 | self._delete_and_verify_metric(name) 112 | 113 | def test_batch_delete(self): 114 | name_one, desc_one = 'Test_one', 'Test gauge to be removed' 115 | name_two, desc_two = 'Test_two', 'Test counter to be removed' 116 | self._add_and_verify_metric(name_one, 10, desc_one) 117 | self._add_and_verify_metric(name_two, 10, desc_two, type='counter') 118 | self._delete_and_verify_metric([name_one, name_two]) 119 | 120 | def test_save_gauge_metrics(self): 121 | name, desc = 'Test', 'Test Counter to be removed' 122 | self.conn.submit(name, 10, description=desc) 123 | self.conn.submit(name, 20, description=desc) 124 | self.conn.delete(name) 125 | 126 | def test_send_batch_gauge_measurements(self): 127 | q = self.conn.new_queue() 128 | for t in range(1, 10): 129 | q.add('temperature', randint(20, 40)) 130 | q.submit() 131 | 132 | for t in range(1, 10): 133 | q.add('temperature', randint(20, 40), measure_time=time.time() + t) 134 | q.submit() 135 | 136 | for t in range(1, 10): 137 | q.add('temperature', randint(20, 40), source='upstairs', measure_time=time.time() + t) 138 | q.submit() 139 | 140 | for t in range(1, 50): 141 | q.add('temperature', randint(20, 30), source='downstairs', measure_time=time.time() + t) 142 | q.submit() 143 | self.conn.delete('temperature') 144 | 145 | def test_batch_sanitation(self): 146 | name_one, name_two = 'a' * 500, r'DSJAK#32102391S,m][][[{{]\\' 147 | 148 | def run_batch(connection): 149 | q = connection.new_queue() 150 | q.add(name_one, 10) 151 | q.add(name_two, 10) 152 | q.submit() 153 | 154 | with self.assertRaises(BadRequest): 155 | run_batch(self.conn) 156 | run_batch(self.conn_sanitize) 157 | 158 | self.conn_sanitize.delete([name_one, name_two]) 159 | 160 | def test_submit_empty_queue(self): 161 | self.conn.new_queue().submit() 162 | 163 | def test_send_batch_counter_measurements(self): 164 | q = self.conn.new_queue() 165 | for nr in range(1, 2): 166 | q.add('num_req', nr, type='counter', source='server1', measure_time=time.time() - 1) 167 | q.add('num_req', nr, type='counter', source='server2', measure_time=time.time() - 1) 168 | q.submit() 169 | 170 | def test_update_metrics_attributes(self): 171 | name, desc = 'Test', 'A great gauge.' 172 | self.conn.submit(name, 10, description=desc) 173 | self.wait_for_replication() 174 | gauge = self.conn.get(name) 175 | assert gauge and gauge.name == name 176 | assert gauge.description == desc 177 | 178 | gauge = self.conn.get(name) 179 | attrs = gauge.attributes 180 | attrs['display_min'] = 0 181 | self.conn.update(name, attributes=attrs) 182 | 183 | gauge = self.conn.get(name) 184 | assert gauge.attributes['display_min'] == 0 185 | 186 | self.conn.delete(name) 187 | 188 | def test_sanitized_update(self): 189 | name, desc = 'a' * 1000, 'too long, really' 190 | new_desc = 'different' 191 | self.conn_sanitize.submit(name, 10, description=desc) 192 | gauge = self.conn_sanitize.get(name) 193 | assert gauge.description == desc 194 | 195 | attrs = gauge.attributes['description'] = new_desc 196 | with self.assertRaises(BadRequest): 197 | self.conn.update(name, attributes=attrs) 198 | self.conn_sanitize.delete(name) 199 | 200 | 201 | class TestLibratoAlertsIntegration(TestLibratoBase): 202 | 203 | alerts_created_during_test = [] 204 | gauges_used_during_test = ['metric_test', 'cpu'] 205 | 206 | def setUp(self): 207 | # Ensure metric names exist so we can create conditions on them 208 | for m in self.gauges_used_during_test: 209 | # Create or just update a gauge metric 210 | self.conn.submit(m, 42) 211 | 212 | def tearDown(self): 213 | for name in self.alerts_created_during_test: 214 | self.conn.delete_alert(name) 215 | 216 | def test_add_empty_alert(self): 217 | name = self.unique_name("test_add_empty_alert") 218 | alert = self.conn.create_alert(name) 219 | alert_id = alert._id 220 | alert = self.conn.get_alert(name) 221 | assert alert._id == alert_id 222 | assert alert.name == alert.name 223 | assert len(alert.conditions) == 0 224 | assert len(alert.services) == 0 225 | 226 | def test_inactive_alert_with_rearm_seconds(self): 227 | name = self.unique_name("test_inactive_alert_with_rearm_seconds") 228 | alert = self.conn.create_alert(name, active=False, rearm_seconds=1200) 229 | alert_id = alert._id 230 | alert = self.conn.get_alert(name) 231 | assert alert.rearm_seconds == 1200 232 | assert alert.active is False 233 | 234 | def test_add_alert_with_a_condition(self): 235 | name = self.unique_name("test_add_alert_with_a_condition") 236 | alert = self.conn.create_alert(name) 237 | alert.add_condition_for('metric_test').above(1) 238 | alert.save() 239 | alert_id = alert._id 240 | alert = self.conn.get_alert(name) 241 | assert alert._id == alert_id 242 | assert len(alert.conditions) == 1 243 | assert alert.conditions[0].condition_type == 'above' 244 | assert alert.conditions[0].metric_name == 'metric_test' 245 | 246 | def test_delete_alert(self): 247 | name = self.unique_name("test_delete_alert") 248 | alert = self.conn.create_alert(name) 249 | alert_id = alert._id 250 | alert = self.conn.get_alert(name) 251 | assert alert.name == name 252 | self.conn.delete_alert(name) 253 | time.sleep(2) 254 | # Make sure it's not there anymore 255 | try: 256 | alert = connection.get(names) 257 | except: 258 | alert = None 259 | assert(alert is None) 260 | 261 | def test_add_alert_with_a_service(self): 262 | name = self.unique_name("test_add_alert_with_a_service") 263 | alert = self.conn.create_alert(name) 264 | alert_id = alert._id 265 | alert.add_service(3747) 266 | alert.save() 267 | alert = self.conn.get_alert(name) 268 | assert len(alert.services) == 1 269 | assert len(alert.conditions) == 0 270 | assert alert.services[0]._id == 3747 271 | 272 | def test_add_alert_with_an_above_condition(self): 273 | name = self.unique_name("test_add_alert_with_an_above_condition") 274 | alert = self.conn.create_alert(name) 275 | alert_id = alert._id 276 | alert.add_condition_for('cpu').above(85).duration(70) 277 | alert.save() 278 | alert = self.conn.get_alert(name) 279 | assert len(alert.services) == 0 280 | assert alert.conditions[0].condition_type == 'above' 281 | assert alert.conditions[0]._duration == 70 282 | assert alert.conditions[0].threshold == 85 283 | assert alert.conditions[0].source == '*' 284 | 285 | def test_add_alert_with_an_absent_condition(self): 286 | name = self.unique_name("test_add_alert_with_an_absent_condition") 287 | alert = self.conn.create_alert(name) 288 | alert.add_condition_for('cpu').stops_reporting_for(60) 289 | alert.save() 290 | alert = self.conn.get_alert(name) 291 | assert len(alert.conditions) == 1 292 | condition = alert.conditions[0] 293 | assert condition.condition_type == 'absent' 294 | assert condition.metric_name == 'cpu' 295 | assert condition._duration == 60 296 | assert condition.source == '*' 297 | 298 | def test_add_alert_with_multiple_conditions(self): 299 | name = self.unique_name("test_add_alert_with_multiple_conditions") 300 | alert = self.conn.create_alert(name) 301 | alert.add_condition_for('cpu').above(0, 'sum') 302 | alert.add_condition_for('cpu').stops_reporting_for(3600) 303 | alert.add_condition_for('cpu').stops_reporting_for(3600) 304 | alert.add_condition_for('cpu').above(0, 'count') 305 | alert.save() 306 | 307 | def unique_name(self, prefix): 308 | name = prefix + str(time.time()) 309 | self.alerts_created_during_test.append(name) 310 | return name 311 | 312 | 313 | class TestSpacesApi(TestLibratoBase): 314 | @classmethod 315 | def setUpClass(cls): 316 | super(TestSpacesApi, cls).setUpClass() 317 | for space in cls.conn.list_spaces(): 318 | cls.conn.delete_space(space.id) 319 | 320 | def test_create_space(self): 321 | space = self.conn.create_space("my space") 322 | self.assertIsNotNone(space.id) 323 | 324 | def test_get_space_by_id(self): 325 | space = self.conn.create_space("find me by id") 326 | self.wait_for_replication() 327 | found = self.conn.get_space(space.id) 328 | self.assertEqual(space.id, found.id) 329 | 330 | def test_find_space_by_name(self): 331 | # This assumes you only have 1 space with this name (it's not unique) 332 | space = self.conn.create_space("find me by name") 333 | self.wait_for_replication() 334 | found = self.conn.find_space(space.name) 335 | self.assertEqual(space.id, found.id) 336 | space.delete() 337 | 338 | def test_list_spaces(self): 339 | name = 'list me' 340 | space = self.conn.create_space(name) 341 | spaces = self.conn.list_spaces() 342 | self.assertTrue(name in [s.name for s in spaces]) 343 | 344 | def test_delete_space(self): 345 | space = self.conn.create_space("delete me") 346 | self.wait_for_replication() 347 | self.conn.delete_space(space.id) 348 | 349 | def test_create_space_via_model(self): 350 | space = librato.Space(self.conn, 'Production') 351 | self.assertIsNone(space.id) 352 | space.save() 353 | self.assertIsNotNone(space.id) 354 | 355 | def test_delete_space_via_model(self): 356 | space = librato.Space(self.conn, 'delete me') 357 | space.save() 358 | self.wait_for_replication() 359 | space.delete() 360 | self.wait_for_replication() 361 | self.assertRaises(librato.exceptions.NotFound, self.conn.get_space, space.id) 362 | 363 | def test_create_chart(self): 364 | # Ensure metrics exist 365 | self.conn.submit('memory.free', 100) 366 | self.conn.submit('memory.used', 200) 367 | # Create space 368 | space = librato.Space(self.conn, 'my space') 369 | space.save() 370 | # Add chart 371 | chart = space.add_chart( 372 | 'memory', 373 | type='line', 374 | streams=[ 375 | {'metric': 'memory.free', 'source': '*'}, 376 | {'metric': 'memory.used', 'source': '*', 377 | 'group_function': 'breakout', 'summary_function': 'average'} 378 | ], 379 | min=0, 380 | max=50, 381 | label='the y axis label', 382 | use_log_yaxis=True, 383 | related_space=1234 384 | ) 385 | self.wait_for_replication() 386 | self.assertEqual(len(chart.streams), 2) 387 | 388 | def test_create_big_number(self): 389 | # Ensure metrics exist 390 | self.conn.submit('memory.free', 100) 391 | # Create space 392 | space = librato.Space(self.conn, 'my space') 393 | space.save() 394 | self.wait_for_replication() 395 | chart = space.add_chart( 396 | 'memory', 397 | type='bignumber', 398 | streams=[ 399 | {'metric': 'memory.free', 'source': '*'} 400 | ], 401 | use_last_value=False 402 | ) 403 | 404 | # Shortcut 405 | chart2 = space.add_bignumber_chart('memory 2', 'memory.free', 'foo*', 406 | use_last_value=True) 407 | 408 | self.wait_for_replication() 409 | 410 | self.assertEqual(chart.type, 'bignumber') 411 | self.assertEqual(len(chart.streams), 1) 412 | self.assertFalse(chart.use_last_value) 413 | self.assertEqual(chart2.type, 'bignumber') 414 | self.assertEqual(len(chart2.streams), 1) 415 | self.assertTrue(chart2.use_last_value) 416 | 417 | 418 | if __name__ == '__main__': 419 | # TO run a specific test: 420 | # $ nosetests tests/integration.py:TestLibratoBasic.test_update_metrics_attributes 421 | nose.runmodule() 422 | -------------------------------------------------------------------------------- /tests/test_aggregator.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import unittest 3 | import librato 4 | from librato.aggregator import Aggregator 5 | from mock_connection import MockConnect, server 6 | # from random import randint 7 | 8 | # logging.basicConfig(level=logging.DEBUG) 9 | librato.HTTPSConnection = MockConnect 10 | 11 | 12 | class TestAggregator(unittest.TestCase): 13 | def setUp(self): 14 | self.conn = librato.connect('user_test', 'key_test') 15 | server.clean() 16 | self.agg = Aggregator(self.conn) 17 | 18 | def test_initialize_measurements(self): 19 | assert self.agg.measurements == {} 20 | 21 | def test_no_tags(self): 22 | tags = self.agg.get_tags() 23 | assert len(tags) == 0 24 | 25 | def test_constructor_tags(self): 26 | agg = Aggregator(self.conn, tags={'sky': 'blue'}) 27 | tags = agg.get_tags() 28 | 29 | assert len(tags) == 1 30 | assert 'sky' in tags 31 | assert tags['sky'] == 'blue' 32 | 33 | def test_add_tags(self): 34 | agg = Aggregator(self.conn, tags={'sky': 'blue'}) 35 | agg.add_tags({'sky': 'red', 'coal': 'black'}) 36 | tags = agg.get_tags() 37 | 38 | assert len(tags) == 2 39 | assert 'sky' in tags 40 | assert tags['sky'] == 'red' 41 | assert 'coal' in tags 42 | assert tags['coal'] == 'black' 43 | 44 | def test_set_tags(self): 45 | agg = Aggregator(self.conn, tags={'sky': 'blue'}) 46 | agg.set_tags({'coal': 'black'}) 47 | tags = agg.get_tags() 48 | 49 | assert len(tags) == 1 50 | assert 'coal' in tags 51 | assert tags['coal'] == 'black' 52 | 53 | def test_initialize_source(self): 54 | assert Aggregator(self.conn).source is None 55 | assert Aggregator(self.conn, source='my.source').source == 'my.source' 56 | 57 | def test_initialize_period(self): 58 | assert Aggregator(self.conn).period is None 59 | assert Aggregator(self.conn, period=300).period == 300 60 | 61 | def test_initialize_measure_time(self): 62 | assert Aggregator(self.conn).measure_time is None 63 | assert Aggregator(self.conn, measure_time=12345).measure_time == 12345 64 | 65 | def test_add_single_measurement(self): 66 | m = 'metric.one' 67 | self.agg.add(m, 3) 68 | meas = self.agg.measurements[m] 69 | assert meas['count'] == 1 70 | assert meas['sum'] == 3 71 | assert meas['min'] == 3 72 | assert meas['max'] == 3 73 | 74 | def test_add_multiple_measurements(self): 75 | m = 'metric.one' 76 | self.agg.add(m, 3.1) 77 | self.agg.add(m, 7.2) 78 | meas = self.agg.measurements[m] 79 | assert meas['count'] == 2 80 | assert meas['sum'] == 10.3 81 | assert meas['min'] == 3.1 82 | assert meas['max'] == 7.2 83 | 84 | def test_add_multiple_metrics(self): 85 | m1 = 'metric.one' 86 | self.agg.add(m1, 3.1) 87 | self.agg.add(m1, 7.2) 88 | 89 | m2 = 'metric.two' 90 | self.agg.add(m2, 42) 91 | self.agg.add(m2, 43) 92 | self.agg.add(m2, 44) 93 | 94 | meas = self.agg.measurements[m1] 95 | assert meas['count'] == 2 96 | assert meas['sum'] == 10.3 97 | assert meas['min'] == 3.1 98 | assert meas['max'] == 7.2 99 | 100 | meas = self.agg.measurements[m2] 101 | assert meas['count'] == 3 102 | assert meas['sum'] == 42 + 43 + 44 103 | assert meas['min'] == 42 104 | assert meas['max'] == 44 105 | 106 | # Only gauges are supported (not counters) 107 | def test_to_payload(self): 108 | self.agg.source = 'mysource' 109 | self.agg.add('test.metric', 42) 110 | self.agg.add('test.metric', 43) 111 | assert self.agg.to_payload() == { 112 | 'gauges': [ 113 | {'name': 'test.metric', 'count': 2, 'sum': 85, 'min': 42, 'max': 43} 114 | ], 115 | 'source': 'mysource' 116 | } 117 | assert 'gauges' in self.agg.to_payload() 118 | assert 'counters' not in self.agg.to_payload() 119 | 120 | def test_to_payload_no_source(self): 121 | self.agg.source = None 122 | self.agg.add('test.metric', 42) 123 | 124 | assert self.agg.to_payload() == { 125 | 'gauges': [ 126 | { 127 | 'name': 'test.metric', 128 | 'count': 1, 129 | 'sum': 42, 130 | 'min': 42, 131 | 'max': 42 132 | } 133 | ] 134 | } 135 | 136 | # If 'value' is specified in the payload, the API will throw an error 137 | # This is because it must be calculated at the API via sum/count=avg 138 | def test_value_not_in_payload(self): 139 | self.agg.add('test.metric', 42) 140 | assert 'value' not in self.agg.to_payload() 141 | 142 | def test_clear_clears_measurements(self): 143 | self.agg.add('test.metric', 42) 144 | assert len(self.agg.measurements) == 1 145 | self.agg.clear() 146 | assert self.agg.measurements == {} 147 | 148 | def test_clear_clears_measure_time(self): 149 | self.agg.measure_time = 12345 150 | assert self.agg.measure_time 151 | self.agg.clear() 152 | assert self.agg.measure_time is None 153 | 154 | def test_connection(self): 155 | assert self.agg.connection == self.conn 156 | 157 | def test_submit(self): 158 | self.agg.add('test.metric', 42) 159 | self.agg.add('test.metric', 10) 160 | resp = self.agg.submit() 161 | # Doesn't return a body 162 | assert resp is None 163 | # Comes back empty 164 | assert self.agg.measurements == {} 165 | 166 | def test_period_default(self): 167 | assert Aggregator(self.conn).period is None 168 | 169 | def test_period_attribute(self): 170 | self.agg.period = 300 171 | assert self.agg.period == 300 172 | 173 | def test_measure_time_attribute(self): 174 | self.agg.measure_time = 1418838418 175 | assert self.agg.measure_time == 1418838418 176 | 177 | def test_measure_time_default(self): 178 | assert self.agg.measure_time is None 179 | 180 | def test_measure_time_in_payload(self): 181 | mt = 1418838418 182 | self.agg.measure_time = mt 183 | self.agg.period = None 184 | self.agg.add("foo", 42) 185 | assert 'measure_time' in self.agg.to_payload() 186 | assert self.agg.to_payload()['measure_time'] == mt 187 | 188 | def test_measure_time_not_in_payload(self): 189 | self.agg.measure_time = None 190 | self.agg.period = None 191 | self.agg.add("foo", 42) 192 | assert 'measure_time' not in self.agg.to_payload() 193 | 194 | def test_floor_measure_time(self): 195 | # 2014-12-17 17:46:58 UTC 196 | # should round to 2014-12-17 17:46:00 UTC 197 | # which is 1418838360 198 | self.agg.measure_time = 1418838418 199 | self.agg.period = 60 200 | assert self.agg.floor_measure_time() == 1418838360 201 | 202 | def test_floor_measure_time_period_only(self): 203 | self.agg.measure_time = None 204 | self.agg.period = 60 205 | # Grab wall time and floor to 60 resulting in no remainder 206 | assert self.agg.floor_measure_time() % 60 == 0 207 | 208 | def test_floor_measure_time_no_period(self): 209 | self.agg.measure_time = 1418838418 210 | self.agg.period = None 211 | # Just return the user-specified measure_time 212 | assert self.agg.floor_measure_time() == self.agg.measure_time 213 | 214 | def test_floor_measure_time_no_period_no_measure_time(self): 215 | self.agg.measure_time = None 216 | self.agg.period = None 217 | # Should return nothing 218 | assert self.agg.floor_measure_time() is None 219 | 220 | def test_floored_measure_time_in_payload(self): 221 | # 2014-12-17 17:46:58 UTC 222 | # should round to 2014-12-17 17:46:00 UTC 223 | # which is 1418838360 224 | # This will occur only if period is set 225 | self.agg.measure_time = 1418838418 226 | self.agg.period = 60 227 | assert self.agg.to_payload()['measure_time'] == 1418838360 228 | 229 | def test_submit_side_by_side(self): 230 | # Tagged and untagged measurements should be handled as separate 231 | self.agg.add_tags({'hostname': 'web-1'}) 232 | self.agg.add('test.metric', 42) 233 | self.agg.add_tagged('test.metric', 10) 234 | self.agg.submit() 235 | 236 | gauge = self.conn.get('test.metric', duration=60) 237 | assert len(gauge.measurements['unassigned']) == 1 238 | 239 | resp = self.conn.get_tagged('test.metric', duration=60, tags_search="hostname=web-1") 240 | assert len(resp['series']) == 1 241 | 242 | 243 | if __name__ == '__main__': 244 | unittest.main() 245 | -------------------------------------------------------------------------------- /tests/test_alerts.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import unittest 3 | import librato 4 | from mock_connection import MockConnect, server 5 | 6 | # Mock the server 7 | librato.HTTPSConnection = MockConnect 8 | 9 | 10 | class TestLibratoAlerts(unittest.TestCase): 11 | def setUp(self): 12 | self.conn = librato.connect('user_test', 'key_test') 13 | self.name = 'my_alert' 14 | server.clean() 15 | 16 | def test_list_alerts_when_none(self): 17 | alerts = list(self.conn.list_alerts()) 18 | self.assertEqual(len(alerts), 0) 19 | 20 | def test_create_alert_without_services_or_conditions(self): 21 | alert = self.conn.create_alert(self.name) 22 | self.assertIsInstance(alert, librato.Alert) 23 | self.assertEqual(alert.name, self.name) 24 | self.assertNotEqual(alert._id, 0) 25 | self.assertEqual(len(alert.services), 0) 26 | self.assertEqual(len(alert.conditions), 0) 27 | self.assertEqual(len(list(self.conn.list_alerts())), 1) 28 | 29 | def test_adding_an_alert_with_description(self): 30 | alert = self.conn.create_alert(self.name, description="test_description") 31 | self.assertEqual(alert.description, "test_description") 32 | 33 | def test_create_alert_with_conditions(self): 34 | cond = {'metric_name': 'cpu', 'type': 'above', 'threshold': 42} 35 | alert = self.conn.create_alert(self.name, conditions=[cond]) 36 | self.assertEqual(len(alert.conditions), 1) 37 | self.assertEqual(alert.conditions[0].metric_name, 'cpu') 38 | self.assertEqual(alert.conditions[0].condition_type, 'above') 39 | self.assertEqual(alert.conditions[0].threshold, 42) 40 | 41 | def test_create_alert_with_add_condition(self): 42 | alert = self.conn.create_alert(self.name) 43 | alert.add_condition_for('cpu').above(200) 44 | self.assertEqual(len(alert.conditions), 1) 45 | self.assertEqual(alert.conditions[0].condition_type, 'above') 46 | self.assertEqual(alert.conditions[0].metric_name, 'cpu') 47 | self.assertEqual(alert.conditions[0].threshold, 200) 48 | 49 | def test_create_alert_with_condition_obj(self): 50 | c1 = librato.alerts.Condition('cpu', 'web*').above(42) 51 | c2 = librato.alerts.Condition('mem').below(99) 52 | alert = self.conn.create_alert(self.name, conditions=[c1, c2]) 53 | self.assertEqual(len(alert.conditions), 2) 54 | self.assertEqual(alert.conditions[0].metric_name, 'cpu') 55 | self.assertEqual(alert.conditions[0].source, 'web*') 56 | self.assertEqual(alert.conditions[0].condition_type, 'above') 57 | self.assertEqual(alert.conditions[0].threshold, 42) 58 | self.assertEqual(alert.conditions[1].metric_name, 'mem') 59 | self.assertEqual(alert.conditions[1].source, '*') 60 | self.assertEqual(alert.conditions[1].condition_type, 'below') 61 | self.assertEqual(alert.conditions[1].threshold, 99) 62 | 63 | def test_deleting_an_alert(self): 64 | alert = self.conn.create_alert(self.name) 65 | # TODO: use requests directly instead of the client methods? 66 | assert len(list(self.conn.list_alerts())) == 1 67 | self.conn.delete_alert(self.name) 68 | assert len(list(self.conn.list_alerts())) == 0 69 | 70 | def test_deleting_an_inexistent_alert(self): 71 | self.conn.create_alert('say_my_name') 72 | self.conn.delete_alert('say_my_wrong_name') 73 | assert self.conn.get_alert('say_my_name') is not None 74 | 75 | def test_create_alert_with_service_id(self): 76 | alert = self.conn.create_alert(self.name) 77 | service_id = 1234 78 | alert.add_service(service_id) 79 | self.assertEqual(len(alert.services), 1) 80 | self.assertEqual(len(alert.conditions), 0) 81 | self.assertEqual(alert.services[0]._id, service_id) 82 | 83 | def test_create_alert_with_service_obj(self): 84 | service = librato.alerts.Service(1234) 85 | alert = self.conn.create_alert(self.name, services=[service]) 86 | self.assertEqual(len(alert.services), 1) 87 | self.assertEqual(len(alert.conditions), 0) 88 | self.assertEqual(alert.services[0]._id, service._id) 89 | 90 | def test_add_above_condition(self): 91 | alert = self.conn.create_alert(self.name) 92 | alert.add_condition_for('metric_test').above(200, 'average').duration(5) 93 | assert len(alert.conditions) == 1 94 | condition = alert.conditions[0] 95 | assert condition.condition_type == 'above' 96 | assert condition.metric_name == 'metric_test' 97 | assert condition.threshold == 200 98 | assert condition._duration == 5 99 | 100 | def test_add_below_condition(self): 101 | alert = self.conn.create_alert(self.name) 102 | alert.add_condition_for('metric_test').below(200, 'average').duration(5) 103 | assert len(alert.conditions) == 1 104 | condition = alert.conditions[0] 105 | assert condition.condition_type == 'below' 106 | assert condition.metric_name == 'metric_test' 107 | assert condition.threshold == 200 108 | assert condition._duration == 5 109 | 110 | def test_add_absent_condition(self): 111 | alert = self.conn.create_alert(self.name) 112 | alert.add_condition_for('metric_test').stops_reporting_for(5) 113 | assert len(alert.conditions) == 1 114 | condition = alert.conditions[0] 115 | assert condition.condition_type == 'absent' 116 | assert condition.metric_name == 'metric_test' 117 | assert condition._duration == 5 118 | 119 | def test_immediate_condition(self): 120 | cond = librato.alerts.Condition('foo') 121 | 122 | cond._duration = None 123 | assert cond.immediate() is True 124 | 125 | # Not even sure this is a valid case, but testing anyway 126 | cond._duration = 0 127 | assert cond.immediate() is True 128 | 129 | cond._duration = 60 130 | assert cond.immediate() is False 131 | 132 | 133 | class TestService(unittest.TestCase): 134 | def setUp(self): 135 | self.conn = librato.connect('user_test', 'key_test') 136 | self.sample_payload = { 137 | 'title': 'Email Ops', 138 | 'type': 'mail', 139 | 'settings': {'addresses': 'someone@example.com'} 140 | } 141 | server.clean() 142 | 143 | def test_list_services(self): 144 | services = list(self.conn.list_services()) 145 | self.assertEqual(len(services), 0) 146 | # Hack this into the server until we have a :create_service 147 | # method on the actual connection 148 | server.create_service(self.sample_payload) 149 | # id is 1 150 | self.assertEqual(server.services[1], self.sample_payload) 151 | services = list(self.conn.list_services()) 152 | self.assertEqual(len(services), 1) 153 | s = services[0] 154 | self.assertIsInstance(s, librato.alerts.Service) 155 | self.assertEqual(s.title, self.sample_payload['title']) 156 | self.assertEqual(s.type, self.sample_payload['type']) 157 | self.assertEqual(s.settings, self.sample_payload['settings']) 158 | 159 | def test_init_service(self): 160 | s = librato.alerts.Service(123, title='the title', type='mail', 161 | settings={'addresses': 'someone@example.com'}) 162 | self.assertEqual(s._id, 123) 163 | self.assertEqual(s.title, 'the title') 164 | self.assertEqual(s.type, 'mail') 165 | self.assertEqual(s.settings['addresses'], 'someone@example.com') 166 | 167 | def test_service_from_dict(self): 168 | payload = {'id': 123, 'title': 'the title', 'type': 'slack', 169 | 'settings': {'room': 'a room'}} 170 | s = librato.alerts.Service.from_dict(self.conn, payload) 171 | self.assertEqual(s._id, 123) 172 | self.assertEqual(s.title, payload['title']) 173 | self.assertEqual(s.type, payload['type']) 174 | self.assertEqual(s.settings['room'], payload['settings']['room']) 175 | 176 | 177 | if __name__ == '__main__': 178 | unittest.main() 179 | -------------------------------------------------------------------------------- /tests/test_annotations.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import unittest 3 | import librato 4 | from mock_connection import MockConnect, server 5 | 6 | # logging.basicConfig(level=logging.DEBUG) 7 | # Mock the server 8 | librato.HTTPSConnection = MockConnect 9 | 10 | 11 | class TestLibratoAnnotations(unittest.TestCase): 12 | def setUp(self): 13 | self.conn = librato.connect('user_test', 'key_test') 14 | server.clean() 15 | 16 | def test_get_annotation_stream(self): 17 | annotation_name = "My_Annotation" 18 | annotation_stream = self.conn.get_annotation_stream(annotation_name) 19 | assert type(annotation_stream) == librato.Annotation 20 | assert annotation_stream.name == annotation_name 21 | 22 | def test_get_payload(self): 23 | annotation = librato.Annotation(self.conn, 'My_Annotation', 'My_Annotation_Display') 24 | payload = annotation.get_payload() 25 | assert payload['name'] == 'My_Annotation' 26 | assert payload['display_name'] == 'My_Annotation_Display' 27 | 28 | def test_from_dict(self): 29 | data = {'name': 'My_Annotation', 'display_name': 'My_Annotation_Display', 'query': {}, 'events': {}} 30 | resp = librato.Annotation.from_dict(self.cls, data) 31 | assert resp.display_name == 'My_Annotation_Display' 32 | assert resp.name == 'My_Annotation' 33 | assert resp.query == {} 34 | assert resp.events == {} 35 | 36 | def cls(self, connection, data): 37 | return librato.Annotation(self.conn, '', '') 38 | 39 | if __name__ == '__main__': 40 | unittest.main() 41 | -------------------------------------------------------------------------------- /tests/test_charts.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import unittest 3 | import librato 4 | from librato import Space, Chart 5 | from librato.streams import Stream 6 | from mock_connection import MockConnect, server 7 | 8 | # logging.basicConfig(level=logging.DEBUG) 9 | # Mock the server 10 | librato.HTTPSConnection = MockConnect 11 | 12 | 13 | class ChartsTest(unittest.TestCase): 14 | def setUp(self): 15 | self.conn = librato.connect('user_test', 'key_test') 16 | server.clean() 17 | 18 | 19 | # Charts 20 | class TestChartsConnection(ChartsTest): 21 | def setUp(self): 22 | super(TestChartsConnection, self).setUp() 23 | self.space = self.conn.create_space("My Space") 24 | 25 | def test_create_chart(self): 26 | # Create a couple of metrics 27 | self.conn.submit('my.metric', 42) 28 | self.conn.submit('my.metric2', 43) 29 | # Create charts in the space 30 | chart_name = "Typical chart" 31 | chart = self.conn.create_chart( 32 | chart_name, 33 | self.space, 34 | streams=[ 35 | {'metric': 'my.metric', 'source': '*', 'summary_function': 'max'}, 36 | {'metric': 'my.metric2', 'source': 'foo', 'color': '#FFFFFF'} 37 | ] 38 | ) 39 | self.assertIsInstance(chart, Chart) 40 | self.assertIsNotNone(chart.id) 41 | self.assertEqual(chart.space_id, self.space.id) 42 | self.assertEqual(chart.name, chart_name) 43 | self.assertEqual(chart.streams[0].metric, 'my.metric') 44 | self.assertEqual(chart.streams[0].source, '*') 45 | self.assertEqual(chart.streams[0].summary_function, 'max') 46 | self.assertEqual(chart.streams[1].metric, 'my.metric2') 47 | self.assertEqual(chart.streams[1].source, 'foo') 48 | self.assertEqual(chart.streams[1].color, '#FFFFFF') 49 | 50 | def test_create_chart_without_streams(self): 51 | chart_name = "Empty Chart" 52 | chart = self.conn.create_chart(chart_name, self.space) 53 | self.assertIsInstance(chart, Chart) 54 | self.assertEqual(chart.name, chart_name) 55 | # Line by default 56 | self.assertEqual(chart.type, 'line') 57 | self.assertEqual(len(chart.streams), 0) 58 | 59 | def test_rename_chart(self): 60 | chart = self.conn.create_chart('CPU', self.space) 61 | chart.rename('CPU 2') 62 | self.assertEqual(chart.name, 'CPU 2') 63 | self.assertEqual(self.conn.get_chart(chart.id, self.space).name, 'CPU 2') 64 | 65 | def test_delete_chart(self): 66 | chart = self.conn.create_chart('cpu', self.space) 67 | self.conn.delete_chart(chart.id, self.space.id) 68 | self.assertEqual(len(self.conn.list_charts_in_space(self.space)), 0) 69 | 70 | def test_add_stream_to_chart(self): 71 | chart = self.conn.create_chart("Chart with no streams", self.space) 72 | metric_name = 'my.metric' 73 | self.conn.submit(metric_name, 42, description='metric description') 74 | chart.new_stream(metric=metric_name) 75 | chart.save() 76 | self.assertEqual(len(chart.streams), 1) 77 | stream = chart.streams[0] 78 | self.assertEqual(stream.metric, metric_name) 79 | self.assertIsNone(stream.composite) 80 | 81 | def test_get_chart_from_space(self): 82 | chart = self.conn.create_chart('cpu', self.space) 83 | found = self.conn.get_chart(chart.id, self.space) 84 | self.assertEqual(found.id, chart.id) 85 | self.assertEqual(found.name, chart.name) 86 | 87 | def test_get_chart_from_space_id(self): 88 | chart = self.conn.create_chart('cpu', self.space) 89 | found = self.conn.get_chart(chart.id, self.space.id) 90 | self.assertEqual(found.id, chart.id) 91 | self.assertEqual(found.name, chart.name) 92 | 93 | def test_find_chart_by_name(self): 94 | chart = self.conn.create_chart('cpu', self.space) 95 | found = self.conn.find_chart('cpu', self.space) 96 | self.assertEqual(found.name, 'cpu') 97 | 98 | 99 | class TestChartModel(ChartsTest): 100 | def setUp(self): 101 | super(TestChartModel, self).setUp() 102 | self.space = self.conn.create_space('My Space') 103 | 104 | def test_init_connection(self): 105 | self.assertEqual(Chart(self.conn).connection, self.conn) 106 | 107 | def test_init_name(self): 108 | self.assertIsNone(Chart(self.conn).name) 109 | self.assertEqual(Chart(self.conn, 'cpu').name, 'cpu') 110 | 111 | def test_init_chart_type(self): 112 | # Debated `chart_type` vs `type`, going with `type` 113 | self.assertEqual(Chart(self.conn, type='line').type, 'line') 114 | self.assertEqual(Chart(self.conn, type='stacked').type, 'stacked') 115 | self.assertEqual(Chart(self.conn, type='bignumber').type, 'bignumber') 116 | 117 | def test_init_space_id(self): 118 | self.assertEqual(Chart(self.conn, space_id=42).space_id, 42) 119 | 120 | def test_space_attribute(self): 121 | chart = Chart(self.conn) 122 | chart._space = self.space 123 | self.assertEqual(chart._space, self.space) 124 | 125 | def test_init_streams(self): 126 | self.assertEqual(Chart(self.conn).streams, []) 127 | 128 | s = [Stream('my.metric'), Stream('other.metric')] 129 | chart = Chart(self.conn, streams=s) 130 | self.assertEqual(chart.streams, s) 131 | 132 | def test_init_streams_dict(self): 133 | streams_dict = [ 134 | {'metric': 'my.metric', 'source': 'blah', 'composite': None}, 135 | {'metric': 'other.metric', 'source': '*', 'composite': None} 136 | ] 137 | chart = Chart(self.conn, streams=streams_dict) 138 | self.assertEqual(chart.streams[0].metric, streams_dict[0]['metric']) 139 | self.assertEqual(chart.streams[0].source, streams_dict[0]['source']) 140 | self.assertEqual(chart.streams[0].composite, streams_dict[0]['composite']) 141 | self.assertEqual(chart.streams[1].metric, streams_dict[1]['metric']) 142 | self.assertEqual(chart.streams[1].source, streams_dict[1]['source']) 143 | self.assertEqual(chart.streams[1].composite, streams_dict[1]['composite']) 144 | 145 | def test_init_streams_list(self): 146 | streams_list = [['my.metric', '*', None]] 147 | chart = Chart(self.conn, streams=streams_list) 148 | self.assertEqual(chart.streams[0].metric, streams_list[0][0]) 149 | 150 | def test_init_streams_group_functions(self): 151 | streams_dict = [ 152 | {'metric': 'my.metric', 'source': '*', 153 | 'group_function': 'sum', 'summary_function': 'max'} 154 | ] 155 | chart = Chart(self.conn, streams=streams_dict) 156 | stream = chart.streams[0] 157 | self.assertEqual(stream.group_function, 'sum') 158 | self.assertEqual(stream.summary_function, 'max') 159 | 160 | def test_init_min_max(self): 161 | chart = Chart(self.conn, min=-42, max=100) 162 | self.assertEqual(chart.min, -42) 163 | self.assertEqual(chart.max, 100) 164 | 165 | def test_init_label(self): 166 | chart = Chart(self.conn, label='I heart charts') 167 | self.assertEqual(chart.label, 'I heart charts') 168 | 169 | def test_init_use_log_yaxis(self): 170 | chart = Chart(self.conn, use_log_yaxis=True) 171 | self.assertTrue(chart.use_log_yaxis) 172 | 173 | def test_save_chart(self): 174 | chart = Chart(self.conn, 'test', space_id=self.space.id) 175 | self.assertFalse(chart.persisted()) 176 | self.assertIsNone(chart.id) 177 | resp = chart.save() 178 | self.assertIsInstance(resp, Chart) 179 | self.assertTrue(chart.persisted()) 180 | self.assertIsNotNone(chart.id) 181 | self.assertEqual(chart.type, 'line') 182 | 183 | def test_save_persists_type(self): 184 | # Ensure that type gets passed in the payload 185 | for t in ['stacked', 'bignumber']: 186 | chart = Chart(self.conn, space_id=self.space.id, type=t) 187 | chart.save() 188 | found = self.conn.get_chart(chart.id, self.space.id) 189 | self.assertEqual(found.type, t) 190 | 191 | def test_save_persists_min_max(self): 192 | chart = Chart(self.conn, space_id=self.space.id) 193 | self.assertIsNone(chart.min) 194 | self.assertIsNone(chart.max) 195 | chart.min = 5 196 | chart.max = 30 197 | chart.save() 198 | found = self.conn.get_chart(chart.id, self.space.id) 199 | self.assertEqual(found.min, 5) 200 | self.assertEqual(found.max, 30) 201 | 202 | def test_save_persists_label(self): 203 | chart = Chart(self.conn, space_id=self.space.id) 204 | self.assertIsNone(chart.label) 205 | chart.label = 'my label' 206 | chart.save() 207 | found = self.conn.get_chart(chart.id, self.space.id) 208 | self.assertEqual(found.label, 'my label') 209 | 210 | def test_save_persists_log_y_axis(self): 211 | chart = Chart(self.conn, space_id=self.space.id) 212 | self.assertIsNone(chart.use_log_yaxis) 213 | chart.use_log_yaxis = True 214 | chart.save() 215 | found = self.conn.get_chart(chart.id, self.space.id) 216 | self.assertTrue(found.use_log_yaxis) 217 | 218 | def test_save_persists_use_last_value(self): 219 | chart = Chart(self.conn, space_id=self.space.id) 220 | self.assertIsNone(chart.use_last_value) 221 | chart.use_last_value = True 222 | chart.save() 223 | found = self.conn.get_chart(chart.id, self.space.id) 224 | self.assertTrue(found.use_last_value) 225 | 226 | def test_save_persists_related_space(self): 227 | chart = Chart(self.conn, space_id=self.space.id) 228 | self.assertIsNone(chart.related_space) 229 | chart.related_space = 1234 230 | chart.save() 231 | found = self.conn.get_chart(chart.id, self.space.id) 232 | self.assertTrue(found.related_space) 233 | 234 | def test_chart_is_not_persisted(self): 235 | chart = Chart('not saved', self.space) 236 | self.assertFalse(chart.persisted()) 237 | 238 | def test_chart_is_persisted_if_id_present(self): 239 | chart = Chart(self.conn, 'test', id=42) 240 | self.assertTrue(chart.persisted()) 241 | chart = Chart(self.conn, 'test', id=None) 242 | self.assertFalse(chart.persisted()) 243 | 244 | def test_get_space_from_chart(self): 245 | chart = Chart(self.conn, space_id=self.space.id) 246 | space = chart.space() 247 | self.assertIsInstance(space, Space) 248 | self.assertEqual(space.id, self.space.id) 249 | 250 | def test_new_stream_defaults(self): 251 | chart = Chart(self.conn, 'test') 252 | self.assertEqual(len(chart.streams), 0) 253 | stream = chart.new_stream('my.metric') 254 | self.assertIsInstance(stream, Stream) 255 | self.assertEqual(stream.metric, 'my.metric') 256 | self.assertEqual(stream.source, '*') 257 | self.assertEqual(stream.composite, None) 258 | # Appends to chart streams 259 | self.assertEqual(len(chart.streams), 1) 260 | self.assertEqual(chart.streams[0].metric, 'my.metric') 261 | # Another way to do the same thing 262 | stream = chart.new_stream(metric='my.metric') 263 | self.assertEqual(stream.metric, 'my.metric') 264 | 265 | def test_new_stream_with_source(self): 266 | chart = Chart(self.conn, 'test') 267 | stream = chart.new_stream('my.metric', 'prod*') 268 | self.assertEqual(stream.metric, 'my.metric') 269 | self.assertEqual(stream.source, 'prod*') 270 | self.assertEqual(stream.composite, None) 271 | stream = chart.new_stream(metric='my.metric', source='prod*') 272 | self.assertEqual(stream.metric, 'my.metric') 273 | self.assertEqual(stream.source, 'prod*') 274 | self.assertEqual(stream.composite, None) 275 | 276 | def test_new_stream_with_composite(self): 277 | chart = Chart(self.conn) 278 | composite_formula = 's("my.metric", "*")' 279 | stream = chart.new_stream(composite=composite_formula) 280 | self.assertIsNone(stream.metric) 281 | self.assertIsNone(stream.source) 282 | self.assertEqual(stream.composite, composite_formula) 283 | 284 | def test_get_payload(self): 285 | chart = Chart(self.conn) 286 | payload = chart.get_payload() 287 | self.assertEqual(payload['name'], chart.name) 288 | self.assertEqual(payload['type'], chart.type) 289 | self.assertEqual(payload['streams'], chart.streams) 290 | 291 | def test_get_payload_bignumber(self): 292 | streams = [{'metric': 'my.metric', 'source': '*'}] 293 | chart = Chart(self.conn, type='bignumber', streams=streams, 294 | use_last_value=False) 295 | payload = chart.get_payload() 296 | self.assertEqual(payload['name'], chart.name) 297 | self.assertEqual(payload['type'], chart.type) 298 | self.assertEqual(payload['streams'], streams) 299 | self.assertEqual(payload['use_last_value'], chart.use_last_value) 300 | 301 | def test_streams_payload(self): 302 | streams_payload = [ 303 | {'metric': 'some.metric', 'source': None, 'composite': None}, 304 | {'metric': None, 'source': None, 'composite': 's("other.metric", "sf", {function: "sum"})'} 305 | ] 306 | chart = Chart(self.conn, streams=streams_payload) 307 | self.assertEqual(chart.streams_payload()[0]['metric'], streams_payload[0]['metric']) 308 | 309 | def test_get_payload_with_streams_dict(self): 310 | streams_payload = [ 311 | {'metric': 'some.metric', 'source': None, 'composite': None}, 312 | {'metric': 'another.metric', 'source': None, 'composite': None} 313 | ] 314 | chart = Chart(self.conn, type='bignumber', space_id=42, streams=streams_payload) 315 | chart_payload = chart.get_payload() 316 | self.assertEqual(chart_payload['streams'][0]['metric'], streams_payload[0]['metric']) 317 | self.assertEqual(chart_payload['streams'][1]['metric'], streams_payload[1]['metric']) 318 | 319 | def test_delete_chart(self): 320 | chart = self.conn.create_chart('cpu', self.space) 321 | chart.delete() 322 | self.assertEqual(self.space.charts(), []) 323 | 324 | 325 | if __name__ == '__main__': 326 | unittest.main() 327 | -------------------------------------------------------------------------------- /tests/test_connection.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import unittest 3 | try: 4 | from unittest.mock import patch 5 | except ImportError: 6 | from mock import patch 7 | import librato 8 | from mock_connection import MockConnect, server 9 | 10 | # logging.basicConfig(level=logging.DEBUG) 11 | # Mock the server 12 | librato.HTTPSConnection = MockConnect 13 | 14 | 15 | class TestConnection(unittest.TestCase): 16 | def setUp(self): 17 | self.conn = librato.connect('user_test', 'key_test', tags={'sky': 'blue'}) 18 | server.clean() 19 | 20 | def test_constructor_tags(self): 21 | tags = self.conn.get_tags() 22 | assert len(tags) == 1 23 | assert 'sky' in tags 24 | assert tags['sky'] == 'blue' 25 | 26 | def test_add_tags(self): 27 | self.conn.add_tags({'sky': 'red', 'coal': 'black'}) 28 | tags = self.conn.get_tags() 29 | 30 | assert len(tags) == 2 31 | assert 'sky' in tags 32 | assert tags['sky'] == 'red' 33 | 34 | assert 'coal' in tags 35 | assert tags['coal'] == 'black' 36 | 37 | def test_set_tags(self): 38 | self.conn.set_tags({'coal': 'black'}) 39 | tags = self.conn.get_tags() 40 | 41 | assert len(tags) == 1 42 | assert 'coal' in tags 43 | assert tags['coal'] == 'black' 44 | 45 | def test_custom_ua(self): 46 | self.conn.custom_ua = 'foo' 47 | assert self.conn._compute_ua() == 'foo' 48 | 49 | if __name__ == '__main__': 50 | unittest.main() 51 | -------------------------------------------------------------------------------- /tests/test_exceptions.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import unittest 3 | from librato import exceptions 4 | 5 | 6 | class TestClientError(unittest.TestCase): 7 | def setUp(self): 8 | pass 9 | 10 | def test_init(self): 11 | ex = exceptions.ClientError(123, "Bad request etc") 12 | self.assertIsInstance(ex, Exception) 13 | self.assertEqual(123, ex.code) 14 | # Sets message on Exception 15 | self.assertEqual(str(ex), ex.error_message()) 16 | 17 | def test_parse_error_message_standard(self): 18 | ex = exceptions.ClientError(400, "a standard message") 19 | self.assertEqual("a standard message", ex._parse_error_message()) 20 | 21 | def test_parse_error_message_request_scalar(self): 22 | error_resp = { 23 | "errors": { 24 | "request": "test error message" 25 | } 26 | } 27 | ex = exceptions.ClientError(400, error_resp) 28 | self.assertEqual("request: test error message", ex._parse_error_message()) 29 | 30 | def test_parse_error_message_request(self): 31 | error_resp = { 32 | "errors": { 33 | "request": ["Not found"] 34 | } 35 | } 36 | ex = exceptions.ClientError(400, error_resp) 37 | self.assertEqual("request: Not found", ex._parse_error_message()) 38 | 39 | def test_parse_error_message_request_multiple(self): 40 | error_resp = { 41 | "errors": { 42 | "request": ["Not found", "Another error"] 43 | } 44 | } 45 | ex = exceptions.ClientError(400, error_resp) 46 | self.assertEqual("request: Not found, request: Another error", ex._parse_error_message()) 47 | 48 | def test_parse_error_message_params(self): 49 | error_resp = { 50 | "errors": { 51 | "params": {"measure_time": ["too far in past"]} 52 | } 53 | } 54 | ex = exceptions.ClientError(400, error_resp) 55 | self.assertEqual("params: measure_time: too far in past", ex._parse_error_message()) 56 | 57 | def test_parse_error_message_params_multi_message(self): 58 | error_resp = { 59 | "errors": { 60 | "params": {"name": ["duplicate etc", "bad character etc"]} 61 | } 62 | } 63 | ex = exceptions.ClientError(400, error_resp) 64 | self.assertEqual("params: name: duplicate etc, bad character etc", ex._parse_error_message()) 65 | 66 | def test_parse_error_message_params_multiple(self): 67 | error_resp = { 68 | "errors": { 69 | "params": { 70 | "measure_time": ["too far in past"], 71 | "name": "mymetricname" 72 | } 73 | } 74 | } 75 | ex = exceptions.ClientError(400, error_resp) 76 | msg = ex._parse_error_message() 77 | self.assertRegexpMatches(msg, "params: measure_time: too far in past") 78 | self.assertRegexpMatches(msg, "params: name: mymetricname") 79 | 80 | def test_parse_error_message_params_multiple_2nd_level(self): 81 | error_resp = { 82 | "errors": { 83 | "params": { 84 | "conditions": { 85 | "duration": ["must be set"] 86 | } 87 | } 88 | } 89 | } 90 | ex = exceptions.ClientError(400, error_resp) 91 | msg = ex._parse_error_message() 92 | self.assertEqual(msg, 'params: conditions: duration: must be set') 93 | 94 | def test_error_message(self): 95 | ex = exceptions.ClientError(400, "Standard message") 96 | self.assertEqual("[400] Standard message", ex.error_message()) 97 | ex = exceptions.ClientError(400, {"errors": {"request": ["Not found"]}}) 98 | self.assertEqual("[400] request: Not found", ex.error_message()) 99 | 100 | def test_error_vs_errors(self): 101 | msg = "You have hit the rate limit, etc" 102 | # The API actually returns 'errors' in most cases, but for rate 103 | # limiting we get 'error' (singular) 104 | error_resp = {'request_time': 1467306906, 'error': msg} 105 | ex = exceptions.ClientError(403, error_resp) 106 | parsed_msg = ex._parse_error_message() 107 | self.assertEqual(msg, parsed_msg) 108 | 109 | 110 | if __name__ == '__main__': 111 | unittest.main() 112 | -------------------------------------------------------------------------------- /tests/test_metrics.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import unittest 3 | try: 4 | from unittest.mock import patch 5 | except ImportError: 6 | from mock import patch 7 | import librato 8 | import time 9 | from mock_connection import MockConnect, server 10 | 11 | # logging.basicConfig(level=logging.DEBUG) 12 | # Mock the server 13 | librato.HTTPSConnection = MockConnect 14 | 15 | fake_metric = { 16 | "name": "3333", 17 | "display_name": "test name", 18 | "type": "gauge", 19 | "attributes": { 20 | "created_by_ua": "fake", 21 | }, 22 | "description": "a description", 23 | "period": 60, 24 | "source_lag": 60 25 | } 26 | 27 | 28 | class TestLibrato(unittest.TestCase): 29 | def setUp(self): 30 | self.conn = librato.connect('user_test', 'key_test') 31 | server.clean() 32 | 33 | def test_list_metrics_when_there_are_no_metrics(self): 34 | metrics = self.conn.list_metrics() 35 | assert len(metrics) == 0 36 | 37 | def test_list_all_metrics(self): 38 | def mock_list(entity, query_props=None): 39 | length = query_props['length'] 40 | offset = query_props['offset'] 41 | # I don't care what the metrics are 42 | # this is about testing the logic and the calls 43 | result = [fake_metric for x in range(12)] 44 | return { 45 | "query": 46 | { 47 | "offset": offset, 48 | "length": length, 49 | "found": 12, 50 | "total": 12 51 | }, 52 | "metrics": result[offset:length + offset] 53 | } 54 | 55 | with patch.object( 56 | self.conn, 57 | '_mexe', 58 | ) as list_prop: 59 | list_prop.side_effect = mock_list 60 | metrics = list(self.conn.list_all_metrics(length=5, offset=0)) 61 | assert len(metrics) == 12 62 | assert list_prop.call_count == 3 63 | 64 | def test_list_metrics_adding_gauge(self): 65 | """ Notice that the api forces you to send a value even when you are 66 | just trying to create the metric without measurements.""" 67 | self.conn.submit('gauge_1', 1, description='desc 1') 68 | self.conn.submit('gauge_2', 2, description='desc 2') 69 | # Get all metrics 70 | metrics = self.conn.list_metrics() 71 | 72 | assert len(metrics) == 2 73 | assert isinstance(metrics[0], librato.metrics.Gauge) 74 | assert metrics[0].name == 'gauge_1' 75 | assert metrics[0].description == 'desc 1' 76 | 77 | assert isinstance(metrics[1], librato.metrics.Gauge) 78 | assert metrics[1].name == 'gauge_2' 79 | assert metrics[1].description == 'desc 2' 80 | 81 | def test_list_metrics_adding_counter_metrics(self): 82 | self.conn.submit('c1', 10, 'counter', description='counter desc 1') 83 | self.conn.submit('c2', 20, 'counter', description='counter desc 2') 84 | # Get all metrics 85 | metrics = self.conn.list_metrics() 86 | 87 | assert len(metrics) == 2 88 | 89 | assert isinstance(metrics[0], librato.metrics.Counter) 90 | assert metrics[0].name == 'c1' 91 | assert metrics[0].description == 'counter desc 1' 92 | 93 | assert isinstance(metrics[1], librato.metrics.Counter) 94 | assert metrics[1].name == 'c2' 95 | assert metrics[1].description == 'counter desc 2' 96 | 97 | def test_list_metrics_adding_one_counter_one_gauge(self): 98 | self.conn.submit('gauge1', 10) 99 | self.conn.submit('counter2', 20, type='counter', description="desc c2") 100 | # Get all metrics 101 | metrics = self.conn.list_metrics() 102 | assert isinstance(metrics[0], librato.metrics.Gauge) 103 | assert metrics[0].name == 'gauge1' 104 | 105 | assert isinstance(metrics[1], librato.metrics.Counter) 106 | assert metrics[1].name == 'counter2' 107 | assert metrics[1].description == 'desc c2' 108 | 109 | def test_deleting_a_gauge(self): 110 | self.conn.submit('test', 100) 111 | assert len(self.conn.list_metrics()) == 1 112 | self.conn.delete('test') 113 | assert len(self.conn.list_metrics()) == 0 114 | 115 | def test_deleting_a_batch_of_gauges(self): 116 | self.conn.submit('test', 100) 117 | self.conn.submit('test2', 100) 118 | assert len(self.conn.list_metrics()) == 2 119 | self.conn.delete(['test', 'test2']) 120 | assert len(self.conn.list_metrics()) == 0 121 | 122 | def test_deleting_a_counter(self): 123 | self.conn.submit('test', 200, type='counter') 124 | assert len(self.conn.list_metrics()) == 1 125 | self.conn.delete('test') 126 | assert len(self.conn.list_metrics()) == 0 127 | 128 | def test_get_gauge_basic(self): 129 | name, desc = '1', 'desc 1' 130 | self.conn.submit(name, 10, description=desc) 131 | gauge = self.conn.get(name) 132 | assert isinstance(gauge, librato.metrics.Gauge) 133 | assert gauge.name == name 134 | assert gauge.description == desc 135 | assert len(gauge.measurements['unassigned']) == 1 136 | assert gauge.measurements['unassigned'][0]['value'] == 10 137 | 138 | def test_get_counter_basic(self): 139 | name, desc = 'counter1', 'count desc 1' 140 | self.conn.submit(name, 20, type='counter', description=desc) 141 | counter = self.conn.get(name) 142 | assert isinstance(counter, librato.metrics.Counter) 143 | assert counter.name == name 144 | assert counter.description == desc 145 | assert len(counter.measurements['unassigned']) == 1 146 | assert counter.measurements['unassigned'][0]['value'] == 20 147 | 148 | def test_send_single_measurements_for_gauge_with_source(self): 149 | name, desc, src = 'Test', 'A Test Gauge.', 'from_source' 150 | self.conn.submit(name, 10, description=desc, source=src) 151 | gauge = self.conn.get(name) 152 | assert gauge.name == name 153 | assert gauge.description == desc 154 | assert len(gauge.measurements[src]) == 1 155 | assert gauge.measurements[src][0]['value'] == 10 156 | 157 | def test_send_single_measurements_for_counter_with_source(self): 158 | name, desc, src = 'Test', 'A Test Counter.', 'from_source' 159 | self.conn.submit(name, 111, type='counter', description=desc, source=src) 160 | counter = self.conn.get(name) 161 | assert counter.name == name 162 | assert counter.description == desc 163 | assert len(counter.measurements[src]) == 1 164 | assert counter.measurements[src][0]['value'] == 111 165 | 166 | def test_add_in_counter(self): 167 | name, desc, src = 'Test', 'A Test Counter.', 'from_source' 168 | self.conn.submit(name, 111, type='counter', description=desc, source=src) 169 | counter = self.conn.get(name) 170 | assert counter.name == name 171 | assert counter.description == desc 172 | assert len(counter.measurements[src]) == 1 173 | assert counter.measurements[src][0]['value'] == 111 174 | 175 | counter.add(1, source=src) 176 | 177 | counter = self.conn.get(name) 178 | assert counter.name == name 179 | assert counter.description == desc 180 | assert len(counter.measurements[src]) == 2 181 | assert counter.measurements[src][-1]['value'] == 1 182 | 183 | def test_add_in_gauge(self): 184 | name, desc, src = 'Test', 'A Test Gauge.', 'from_source' 185 | self.conn.submit(name, 10, description=desc, source=src) 186 | gauge = self.conn.get(name) 187 | assert gauge.name == name 188 | assert gauge.description == desc 189 | assert len(gauge.measurements[src]) == 1 190 | assert gauge.measurements[src][0]['value'] == 10 191 | 192 | gauge.add(1, source=src) 193 | 194 | gauge = self.conn.get(name) 195 | assert gauge.name == name 196 | assert gauge.description == desc 197 | assert len(gauge.measurements[src]) == 2 198 | assert gauge.measurements[src][-1]['value'] == 1 199 | 200 | def test_md_inherit_tags(self): 201 | self.conn.set_tags({'company': 'Librato', 'hi': 'four'}) 202 | 203 | measurement = self.conn.create_tagged_payload('user_cpu', 20.2, tags={'hi': 'five'}, inherit_tags=True) 204 | 205 | assert measurement['tags'] == {'hi': 'five', 'company': 'Librato'} 206 | 207 | def test_md_donot_inherit_tags(self): 208 | self.conn.set_tags({'company': 'Librato', 'hi': 'four'}) 209 | 210 | measurement = self.conn.create_tagged_payload('user_cpu', 20.2, tags={'hi': 'five'}) 211 | 212 | assert measurement['tags'] == {'hi': 'five'} 213 | 214 | def test_md_submit(self): 215 | mt1 = int(time.time()) - 5 216 | 217 | tags = {'hostname': 'web-1'} 218 | self.conn.submit_tagged('user_cpu', 20.2, time=mt1, tags=tags) 219 | 220 | resp = self.conn.get_tagged('user_cpu', duration=60, tags_search="hostname=web-1") 221 | assert len(resp['series']) == 1 222 | assert resp['series'][0].get('tags', {}) == tags 223 | 224 | # Same query using tags param instead 225 | resp = self.conn.get_tagged('user_cpu', duration=60, tags={'hostname': 'web-1'}) 226 | assert len(resp['series']) == 1 227 | assert resp['series'][0].get('tags', {}) == tags 228 | 229 | measurements = resp['series'][0]['measurements'] 230 | assert len(measurements) == 1 231 | 232 | assert measurements[0]['time'] == mt1 233 | assert measurements[0]['value'] == 20.2 234 | 235 | def test_merge_tags(self): 236 | mt1 = int(time.time()) - 5 237 | 238 | self.conn.set_tags({'company': 'Librato'}) 239 | tags = {'hostname': 'web-1'} 240 | self.conn.submit_tagged('user_cpu', 20.2, time=mt1, tags=tags, inherit_tags=True) 241 | 242 | # Ensure 'company' and 'hostname' tags made it through 243 | for tags_search in ["hostname=web-1", "company=Librato"]: 244 | resp = self.conn.get_tagged('user_cpu', duration=60, tags_search=tags_search) 245 | 246 | assert len(resp['series']) == 1 247 | 248 | measurements = resp['series'][0]['measurements'] 249 | assert len(measurements) == 1 250 | 251 | assert measurements[0]['time'] == mt1 252 | assert measurements[0]['value'] == 20.2 253 | 254 | def test_submit_transparent_tagging(self): 255 | mt1 = int(time.time()) - 5 256 | 257 | tags = {'hostname': 'web-1'} 258 | self.conn.submit('user_cpu', 20.2, time=mt1, tags=tags) 259 | 260 | resp = self.conn.get_tagged('user_cpu', duration=60, tags_search="hostname=web-1") 261 | 262 | assert len(resp['series']) == 1 263 | assert resp['series'][0].get('tags', {}) == tags 264 | 265 | measurements = resp['series'][0]['measurements'] 266 | assert len(measurements) == 1 267 | 268 | assert measurements[0]['time'] == mt1 269 | assert measurements[0]['value'] == 20.2 270 | 271 | if __name__ == '__main__': 272 | unittest.main() 273 | -------------------------------------------------------------------------------- /tests/test_param_url_encoding.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import unittest 3 | import librato 4 | 5 | 6 | class TestLibratoUrlEncoding(unittest.TestCase): 7 | 8 | def setUp(self): 9 | self.conn = librato.connect('user_test', 'key_test') 10 | 11 | def test_string_encoding(self): 12 | params = {"name": "abcd"} 13 | assert self.conn._url_encode_params(params) == 'name=abcd' 14 | 15 | def test_list_encoding(self): 16 | params = {"sources": ['a', 'b']} 17 | assert self.conn._url_encode_params(params) == 'sources%5B%5D=a&sources%5B%5D=b' 18 | 19 | def test_empty_encoding(self): 20 | params = {} 21 | assert self.conn._url_encode_params(params) == '' 22 | 23 | if __name__ == '__main__': 24 | unittest.main() 25 | -------------------------------------------------------------------------------- /tests/test_process_response.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import unittest 3 | try: 4 | from unittest.mock import create_autospec, PropertyMock 5 | except ImportError: 6 | from mock import create_autospec, PropertyMock 7 | import librato 8 | from mock_connection import MockConnect, server 9 | from six.moves.http_client import HTTPResponse 10 | 11 | # logging.basicConfig(level=logging.DEBUG) 12 | # Mock the server 13 | librato.HTTPSConnection = MockConnect 14 | 15 | 16 | class TestLibrato(unittest.TestCase): 17 | def setUp(self): 18 | self.conn = librato.connect('user_test', 'key_test') 19 | server.clean() 20 | 21 | def test_get_authentication_failure(self): 22 | """ 23 | fails with Unauthorized on 401 during GET 24 | """ 25 | mock_response = create_autospec(HTTPResponse, spec_set=True, instance=True) 26 | mock_response.mock_add_spec(['status'], spec_set=True) 27 | mock_response.status = 401 28 | # GET 401 responds with JSON 29 | mock_response.getheader.return_value = "application/json;charset=utf-8" 30 | mock_response.read.return_value = '{"errors":{"request":["Authorization Required"]}}'.encode('utf-8') 31 | 32 | with self.assertRaises(librato.exceptions.Unauthorized): 33 | self.conn._process_response(mock_response, 1) 34 | 35 | def test_post_authentication_failure(self): 36 | """ 37 | fails with Unauthorized on 401 during POST 38 | """ 39 | mock_response = create_autospec(HTTPResponse, spec_set=True, instance=True) 40 | mock_response.mock_add_spec(['status'], spec_set=True) 41 | mock_response.status = 401 42 | # POST 401 responds with text 43 | mock_response.getheader.return_value = "text/plain" 44 | mock_response.read.return_value = 'Credentials are required to access this resource.'.encode('utf-8') 45 | 46 | with self.assertRaises(librato.exceptions.Unauthorized): 47 | self.conn._process_response(mock_response, 1) 48 | -------------------------------------------------------------------------------- /tests/test_queue.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import unittest 3 | import librato 4 | from librato.aggregator import Aggregator 5 | from mock_connection import MockConnect, server 6 | from random import randint 7 | import time 8 | 9 | # logging.basicConfig(level=logging.DEBUG) 10 | librato.HTTPSConnection = MockConnect 11 | 12 | 13 | class TestLibratoQueue(unittest.TestCase): 14 | def setUp(self): 15 | self.conn = librato.connect('user_test', 'key_test') 16 | server.clean() 17 | self.q = self.conn.new_queue() 18 | 19 | def test_empty_queue(self): 20 | q = self.q 21 | assert len(q.chunks) == 0 22 | assert q._num_measurements_in_current_chunk() == 0 23 | 24 | def test_no_tags(self): 25 | q = self.q 26 | assert len(q.get_tags()) == 0 27 | 28 | def test_inherited_tags(self): 29 | conn = librato.connect('user_test', 'key_test', tags={'sky': 'blue'}) 30 | assert conn.get_tags() == {'sky': 'blue'} 31 | 32 | q = conn.new_queue() 33 | q.add_tagged('user_cpu', 10) 34 | q.submit() 35 | 36 | # Measurement must inherit 'sky' tag from connection 37 | resp = self.conn.get_tagged('user_cpu', duration=60, tags_search="sky=blue") 38 | 39 | assert len(resp['series']) == 1 40 | assert resp['series'][0].get('tags', {}) == conn.get_tags() 41 | 42 | measurements = resp['series'][0]['measurements'] 43 | assert len(measurements) == 1 44 | assert measurements[0]['value'] == 10 45 | 46 | def test_inherit_connection_level_tags(self): 47 | """test if top level tags are ignored when passing measurement level tags""" 48 | conn = librato.connect('user_test', 'key_test', tags={'sky': 'blue'}) 49 | 50 | q = conn.new_queue() 51 | q.add_tagged('user_cpu', 10, tags={"hi": "five"}, inherit_tags=True) 52 | 53 | measurements = q.tagged_chunks[0]['measurements'] 54 | 55 | assert len(measurements) == 1 56 | assert measurements[0].get('tags', {}) == {'sky': 'blue', 'hi': 'five'} 57 | 58 | def test_ignore_connection_queue_level_tags(self): 59 | """test if queue level tags are ignored when passing measurement level tags""" 60 | conn = librato.connect('user_test', 'key_test', tags={'sky': 'blue'}) 61 | 62 | q = conn.new_queue(tags={"service": "api"}) 63 | q.add_tagged('user_cpu', 10, tags={"hi": "five"}) 64 | measurements = q.tagged_chunks[0]['measurements'] 65 | 66 | assert len(measurements) == 1 67 | assert measurements[0].get('tags', {}) == {'hi': 'five'} 68 | 69 | q.submit() 70 | 71 | resp = self.conn.get_tagged('user_cpu', duration=60, tags_search="sky=blue") 72 | assert len(resp['series']) == 0 73 | 74 | def test_inherit_connection_level_tags_through_add(self): 75 | """test if connection level tags are recognized when using the add function""" 76 | conn = librato.connect('user_test', 'key_test', tags={'sky': 'blue', 'company': 'Librato'}) 77 | 78 | q = conn.new_queue() 79 | q.add('user_cpu', 100) 80 | measurements = q.tagged_chunks[0]['measurements'] 81 | 82 | assert len(measurements) == 1 83 | assert measurements[0].get('tags', {}) == {'sky': 'blue', 'company': 'Librato'} 84 | 85 | def test_inherit_queue_connection_level_tags(self): 86 | """test if queue level tags are ignored when passing measurement level tags""" 87 | conn = librato.connect('user_test', 'key_test', tags={'sky': 'blue', 'company': 'Librato'}) 88 | 89 | q = conn.new_queue(tags={"service": "api", "hi": "four", "sky": "red"}) 90 | q.add_tagged('user_cpu', 100, tags={"hi": "five"}, inherit_tags=True) 91 | measurements = q.tagged_chunks[0]['measurements'] 92 | 93 | assert len(measurements) == 1 94 | assert measurements[0].get('tags', {}) == {'sky': 'red', 'service': 'api', 'hi': 'five', 'company': 'Librato'} 95 | 96 | def test_inherit_queue_level_tags(self): 97 | """test if queue level tags are ignored when passing measurement level tags""" 98 | conn = librato.connect('user_test', 'key_test') 99 | 100 | q = conn.new_queue(tags={"service": "api", "hi": "four"}) 101 | q.add_tagged('user_cpu', 100, tags={"hi": "five"}, inherit_tags=True) 102 | measurements = q.tagged_chunks[0]['measurements'] 103 | 104 | assert len(measurements) == 1 105 | assert measurements[0].get('tags', {}) == {'service': 'api', 'hi': 'five'} 106 | 107 | def test_constructor_tags(self): 108 | conn = librato.connect('user_test', 'key_test', tags={'sky': 'blue'}) 109 | q = conn.new_queue(tags={'sky': 'red', 'coal': 'black'}) 110 | tags = q.get_tags() 111 | 112 | assert len(tags) == 2 113 | assert 'sky' in tags 114 | assert tags['sky'] == 'red' 115 | assert 'coal' in tags 116 | assert tags['coal'] == 'black' 117 | 118 | def test_add_tags(self): 119 | q = self.q 120 | q.add_tags({'mercury': 'silver'}) 121 | tags = q.get_tags() 122 | 123 | assert len(tags) == 1 124 | assert 'mercury' in tags 125 | assert tags['mercury'] == 'silver' 126 | 127 | def test_set_tags(self): 128 | q = self.q 129 | q.add_tags({'mercury': 'silver'}) 130 | 131 | q.set_tags({'sky': 'blue', 'mercury': 'silver'}) 132 | tags = q.get_tags() 133 | 134 | assert len(tags) == 2 135 | assert 'sky' in tags 136 | assert tags['sky'] == 'blue' 137 | assert 'mercury' in tags 138 | assert tags['mercury'] == 'silver' 139 | 140 | def test_single_measurement_gauge(self): 141 | q = self.q 142 | q.add('temperature', 22.1) 143 | assert len(q.chunks) == 1 144 | assert q._num_measurements_in_current_chunk() == 1 145 | 146 | def test_default_type_measurement(self): 147 | q = self.q 148 | q.add('temperature', 22.1) 149 | assert len(q._current_chunk()['gauges']) == 1 150 | assert len(q._current_chunk()['counters']) == 0 151 | 152 | def test_single_measurement_counter(self): 153 | q = self.q 154 | q.add('num_requests', 2000, type='counter') 155 | assert len(q.chunks) == 1 156 | assert q._num_measurements_in_current_chunk() == 1 157 | assert len(q._current_chunk()['gauges']) == 0 158 | assert len(q._current_chunk()['counters']) == 1 159 | 160 | def test_num_metrics_in_queue(self): 161 | q = self.q 162 | # With only one chunk 163 | for _ in range(q.MAX_MEASUREMENTS_PER_CHUNK - 10): 164 | q.add('temperature', randint(20, 30)) 165 | assert q._num_measurements_in_queue() == 290 166 | # Now ensure multiple chunks 167 | for _ in range(100): 168 | q.add('num_requests', randint(100, 300), type='counter') 169 | assert q._num_measurements_in_queue() == 390 170 | 171 | def test_auto_submit_on_metric_count(self): 172 | q = self.conn.new_queue(auto_submit_count=10) 173 | for _ in range(9): 174 | q.add('temperature', randint(20, 30)) 175 | assert q._num_measurements_in_queue() == 9 176 | metrics = self.conn.list_metrics() 177 | assert len(metrics) == 0 178 | q.add('temperature', randint(20, 30)) 179 | assert q._num_measurements_in_queue() == 0 180 | metrics = self.conn.list_metrics() 181 | assert len(metrics) == 1 182 | 183 | def test_reach_chunk_limit(self): 184 | q = self.q 185 | for i in range(1, q.MAX_MEASUREMENTS_PER_CHUNK + 1): 186 | q.add('temperature', randint(20, 30)) 187 | assert len(q.chunks) == 1 188 | assert q._num_measurements_in_current_chunk() == q.MAX_MEASUREMENTS_PER_CHUNK 189 | 190 | q.add('temperature', 40) # damn is pretty hot :) 191 | assert q._num_measurements_in_current_chunk() == 1 192 | assert len(q.chunks) == 2 193 | 194 | def test_submit_context_manager(self): 195 | try: 196 | with self.conn.new_queue() as q: 197 | q.add('temperature', 32) 198 | raise ValueError 199 | except ValueError: 200 | gauge = self.conn.get('temperature', resolution=1, count=2) 201 | assert gauge.name == 'temperature' 202 | assert gauge.description is None 203 | assert len(gauge.measurements['unassigned']) == 1 204 | 205 | def test_submit_one_measurement_batch_mode(self): 206 | q = self.q 207 | q.add('temperature', 22.1) 208 | q.submit() 209 | metrics = self.conn.list_metrics() 210 | assert len(metrics) == 1 211 | gauge = self.conn.get('temperature', resolution=1, count=2) 212 | assert gauge.name == 'temperature' 213 | assert gauge.description is None 214 | assert len(gauge.measurements['unassigned']) == 1 215 | 216 | # Add another measurements for temperature 217 | q.add('temperature', 23) 218 | q.submit() 219 | metrics = self.conn.list_metrics() 220 | assert len(metrics) == 1 221 | gauge = self.conn.get('temperature', resolution=1, count=2) 222 | assert gauge.name == 'temperature' 223 | assert gauge.description is None 224 | assert len(gauge.measurements['unassigned']) == 2 225 | assert gauge.measurements['unassigned'][0]['value'] == 22.1 226 | assert gauge.measurements['unassigned'][1]['value'] == 23 227 | 228 | def test_submit_tons_of_measurement_batch_mode(self): 229 | q = self.q 230 | metrics = self.conn.list_metrics() 231 | assert len(metrics) == 0 232 | 233 | for t in range(1, q.MAX_MEASUREMENTS_PER_CHUNK + 1): 234 | q.add('temperature', t) 235 | q.submit() 236 | metrics = self.conn.list_metrics() 237 | assert len(metrics) == 1 238 | gauge = self.conn.get('temperature', resolution=1, count=q.MAX_MEASUREMENTS_PER_CHUNK + 1) 239 | assert gauge.name == 'temperature' 240 | assert gauge.description is None 241 | for t in range(1, q.MAX_MEASUREMENTS_PER_CHUNK + 1): 242 | assert gauge.measurements['unassigned'][t - 1]['value'] == t 243 | 244 | for cl in range(1, q.MAX_MEASUREMENTS_PER_CHUNK + 1): 245 | q.add('cpu_load', cl) 246 | q.submit() 247 | metrics = self.conn.list_metrics() 248 | assert len(metrics) == 2 249 | gauge = self.conn.get('cpu_load', resolution=1, count=q.MAX_MEASUREMENTS_PER_CHUNK + 1) 250 | assert gauge.name == 'cpu_load' 251 | assert gauge.description is None 252 | for t in range(1, q.MAX_MEASUREMENTS_PER_CHUNK + 1): 253 | assert gauge.measurements['unassigned'][t - 1]['value'] == t 254 | 255 | def test_add_aggregator(self): 256 | q = self.q 257 | metrics = self.conn.list_metrics() 258 | a = Aggregator(self.conn, source='mysource', period=10) 259 | a.add('foo', 42) 260 | a.add('bar', 37) 261 | q.add_aggregator(a) 262 | 263 | gauges = q.chunks[0]['gauges'] 264 | names = [g['name'] for g in gauges] 265 | 266 | assert len(q.chunks) == 1 267 | 268 | assert 'foo' in names 269 | assert 'bar' in names 270 | 271 | # All gauges should have the same source 272 | assert gauges[0]['source'] == 'mysource' 273 | assert gauges[1]['source'] == 'mysource' 274 | 275 | # All gauges should have the same measure_time 276 | assert 'measure_time' in gauges[0] 277 | assert 'measure_time' in gauges[1] 278 | 279 | # Test that time was snapped to 10s 280 | assert gauges[0]['measure_time'] % 10 == 0 281 | 282 | def test_md_submit(self): 283 | q = self.q 284 | q.set_tags({'hostname': 'web-1'}) 285 | 286 | mt1 = int(time.time()) - 5 287 | q.add_tagged('system_cpu', 3.2, time=mt1) 288 | assert q._num_measurements_in_queue() == 1 289 | q.submit() 290 | 291 | resp = self.conn.get_tagged('system_cpu', duration=60, tags_search="hostname=web-1") 292 | 293 | assert len(resp['series']) == 1 294 | assert resp['series'][0].get('tags', {}) == {'hostname': 'web-1'} 295 | 296 | measurements = resp['series'][0]['measurements'] 297 | assert len(measurements) == 1 298 | 299 | assert measurements[0]['time'] == mt1 300 | assert measurements[0]['value'] == 3.2 301 | 302 | def test_md_measurement_level_tag(self): 303 | q = self.q 304 | q.set_tags({'hostname': 'web-1'}) 305 | 306 | mt1 = int(time.time()) - 5 307 | q.add_tagged('system_cpu', 33.22, time=mt1, tags={"user": "james"}, inherit_tags=True) 308 | q.submit() 309 | 310 | # Ensure both tags get submitted 311 | for tag_search in ["hostname=web-1", "user=james"]: 312 | resp = self.conn.get_tagged('system_cpu', duration=60, tags_search=tag_search) 313 | 314 | assert len(resp['series']) == 1 315 | 316 | measurements = resp['series'][0]['measurements'] 317 | assert len(measurements) == 1 318 | 319 | assert measurements[0]['time'] == mt1 320 | assert measurements[0]['value'] == 33.22 321 | 322 | def test_md_measurement_level_tag_supersedes(self): 323 | q = self.q 324 | q.set_tags({'hostname': 'web-1'}) 325 | 326 | mt1 = int(time.time()) - 5 327 | q.add_tagged('system_cpu', 33.22, time=mt1, tags={"hostname": "web-2"}) 328 | q.submit() 329 | 330 | # Ensure measurement-level tag takes precedence 331 | resp = self.conn.get_tagged('system_cpu', duration=60, tags_search="hostname=web-1") 332 | assert len(resp['series']) == 0 333 | 334 | resp = self.conn.get_tagged('system_cpu', duration=60, tags_search="hostname=web-2") 335 | assert len(resp['series']) == 1 336 | 337 | measurements = resp['series'][0]['measurements'] 338 | assert len(measurements) == 1 339 | 340 | assert measurements[0]['time'] == mt1 341 | assert measurements[0]['value'] == 33.22 342 | 343 | def test_side_by_side(self): 344 | # Ensure tagged and untagged measurements are handled independently 345 | q = self.conn.new_queue(tags={'hostname': 'web-1'}) 346 | 347 | q.add('system_cpu', 10) 348 | q.add_tagged('system_cpu', 20) 349 | q.submit() 350 | 351 | resp = self.conn.get_tagged('system_cpu', duration=60, tags_search="hostname=web-1") 352 | assert len(resp['series']) == 1 353 | 354 | measurements = resp['series'][0]['measurements'] 355 | assert len(measurements) == 2 356 | assert measurements[0]['value'] == 10 357 | assert measurements[1]['value'] == 20 358 | 359 | def test_md_auto_submit_on_metric_count(self): 360 | q = self.conn.new_queue(auto_submit_count=2) 361 | 362 | q.add('untagged_cpu', 10) 363 | q.add_tagged('tagged_cpu', 20, tags={'hostname': 'web-2'}) 364 | 365 | assert q._num_measurements_in_queue() == 0 366 | 367 | gauge = self.conn.get('untagged_cpu', duration=60) 368 | assert len(gauge.measurements['unassigned']) == 1 369 | 370 | resp = self.conn.get_tagged('tagged_cpu', duration=60, tags_search="hostname=web-2") 371 | assert len(resp['series']) == 1 372 | 373 | def queue_send_as_md_when_queue_has_tags(self): 374 | q = self.conn.new_queue(tags={'foo': 1}) 375 | q.add('a_metric', 10) 376 | 377 | assert q._num_measurements_in_queue() == 1 378 | 379 | resp = self.conn.get_tagged('a_metric', duration=60, tags_search="foo=1") 380 | assert len(resp['series']) == 1 381 | 382 | def test_transparent_submit_md(self): 383 | q = self.q 384 | tags = {'hostname': 'web-1'} 385 | 386 | mt1 = int(time.time()) - 5 387 | q.add('system_cpu', 3.2, time=mt1, tags=tags) 388 | assert q._num_measurements_in_queue() == 1 389 | q.submit() 390 | 391 | resp = self.conn.get_tagged('system_cpu', duration=60, tags_search="hostname=web-1") 392 | 393 | assert len(resp['series']) == 1 394 | assert resp['series'][0].get('tags', {}) == {'hostname': 'web-1'} 395 | 396 | measurements = resp['series'][0]['measurements'] 397 | assert len(measurements) == 1 398 | 399 | assert measurements[0]['time'] == mt1 400 | assert measurements[0]['value'] == 3.2 401 | 402 | if __name__ == '__main__': 403 | unittest.main() 404 | -------------------------------------------------------------------------------- /tests/test_retry_logic.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import unittest 3 | import librato 4 | import mock_connection 5 | 6 | # logging.basicConfig(level=logging.DEBUG) 7 | # Mock the server 8 | librato.HTTPSConnection = mock_connection.MockConnect 9 | 10 | 11 | class TestRetries(unittest.TestCase): 12 | def setUp(self): 13 | self.conn = librato.connect('user_test', 'key_test') 14 | mock_connection.server.clean() 15 | 16 | def test_list_metrics_with_retries(self): 17 | self.conn.fake_n_errors = 1 18 | self.conn.backoff_logic = lambda x: 0.1 # We don't want to wait 1 sec for this to finish 19 | metrics = self.conn.list_metrics() 20 | assert len(metrics) == 0 21 | 22 | if __name__ == '__main__': 23 | unittest.main() 24 | -------------------------------------------------------------------------------- /tests/test_sanitization.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | import unittest 3 | from librato import sanitize_no_op, sanitize_metric_name 4 | 5 | 6 | class TestSanitization(unittest.TestCase): 7 | def test_sanitize_no_op(self): 8 | for name in ['323***', 'name1', 'name2']: 9 | self.assertEquals(name, sanitize_no_op(name)) 10 | 11 | def test_sanitize_metric_name(self): 12 | valid_chars = 'abcdefghijklmnopqrstuvwxyz.:-_' 13 | for name, expected in [ 14 | (valid_chars, valid_chars), 15 | (valid_chars.upper(), valid_chars.upper()), 16 | ('a' * 500, 'a' * 255), 17 | (' \t\nbat$$$*[]()m#@%^&=`~an', '-bat-m-an'), # throw in a unicode char 18 | ('Just*toBeSafe', 'Just-toBeSafe') 19 | ]: 20 | self.assertEquals(sanitize_metric_name(name), expected) 21 | -------------------------------------------------------------------------------- /tests/test_spaces.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import unittest 3 | import librato 4 | from librato import Space, Chart 5 | from librato.streams import Stream 6 | from mock_connection import MockConnect, server 7 | 8 | # logging.basicConfig(level=logging.DEBUG) 9 | # Mock the server 10 | librato.HTTPSConnection = MockConnect 11 | 12 | 13 | class SpacesTest(unittest.TestCase): 14 | def setUp(self): 15 | self.conn = librato.connect('user_test', 'key_test') 16 | server.clean() 17 | 18 | 19 | class TestSpacesConnection(SpacesTest): 20 | def test_list_spaces(self): 21 | self.conn.create_space('My Space') 22 | self.assertEqual(len(list(self.conn.list_spaces())), 1) 23 | 24 | def test_list_spaces_when_none(self): 25 | spcs = self.conn.list_spaces() 26 | self.assertEqual(len(list(spcs)), 0) 27 | 28 | # Find a space by ID 29 | def test_get_space(self): 30 | space = self.conn.create_space('My Space') 31 | found = self.conn.get_space(space.id) 32 | self.assertEqual(found.id, space.id) 33 | self.assertEqual(found.name, space.name) 34 | 35 | # Find a space by name 36 | def test_find_space(self): 37 | space = self.conn.create_space('My Space') 38 | found = self.conn.find_space(space.name) 39 | self.assertIsInstance(found, Space) 40 | self.assertEqual(found.name, space.name) 41 | 42 | def test_create_space(self): 43 | name = "My Space" 44 | space = self.conn.create_space(name) 45 | self.assertIsInstance(space, Space) 46 | self.assertEqual(space.name, name) 47 | 48 | def test_rename_space(self): 49 | name = 'My Space' 50 | new_name = 'My Space 42' 51 | space = self.conn.create_space(name) 52 | space.rename(new_name) 53 | found = self.conn.find_space(new_name) 54 | self.assertEqual(found.name, new_name) 55 | 56 | def test_list_charts(self): 57 | space_name = "My Space" 58 | space = self.conn.create_space(space_name) 59 | chart1 = self.conn.create_chart('CPU 1', space) 60 | chart2 = self.conn.create_chart('CPU 2', space) 61 | charts = space.charts() 62 | for c in charts: 63 | self.assertIsInstance(c, Chart) 64 | self.assertIn(c.name, ['CPU 1', 'CPU 2']) 65 | 66 | def test_update_space(self): 67 | space = self.conn.create_space('My Space') 68 | self.conn.update_space(space, name='New Name') 69 | 70 | updated_spaces = list(self.conn.list_spaces()) 71 | self.assertEqual(len(updated_spaces), 1) 72 | 73 | updated = updated_spaces[0] 74 | updated = self.conn.get_space(space.id) 75 | self.assertEqual(updated.id, space.id) 76 | self.assertEqual(updated.name, 'New Name') 77 | 78 | def test_delete_chart(self): 79 | space = self.conn.create_space('My Space') 80 | chart = self.conn.create_chart('My Chart', space) 81 | self.conn.delete_chart(chart.id, space.id) 82 | self.assertEqual(len(self.conn.list_charts_in_space(space)), 0) 83 | 84 | def test_delete_space(self): 85 | space = self.conn.create_space('My Space') 86 | self.conn.delete_space(space.id) 87 | self.assertEqual(len(list(self.conn.list_spaces())), 0) 88 | 89 | 90 | class TestSpaceModel(SpacesTest): 91 | def setUp(self): 92 | super(TestSpaceModel, self).setUp() 93 | self.space = Space(self.conn, 'My Space', id=123) 94 | 95 | def test_connection(self): 96 | self.assertEqual(Space(self.conn, 'My Space').connection, self.conn) 97 | 98 | def test_init_with_name(self): 99 | self.assertEqual(Space(self.conn, 'My Space').name, 'My Space') 100 | 101 | def test_init_with_tags(self): 102 | self.assertFalse(Space(self.conn, 'My Space').tags) 103 | self.assertFalse(Space(self.conn, 'My Space', tags=False).tags) 104 | self.assertTrue(Space(self.conn, 'My Space', tags=True).tags) 105 | 106 | def test_init_with_empty_name(self): 107 | self.assertEqual(Space(self.conn, None).name, None) 108 | 109 | def test_init_with_id(self): 110 | self.assertEqual(Space(self.conn, 'My Space', 123).id, 123) 111 | 112 | def test_charts_var(self): 113 | self.assertEqual(self.space._charts, None) 114 | 115 | def test_init_chart_ids_empty(self): 116 | self.assertEqual(self.space.chart_ids, []) 117 | 118 | def test_init_with_chart_payload(self): 119 | space = Space(self.conn, 'My Space', chart_dicts=[{'id': 123}, {'id': 456}]) 120 | self.assertEqual(space.chart_ids, [123, 456]) 121 | 122 | def test_space_is_not_persisted(self): 123 | space = Space(self.conn, 'not saved') 124 | self.assertFalse(space.persisted()) 125 | 126 | def test_space_is_persisted_if_id_present(self): 127 | space = Space(self.conn, 'saved', id=42) 128 | self.assertTrue(space.persisted()) 129 | 130 | # This only returns the name because that's all we can send to the Spaces API 131 | def test_get_payload(self): 132 | self.assertEqual(self.space.get_payload(), {'name': self.space.name}) 133 | 134 | def test_from_dict(self): 135 | payload = {'id': 42, 'name': 'test', 'charts': [{'id': 123}, {'id': 456}]} 136 | space = Space.from_dict(self.conn, payload) 137 | self.assertIsInstance(space, Space) 138 | self.assertEqual(space.id, 42) 139 | self.assertEqual(space.name, 'test') 140 | self.assertEqual(space.chart_ids, [123, 456]) 141 | 142 | def test_save_creates_space(self): 143 | space = Space(self.conn, 'not saved') 144 | self.assertFalse(space.persisted()) 145 | resp = space.save() 146 | self.assertIsInstance(resp, Space) 147 | self.assertTrue(space.persisted()) 148 | 149 | def save_updates_space(self): 150 | space = Space(self.conn, 'some name').save() 151 | self.assertEqual(space.name, 'some name') 152 | space.name = 'new name' 153 | space.save() 154 | self.assertEqual(self.conn.find_space('new_name').name, 'new name') 155 | 156 | def test_new_chart_name(self): 157 | chart = self.space.new_chart('test') 158 | self.assertIsInstance(chart, Chart) 159 | self.assertEqual(chart.name, 'test') 160 | 161 | def test_new_chart_not_persisted(self): 162 | # Doesn't save 163 | self.assertFalse(self.space.new_chart('test').persisted()) 164 | 165 | def test_new_chart_type(self): 166 | chart = self.space.new_chart('test') 167 | self.assertEqual(chart.type, 'line') 168 | chart = self.space.new_chart('test', type='stacked') 169 | self.assertEqual(chart.type, 'stacked') 170 | chart = self.space.new_chart('test', type='bignumber') 171 | self.assertEqual(chart.type, 'bignumber') 172 | 173 | def test_new_chart_attrs(self): 174 | chart = self.space.new_chart('test', 175 | label='hello', 176 | min=-5, 177 | max=30, 178 | use_log_yaxis=True, 179 | use_last_value=True, 180 | related_space=1234) 181 | self.assertEqual(chart.label, 'hello') 182 | self.assertEqual(chart.min, -5) 183 | self.assertEqual(chart.max, 30) 184 | self.assertTrue(chart.use_log_yaxis) 185 | self.assertTrue(chart.use_last_value) 186 | self.assertEqual(chart.related_space, 1234) 187 | 188 | def test_new_chart_bignumber(self): 189 | chart = self.space.new_chart('test', type='bignumber', 190 | use_last_value=False) 191 | self.assertEqual(chart.type, 'bignumber') 192 | self.assertFalse(chart.use_last_value) 193 | 194 | def test_add_chart_name(self): 195 | space = self.conn.create_space('foo') 196 | chart = space.add_chart('bar') 197 | self.assertIsInstance(chart, Chart) 198 | self.assertEqual(chart.name, 'bar') 199 | 200 | def test_add_chart_type(self): 201 | space = self.conn.create_space('foo') 202 | chart = space.add_chart('baz', type='stacked') 203 | self.assertEqual(chart.type, 'stacked') 204 | 205 | def test_add_chart_persisted(self): 206 | space = self.conn.create_space('foo') 207 | chart = space.add_chart('bar') 208 | # Does save 209 | self.assertTrue(chart.persisted()) 210 | 211 | def test_add_chart_streams(self): 212 | space = self.conn.create_space('foo') 213 | streams = [ 214 | {'metric': 'my.metric', 'source': 'foo'}, 215 | {'metric': 'my.metric2', 'source': 'bar'} 216 | ] 217 | chart = space.add_chart('cpu', streams=streams) 218 | self.assertEqual(chart.streams[0].metric, 'my.metric') 219 | self.assertEqual(chart.streams[0].source, 'foo') 220 | self.assertEqual(chart.streams[1].metric, 'my.metric2') 221 | self.assertEqual(chart.streams[1].source, 'bar') 222 | 223 | def test_add_chart_bignumber_default(self): 224 | space = self.conn.create_space('foo') 225 | chart = space.add_chart('baz', type='bignumber') 226 | self.assertEqual(chart.type, 'bignumber') 227 | # Leave this up to the Librato API to default 228 | self.assertIsNone(chart.use_last_value) 229 | 230 | def test_add_chart_bignumber_use_last_value(self): 231 | space = self.conn.create_space('foo') 232 | chart = space.add_chart('baz', type='bignumber', use_last_value=False) 233 | self.assertFalse(chart.use_last_value) 234 | chart = space.add_chart('baz', type='bignumber', use_last_value=True) 235 | self.assertTrue(chart.use_last_value) 236 | 237 | def test_add_line_chart(self): 238 | space = self.conn.create_space('foo') 239 | streams = [{'metric': 'my.metric', 'source': 'my.source'}] 240 | chart = space.add_line_chart('cpu', streams=streams) 241 | self.assertEqual([chart.name, chart.type], ['cpu', 'line']) 242 | self.assertEqual(len(chart.streams), 1) 243 | 244 | def test_add_single_line_chart_default(self): 245 | space = self.conn.create_space('foo') 246 | chart = space.add_single_line_chart('cpu', 'my.cpu.metric') 247 | self.assertEqual(chart.type, 'line') 248 | self.assertEqual(chart.name, 'cpu') 249 | self.assertEqual(len(chart.streams), 1) 250 | self.assertEqual(chart.streams[0].metric, 'my.cpu.metric') 251 | self.assertEqual(chart.streams[0].source, '*') 252 | 253 | def test_add_single_line_chart_source(self): 254 | space = self.conn.create_space('foo') 255 | chart = space.add_single_line_chart('cpu', 'my.cpu.metric', 'prod*') 256 | self.assertEqual(chart.streams[0].source, 'prod*') 257 | 258 | def test_add_single_line_chart_group_functions(self): 259 | space = self.conn.create_space('foo') 260 | chart = space.add_single_line_chart('cpu', 'my.cpu.metric', '*', 'min', 'max') 261 | stream = chart.streams[0] 262 | self.assertEqual(stream.group_function, 'min') 263 | self.assertEqual(stream.summary_function, 'max') 264 | 265 | def test_add_stacked_chart(self): 266 | space = self.conn.create_space('foo') 267 | streams = [{'metric': 'my.metric', 'source': 'my.source'}] 268 | chart = space.add_stacked_chart('cpu', streams=streams) 269 | self.assertEqual(chart.type, 'stacked') 270 | self.assertEqual([chart.name, chart.type], ['cpu', 'stacked']) 271 | self.assertEqual(len(chart.streams), 1) 272 | 273 | def test_add_single_stacked_chart(self): 274 | space = self.conn.create_space('foo') 275 | chart = space.add_single_stacked_chart('cpu', 'my.cpu.metric', '*') 276 | self.assertEqual(chart.type, 'stacked') 277 | self.assertEqual(chart.name, 'cpu') 278 | self.assertEqual(len(chart.streams), 1) 279 | self.assertEqual(chart.streams[0].metric, 'my.cpu.metric') 280 | self.assertEqual(chart.streams[0].source, '*') 281 | 282 | def test_add_bignumber_chart_default(self): 283 | space = self.conn.create_space('foo') 284 | chart = space.add_bignumber_chart('cpu', 'my.metric') 285 | self.assertEqual(chart.type, 'bignumber') 286 | self.assertEqual(chart.name, 'cpu') 287 | self.assertTrue(chart.use_last_value) 288 | stream = chart.streams[0] 289 | self.assertEqual(stream.metric, 'my.metric') 290 | self.assertEqual(stream.source, '*') 291 | self.assertEqual(stream.summary_function, 'average') 292 | 293 | def test_add_bignumber_chart_source(self): 294 | space = self.conn.create_space('foo') 295 | chart = space.add_bignumber_chart('cpu', 'my.metric', 'foo') 296 | self.assertEqual(chart.streams[0].source, 'foo') 297 | 298 | def test_add_bignumber_chart_summary_function(self): 299 | space = self.conn.create_space('foo') 300 | chart = space.add_bignumber_chart('cpu', 'my.metric', 301 | summary_function='min') 302 | self.assertEqual(chart.streams[0].summary_function, 'min') 303 | 304 | def test_add_bignumber_chart_group_function(self): 305 | space = self.conn.create_space('foo') 306 | chart = space.add_bignumber_chart('cpu', 'my.metric', 307 | group_function='max') 308 | self.assertEqual(chart.streams[0].group_function, 'max') 309 | 310 | def test_add_bignumber_chart_use_last_value(self): 311 | space = self.conn.create_space('foo') 312 | # True shows the most recent value, False reduces over time 313 | # Default to True 314 | chart = space.add_bignumber_chart('cpu', 'my.metric') 315 | self.assertTrue(chart.use_last_value) 316 | chart = space.add_bignumber_chart('cpu', 'my.metric', use_last_value=True) 317 | self.assertTrue(chart.use_last_value) 318 | chart = space.add_bignumber_chart('cpu', 'my.metric', use_last_value=False) 319 | self.assertFalse(chart.use_last_value) 320 | 321 | def test_delete_space(self): 322 | space = self.conn.create_space('Delete Me') 323 | # Ensure we can find it 324 | self.assertEqual(self.conn.find_space(space.name).name, space.name) 325 | resp = space.delete() 326 | # Returns None 327 | self.assertIsNone(resp) 328 | # Ensure it was deleted 329 | self.assertIsNone(self.conn.find_space(space.name)) 330 | 331 | 332 | if __name__ == '__main__': 333 | unittest.main() 334 | -------------------------------------------------------------------------------- /tests/test_streams.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import unittest 3 | import librato 4 | from librato.streams import Stream 5 | from mock_connection import MockConnect, server 6 | 7 | # logging.basicConfig(level=logging.DEBUG) 8 | # Mock the server 9 | librato.HTTPSConnection = MockConnect 10 | 11 | 12 | class TestStreamModel(unittest.TestCase): 13 | def setUp(self): 14 | self.conn = librato.connect('user_test', 'key_test') 15 | server.clean() 16 | 17 | def test_init_metric(self): 18 | self.assertEqual(Stream('my.metric').metric, 'my.metric') 19 | self.assertEqual(Stream(metric='my.metric').metric, 'my.metric') 20 | 21 | def test_init_source(self): 22 | self.assertEqual(Stream('my.metric', 'my.source').source, 'my.source') 23 | self.assertEqual(Stream(source='my.source').source, 'my.source') 24 | 25 | def test_init_composite(self): 26 | composite_formula = 's("my.metric", "*")' 27 | self.assertEqual(Stream(composite=composite_formula).composite, composite_formula) 28 | 29 | def test_source_defaults_to_all(self): 30 | self.assertEqual(Stream('my.metric').source, '*') 31 | 32 | def test_composite_defaults_to_none(self): 33 | self.assertIsNone(Stream('my.metric').composite) 34 | 35 | def test_init_group_function(self): 36 | self.assertIsNone(Stream('my.metric').group_function) 37 | self.assertEqual(Stream(group_function='max').group_function, 'max') 38 | 39 | def test_init_summary_function(self): 40 | self.assertIsNone(Stream('my.metric').summary_function) 41 | self.assertEqual(Stream(summary_function='min').summary_function, 'min') 42 | 43 | def test_init_transform_function(self): 44 | self.assertIsNone(Stream('my.metric').transform_function) 45 | self.assertEqual(Stream(transform_function='x/p*60').transform_function, 'x/p*60') 46 | 47 | # For composites ONLY 48 | def test_init_downsample_function(self): 49 | self.assertIsNone(Stream('my.metric').downsample_function) 50 | self.assertEqual(Stream(downsample_function='sum').downsample_function, 'sum') 51 | 52 | def test_init_period(self): 53 | self.assertIsNone(Stream('my.metric').period) 54 | self.assertEqual(Stream(period=60).period, 60) 55 | 56 | # Not very useful but part of the API 57 | def test_init_type(self): 58 | self.assertIsNone(Stream().type) 59 | self.assertEqual(Stream(type='gauge').type, 'gauge') 60 | 61 | def test_init_split_axis(self): 62 | self.assertIsNone(Stream().split_axis) 63 | self.assertTrue(Stream(split_axis=True).split_axis) 64 | self.assertFalse(Stream(split_axis=False).split_axis) 65 | 66 | def test_init_min_max(self): 67 | self.assertIsNone(Stream().min) 68 | self.assertIsNone(Stream().max) 69 | self.assertEqual(Stream(min=-2).min, -2) 70 | self.assertEqual(Stream(max=42).max, 42) 71 | 72 | def test_init_units(self): 73 | self.assertIsNone(Stream().units_short) 74 | self.assertIsNone(Stream().units_long) 75 | self.assertEqual(Stream(units_short='req/s').units_short, 'req/s') 76 | self.assertEqual(Stream(units_long='requests per second').units_long, 'requests per second') 77 | 78 | def test_init_color(self): 79 | self.assertIsNone(Stream().color) 80 | self.assertEqual(Stream(color='#f00').color, '#f00') 81 | 82 | def test_init_gap_detection(self): 83 | self.assertIsNone(Stream().gap_detection) 84 | self.assertTrue(Stream(gap_detection=True).gap_detection) 85 | self.assertFalse(Stream(gap_detection=False).gap_detection) 86 | 87 | # Adding this to avoid exceptions raised due to unknown Stream attributes 88 | def test_init_with_extra_attributes(self): 89 | attrs = {"color": "#f00", "something": "foo"} 90 | s = Stream(**attrs) 91 | # color is a known attribute 92 | self.assertEqual(s.color, '#f00') 93 | self.assertEqual(s.something, 'foo') 94 | 95 | def test_get_payload(self): 96 | self.assertEqual(Stream(metric='my.metric').get_payload(), 97 | {'metric': 'my.metric', 'source': '*'}) 98 | 99 | def test_payload_all_attributes(self): 100 | s = Stream(metric='my.metric', source='*', name='my display name', 101 | type='gauge', id=1234, 102 | group_function='min', summary_function='max', 103 | transform_function='x/p', downsample_function='min', 104 | period=60, split_axis=False, 105 | min=0, max=42, 106 | units_short='req/s', units_long='requests per second') 107 | payload = { 108 | 'metric': 'my.metric', 109 | 'source': '*', 110 | 'name': 'my display name', 111 | 'type': 'gauge', 112 | 'id': 1234, 113 | 'group_function': 'min', 114 | 'summary_function': 'max', 115 | 'transform_function': 'x/p', 116 | 'downsample_function': 'min', 117 | 'period': 60, 118 | 'split_axis': False, 119 | 'min': 0, 120 | 'max': 42, 121 | 'units_short': 'req/s', 122 | 'units_long': 'requests per second' 123 | } 124 | self.assertEqual(s.get_payload(), payload) 125 | 126 | 127 | if __name__ == '__main__': 128 | unittest.main() 129 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py27, py33, py34, pypy 3 | 4 | [testenv] 5 | deps = nose 6 | mock 7 | commands = nosetests tests/ 8 | 9 | --------------------------------------------------------------------------------