diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 00000000..69a0f522 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,14 @@ +[run] +branch = True +source = prometheus_client +omit = + prometheus_client/decorator.py + +[paths] +source = + prometheus_client + .tox/*/lib/python*/site-packages/prometheus_client + .tox/pypy/site-packages/prometheus_client + +[report] +show_missing = True \ No newline at end of file diff --git a/.gitignore b/.gitignore index 2849cc3e..043223b4 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ dist *.egg-info *.pyc *.swp +.coverage.* +.coverage +.tox diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 00000000..e5b8bf00 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,32 @@ +sudo: false +cache: + directories: + - $HOME/.cache/pip + +language: python + +matrix: + include: + - python: "2.6" + env: TOXENV=py26 + - python: "2.7" + env: TOXENV=py27 + - python: "2.7" + env: TOXENV=py27-nooptionals + - python: "3.4" + env: TOXENV=py34 + - python: "3.5" + env: TOXENV=py35 + - python: "3.5" + env: TOXENV=py35-nooptionals + - python: "pypy" + env: TOXENV=pypy + +install: + - pip install tox + +script: + - tox + +notifications: + email: false diff --git a/NOTICE b/NOTICE index 0675ae13..59efb6c7 100644 --- a/NOTICE +++ b/NOTICE @@ -1,2 +1,5 @@ Prometheus instrumentation library for Python applications Copyright 2015 The Prometheus Authors + +This product bundles decorator 4.0.10 which is available under a "2-clause BSD" +license. For details, see prometheus_client/decorator.py. diff --git a/README.md b/README.md index ee41e811..67220f9e 100644 --- a/README.md +++ b/README.md @@ -185,13 +185,13 @@ c.labels('get', '/').inc() c.labels('post', '/submit').inc() ``` -Labels can also be provided as a dict: +Labels can also be passed as keyword-arguments: ```python from prometheus_client import Counter c = Counter('my_requests_total', 'HTTP Failures', ['method', 'endpoint']) -c.labels({'method': 'get', 'endpoint': '/'}).inc() -c.labels({'method': 'post', 'endpoint': '/submit'}).inc() +c.labels(method='get', endpoint='/').inc() +c.labels(method='post', endpoint='/submit').inc() ``` ### Process Collector @@ -229,6 +229,50 @@ To add Prometheus exposition to an existing HTTP server, see the `MetricsHandler which provides a `BaseHTTPRequestHandler`. It also serves as a simple example of how to write a custom endpoint. +#### Twisted + +To use prometheus with [twisted](https://twistedmatrix.com/), there is `MetricsResource` which exposes metrics as a twisted resource. + +```python +from prometheus_client.twisted import MetricsResource +from twisted.web.server import Site +from twisted.web.resource import Resource +from twisted.internet import reactor + +root = Resource() +root.putChild(b'metrics', MetricsResource()) + +factory = Site(root) +reactor.listenTCP(8000, factory) +reactor.run() +``` + +#### WSGI + +To use Prometheus with [WSGI](http://wsgi.readthedocs.org/en/latest/), there is +`make_wsgi_app` which creates a WSGI application. + +```python +from prometheus_client import make_wsgi_app +from wsgiref.simple_server import make_server + +app = make_wsgi_app() +httpd = make_server('', 8000, app) +httpd.serve_forever() +``` + +Such an application can be useful when integrating Prometheus metrics with WSGI +apps. + +The method `start_wsgi_server` can be used to serve the metrics through the +WSGI reference implementation in a new thread. + +```python +from prometheus_client import start_wsgi_server + +start_wsgi_server(8000) +``` + ### Node exporter textfile collector The [textfile collector](https://github.com/prometheus/node_exporter#textfile-collector) @@ -325,14 +369,13 @@ REGISTRY.register(CustomCollector()) ## Parser The Python client supports parsing the Promeheus text format. -This is intended for advanced use cases where you have servers +This is intended for advanced use cases where you have servers exposing Prometheus metrics and need to get them into some other system. -``` +```python from prometheus_client.parser import text_string_to_metric_families for family in text_string_to_metric_families("my_gauge 1.0\n"): for sample in family.samples: print("Name: {0} Labels: {1} Value: {2}".format(*sample)) ``` - diff --git a/prometheus_client/__init__.py b/prometheus_client/__init__.py index 80424dbf..358146a4 100644 --- a/prometheus_client/__init__.py +++ b/prometheus_client/__init__.py @@ -19,7 +19,9 @@ CONTENT_TYPE_LATEST = exposition.CONTENT_TYPE_LATEST generate_latest = exposition.generate_latest MetricsHandler = exposition.MetricsHandler +make_wsgi_app = exposition.make_wsgi_app start_http_server = exposition.start_http_server +start_wsgi_server = exposition.start_wsgi_server write_to_textfile = exposition.write_to_textfile push_to_gateway = exposition.push_to_gateway pushadd_to_gateway = exposition.pushadd_to_gateway diff --git a/prometheus_client/core.py b/prometheus_client/core.py index 720efe6a..6dd7e45b 100644 --- a/prometheus_client/core.py +++ b/prometheus_client/core.py @@ -14,9 +14,10 @@ # Python 3 unicode = str -from functools import wraps from threading import Lock +from .decorator import decorate + _METRIC_NAME_RE = re.compile(r'^[a-zA-Z_:][a-zA-Z0-9_:]*$') _METRIC_LABEL_NAME_RE = re.compile(r'^[a-zA-Z_:][a-zA-Z0-9_:]*$') _RESERVED_METRIC_LABEL_NAME_RE = re.compile(r'^__.*$') @@ -253,7 +254,7 @@ def __init__(self, wrappedClass, name, labelnames, **kwargs): if l.startswith('__'): raise ValueError('Invalid label metric name: ' + l) - def labels(self, *labelvalues): + def labels(self, *labelvalues, **labelkwargs): '''Return the child for the given labelset. All metrics can have labels, allowing grouping of related time series. @@ -276,10 +277,13 @@ def labels(self, *labelvalues): See the best practices on [naming](http://prometheus.io/docs/practices/naming/) and [labels](http://prometheus.io/docs/practices/instrumentation/#use-labels). ''' - if len(labelvalues) == 1 and type(labelvalues[0]) == dict: - if sorted(labelvalues[0].keys()) != sorted(self._labelnames): + if labelvalues and labelkwargs: + raise ValueError("Can't pass both *args and **kwargs") + + if labelkwargs: + if sorted(labelkwargs) != sorted(self._labelnames): raise ValueError('Incorrect label names') - labelvalues = tuple([unicode(labelvalues[0][l]) for l in self._labelnames]) + labelvalues = tuple([unicode(labelkwargs[l]) for l in self._labelnames]) else: if len(labelvalues) != len(self._labelnames): raise ValueError('Incorrect label count') @@ -398,26 +402,7 @@ def count_exceptions(self, exception=Exception): Increments the counter when an exception of the given type is raised up out of the code. ''' - - class ExceptionCounter(object): - def __init__(self, counter): - self._counter = counter - - def __enter__(self): - pass - - def __exit__(self, typ, value, traceback): - if isinstance(value, exception): - self._counter.inc() - - def __call__(self, f): - @wraps(f) - def wrapped(*args, **kwargs): - with self: - return f(*args, **kwargs) - return wrapped - - return ExceptionCounter(self) + return _ExceptionCounter(self, exception) def _samples(self): return (('', {}, self._value.get()), ) @@ -490,51 +475,14 @@ def track_inprogress(self): Increments the gauge when the code is entered, and decrements when it is exited. ''' - - class InprogressTracker(object): - def __init__(self, gauge): - self._gauge = gauge - - def __enter__(self): - self._gauge.inc() - - def __exit__(self, typ, value, traceback): - self._gauge.dec() - - def __call__(self, f): - @wraps(f) - def wrapped(*args, **kwargs): - with self: - return f(*args, **kwargs) - return wrapped - - return InprogressTracker(self) + return _InprogressTracker(self) def time(self): '''Time a block of code or function, and set the duration in seconds. Can be used as a function decorator or context manager. ''' - - class Timer(object): - def __init__(self, gauge): - self._gauge = gauge - - def __enter__(self): - self._start = time.time() - - def __exit__(self, typ, value, traceback): - # Time can go backwards. - self._gauge.set(max(time.time() - self._start, 0)) - - def __call__(self, f): - @wraps(f) - def wrapped(*args, **kwargs): - with self: - return f(*args, **kwargs) - return wrapped - - return Timer(self) + return _GaugeTimer(self) def set_function(self, f): '''Call the provided function to return the Gauge value. @@ -598,26 +546,7 @@ def time(self): Can be used as a function decorator or context manager. ''' - - class Timer(object): - def __init__(self, summary): - self._summary = summary - - def __enter__(self): - self._start = time.time() - - def __exit__(self, typ, value, traceback): - # Time can go backwards. - self._summary.observe(max(time.time() - self._start, 0)) - - def __call__(self, f): - @wraps(f) - def wrapped(*args, **kwargs): - with self: - return f(*args, **kwargs) - return wrapped - - return Timer(self) + return _SummaryTimer(self) def _samples(self): return ( @@ -707,26 +636,7 @@ def time(self): Can be used as a function decorator or context manager. ''' - - class Timer(object): - def __init__(self, histogram): - self._histogram = histogram - - def __enter__(self): - self._start = time.time() - - def __exit__(self, typ, value, traceback): - # Time can go backwards. - self._histogram.observe(max(time.time() - self._start, 0)) - - def __call__(self, f): - @wraps(f) - def wrapped(*args, **kwargs): - with self: - return f(*args, **kwargs) - return wrapped - - return Timer(self) + return _HistogramTimer(self) def _samples(self): samples = [] @@ -738,3 +648,92 @@ def _samples(self): samples.append(('_sum', {}, self._sum.get())) return tuple(samples) + +class _HistogramTimer(object): + def __init__(self, histogram): + self._histogram = histogram + + def __enter__(self): + self._start = time.time() + + def __exit__(self, typ, value, traceback): + # Time can go backwards. + self._histogram.observe(max(time.time() - self._start, 0)) + + def __call__(self, f): + def wrapped(func, *args, **kwargs): + with self: + return func(*args, **kwargs) + return decorate(f, wrapped) + + +class _ExceptionCounter(object): + def __init__(self, counter, exception): + self._counter = counter + self._exception = exception + + def __enter__(self): + pass + + def __exit__(self, typ, value, traceback): + if isinstance(value, self._exception): + self._counter.inc() + + def __call__(self, f): + def wrapped(func, *args, **kwargs): + with self: + return func(*args, **kwargs) + return decorate(f, wrapped) + + +class _InprogressTracker(object): + def __init__(self, gauge): + self._gauge = gauge + + def __enter__(self): + self._gauge.inc() + + def __exit__(self, typ, value, traceback): + self._gauge.dec() + + def __call__(self, f): + def wrapped(func, *args, **kwargs): + with self: + return func(*args, **kwargs) + return decorate(f, wrapped) + + +class _SummaryTimer(object): + def __init__(self, summary): + self._summary = summary + + def __enter__(self): + self._start = time.time() + + def __exit__(self, typ, value, traceback): + # Time can go backwards. + self._summary.observe(max(time.time() - self._start, 0)) + + def __call__(self, f): + def wrapped(func, *args, **kwargs): + with self: + return func(*args, **kwargs) + return decorate(f, wrapped) + + +class _GaugeTimer(object): + def __init__(self, gauge): + self._gauge = gauge + + def __enter__(self): + self._start = time.time() + + def __exit__(self, typ, value, traceback): + # Time can go backwards. + self._gauge.set(max(time.time() - self._start, 0)) + + def __call__(self, f): + def wrapped(func, *args, **kwargs): + with self: + return func(*args, **kwargs) + return decorate(f, wrapped) diff --git a/prometheus_client/decorator.py b/prometheus_client/decorator.py new file mode 100644 index 00000000..50876c6f --- /dev/null +++ b/prometheus_client/decorator.py @@ -0,0 +1,417 @@ +# ######################### LICENSE ############################ # + +# Copyright (c) 2005-2016, Michele Simionato +# All rights reserved. + +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: + +# Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# Redistributions in bytecode form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. + +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS +# OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR +# TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE +# USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH +# DAMAGE. + +""" +Decorator module, see http://pypi.python.org/pypi/decorator +for the documentation. +""" +from __future__ import print_function + +import re +import sys +import inspect +import operator +import itertools +import collections + +__version__ = '4.0.10' + +if sys.version >= '3': + from inspect import getfullargspec + + def get_init(cls): + return cls.__init__ +else: + class getfullargspec(object): + "A quick and dirty replacement for getfullargspec for Python 2.X" + def __init__(self, f): + self.args, self.varargs, self.varkw, self.defaults = \ + inspect.getargspec(f) + self.kwonlyargs = [] + self.kwonlydefaults = None + + def __iter__(self): + yield self.args + yield self.varargs + yield self.varkw + yield self.defaults + + getargspec = inspect.getargspec + + def get_init(cls): + return cls.__init__.__func__ + +# getargspec has been deprecated in Python 3.5 +ArgSpec = collections.namedtuple( + 'ArgSpec', 'args varargs varkw defaults') + + +def getargspec(f): + """A replacement for inspect.getargspec""" + spec = getfullargspec(f) + return ArgSpec(spec.args, spec.varargs, spec.varkw, spec.defaults) + +DEF = re.compile('\s*def\s*([_\w][_\w\d]*)\s*\(') + + +# basic functionality +class FunctionMaker(object): + """ + An object with the ability to create functions with a given signature. + It has attributes name, doc, module, signature, defaults, dict and + methods update and make. + """ + + # Atomic get-and-increment provided by the GIL + _compile_count = itertools.count() + + def __init__(self, func=None, name=None, signature=None, + defaults=None, doc=None, module=None, funcdict=None): + self.shortsignature = signature + if func: + # func can be a class or a callable, but not an instance method + self.name = func.__name__ + if self.name == '': # small hack for lambda functions + self.name = '_lambda_' + self.doc = func.__doc__ + self.module = func.__module__ + if inspect.isfunction(func): + argspec = getfullargspec(func) + self.annotations = getattr(func, '__annotations__', {}) + for a in ('args', 'varargs', 'varkw', 'defaults', 'kwonlyargs', + 'kwonlydefaults'): + setattr(self, a, getattr(argspec, a)) + for i, arg in enumerate(self.args): + setattr(self, 'arg%d' % i, arg) + if sys.version < '3': # easy way + self.shortsignature = self.signature = ( + inspect.formatargspec( + formatvalue=lambda val: "", *argspec)[1:-1]) + else: # Python 3 way + allargs = list(self.args) + allshortargs = list(self.args) + if self.varargs: + allargs.append('*' + self.varargs) + allshortargs.append('*' + self.varargs) + elif self.kwonlyargs: + allargs.append('*') # single star syntax + for a in self.kwonlyargs: + allargs.append('%s=None' % a) + allshortargs.append('%s=%s' % (a, a)) + if self.varkw: + allargs.append('**' + self.varkw) + allshortargs.append('**' + self.varkw) + self.signature = ', '.join(allargs) + self.shortsignature = ', '.join(allshortargs) + self.dict = func.__dict__.copy() + # func=None happens when decorating a caller + if name: + self.name = name + if signature is not None: + self.signature = signature + if defaults: + self.defaults = defaults + if doc: + self.doc = doc + if module: + self.module = module + if funcdict: + self.dict = funcdict + # check existence required attributes + assert hasattr(self, 'name') + if not hasattr(self, 'signature'): + raise TypeError('You are decorating a non function: %s' % func) + + def update(self, func, **kw): + "Update the signature of func with the data in self" + func.__name__ = self.name + func.__doc__ = getattr(self, 'doc', None) + func.__dict__ = getattr(self, 'dict', {}) + func.__defaults__ = getattr(self, 'defaults', ()) + func.__kwdefaults__ = getattr(self, 'kwonlydefaults', None) + func.__annotations__ = getattr(self, 'annotations', None) + try: + frame = sys._getframe(3) + except AttributeError: # for IronPython and similar implementations + callermodule = '?' + else: + callermodule = frame.f_globals.get('__name__', '?') + func.__module__ = getattr(self, 'module', callermodule) + func.__dict__.update(kw) + + def make(self, src_templ, evaldict=None, addsource=False, **attrs): + "Make a new function from a given template and update the signature" + src = src_templ % vars(self) # expand name and signature + evaldict = evaldict or {} + mo = DEF.match(src) + if mo is None: + raise SyntaxError('not a valid function template\n%s' % src) + name = mo.group(1) # extract the function name + names = set([name] + [arg.strip(' *') for arg in + self.shortsignature.split(',')]) + for n in names: + if n in ('_func_', '_call_'): + raise NameError('%s is overridden in\n%s' % (n, src)) + + if not src.endswith('\n'): # add a newline for old Pythons + src += '\n' + + # Ensure each generated function has a unique filename for profilers + # (such as cProfile) that depend on the tuple of (, + # , ) being unique. + filename = '' % (next(self._compile_count),) + try: + code = compile(src, filename, 'single') + exec(code, evaldict) + except: + print('Error in generated code:', file=sys.stderr) + print(src, file=sys.stderr) + raise + func = evaldict[name] + if addsource: + attrs['__source__'] = src + self.update(func, **attrs) + return func + + @classmethod + def create(cls, obj, body, evaldict, defaults=None, + doc=None, module=None, addsource=True, **attrs): + """ + Create a function from the strings name, signature and body. + evaldict is the evaluation dictionary. If addsource is true an + attribute __source__ is added to the result. The attributes attrs + are added, if any. + """ + if isinstance(obj, str): # "name(signature)" + name, rest = obj.strip().split('(', 1) + signature = rest[:-1] # strip a right parens + func = None + else: # a function + name = None + signature = None + func = obj + self = cls(func, name, signature, defaults, doc, module) + ibody = '\n'.join(' ' + line for line in body.splitlines()) + return self.make('def %(name)s(%(signature)s):\n' + ibody, + evaldict, addsource, **attrs) + + +def decorate(func, caller): + """ + decorate(func, caller) decorates a function using a caller. + """ + evaldict = dict(_call_=caller, _func_=func) + fun = FunctionMaker.create( + func, "return _call_(_func_, %(shortsignature)s)", + evaldict, __wrapped__=func) + if hasattr(func, '__qualname__'): + fun.__qualname__ = func.__qualname__ + return fun + + +def decorator(caller, _func=None): + """decorator(caller) converts a caller function into a decorator""" + if _func is not None: # return a decorated function + # this is obsolete behavior; you should use decorate instead + return decorate(_func, caller) + # else return a decorator function + if inspect.isclass(caller): + name = caller.__name__.lower() + doc = 'decorator(%s) converts functions/generators into ' \ + 'factories of %s objects' % (caller.__name__, caller.__name__) + elif inspect.isfunction(caller): + if caller.__name__ == '': + name = '_lambda_' + else: + name = caller.__name__ + doc = caller.__doc__ + else: # assume caller is an object with a __call__ method + name = caller.__class__.__name__.lower() + doc = caller.__call__.__doc__ + evaldict = dict(_call_=caller, _decorate_=decorate) + return FunctionMaker.create( + '%s(func)' % name, 'return _decorate_(func, _call_)', + evaldict, doc=doc, module=caller.__module__, + __wrapped__=caller) + + +# ####################### contextmanager ####################### # + +try: # Python >= 3.2 + from contextlib import _GeneratorContextManager +except ImportError: # Python >= 2.5 + from contextlib import GeneratorContextManager as _GeneratorContextManager + + +class ContextManager(_GeneratorContextManager): + def __call__(self, func): + """Context manager decorator""" + return FunctionMaker.create( + func, "with _self_: return _func_(%(shortsignature)s)", + dict(_self_=self, _func_=func), __wrapped__=func) + +init = getfullargspec(_GeneratorContextManager.__init__) +n_args = len(init.args) +if n_args == 2 and not init.varargs: # (self, genobj) Python 2.7 + def __init__(self, g, *a, **k): + return _GeneratorContextManager.__init__(self, g(*a, **k)) + ContextManager.__init__ = __init__ +elif n_args == 2 and init.varargs: # (self, gen, *a, **k) Python 3.4 + pass +elif n_args == 4: # (self, gen, args, kwds) Python 3.5 + def __init__(self, g, *a, **k): + return _GeneratorContextManager.__init__(self, g, a, k) + ContextManager.__init__ = __init__ + +contextmanager = decorator(ContextManager) + + +# ############################ dispatch_on ############################ # + +def append(a, vancestors): + """ + Append ``a`` to the list of the virtual ancestors, unless it is already + included. + """ + add = True + for j, va in enumerate(vancestors): + if issubclass(va, a): + add = False + break + if issubclass(a, va): + vancestors[j] = a + add = False + if add: + vancestors.append(a) + + +# inspired from simplegeneric by P.J. Eby and functools.singledispatch +def dispatch_on(*dispatch_args): + """ + Factory of decorators turning a function into a generic function + dispatching on the given arguments. + """ + assert dispatch_args, 'No dispatch args passed' + dispatch_str = '(%s,)' % ', '.join(dispatch_args) + + def check(arguments, wrong=operator.ne, msg=''): + """Make sure one passes the expected number of arguments""" + if wrong(len(arguments), len(dispatch_args)): + raise TypeError('Expected %d arguments, got %d%s' % + (len(dispatch_args), len(arguments), msg)) + + def gen_func_dec(func): + """Decorator turning a function into a generic function""" + + # first check the dispatch arguments + argset = set(getfullargspec(func).args) + if not set(dispatch_args) <= argset: + raise NameError('Unknown dispatch arguments %s' % dispatch_str) + + typemap = {} + + def vancestors(*types): + """ + Get a list of sets of virtual ancestors for the given types + """ + check(types) + ras = [[] for _ in range(len(dispatch_args))] + for types_ in typemap: + for t, type_, ra in zip(types, types_, ras): + if issubclass(t, type_) and type_ not in t.__mro__: + append(type_, ra) + return [set(ra) for ra in ras] + + def ancestors(*types): + """ + Get a list of virtual MROs, one for each type + """ + check(types) + lists = [] + for t, vas in zip(types, vancestors(*types)): + n_vas = len(vas) + if n_vas > 1: + raise RuntimeError( + 'Ambiguous dispatch for %s: %s' % (t, vas)) + elif n_vas == 1: + va, = vas + mro = type('t', (t, va), {}).__mro__[1:] + else: + mro = t.__mro__ + lists.append(mro[:-1]) # discard t and object + return lists + + def register(*types): + """ + Decorator to register an implementation for the given types + """ + check(types) + + def dec(f): + check(getfullargspec(f).args, operator.lt, ' in ' + f.__name__) + typemap[types] = f + return f + return dec + + def dispatch_info(*types): + """ + An utility to introspect the dispatch algorithm + """ + check(types) + lst = [] + for anc in itertools.product(*ancestors(*types)): + lst.append(tuple(a.__name__ for a in anc)) + return lst + + def _dispatch(dispatch_args, *args, **kw): + types = tuple(type(arg) for arg in dispatch_args) + try: # fast path + f = typemap[types] + except KeyError: + pass + else: + return f(*args, **kw) + combinations = itertools.product(*ancestors(*types)) + next(combinations) # the first one has been already tried + for types_ in combinations: + f = typemap.get(types_) + if f is not None: + return f(*args, **kw) + + # else call the default implementation + return func(*args, **kw) + + return FunctionMaker.create( + func, 'return _f_(%s, %%(shortsignature)s)' % dispatch_str, + dict(_f_=_dispatch), register=register, default=func, + typemap=typemap, vancestors=vancestors, ancestors=ancestors, + dispatch_info=dispatch_info, __wrapped__=func) + + gen_func_dec.__name__ = 'dispatch_on' + dispatch_str + return gen_func_dec diff --git a/prometheus_client/exposition.py b/prometheus_client/exposition.py index 3c4795d3..dc829b6a 100644 --- a/prometheus_client/exposition.py +++ b/prometheus_client/exposition.py @@ -7,6 +7,7 @@ import time import threading from contextlib import closing +from wsgiref.simple_server import make_server from . import core try: @@ -23,10 +24,31 @@ from urllib.parse import quote_plus -CONTENT_TYPE_LATEST = 'text/plain; version=0.0.4; charset=utf-8' +CONTENT_TYPE_LATEST = str('text/plain; version=0.0.4; charset=utf-8') '''Content type of the latest text format''' +def make_wsgi_app(registry=core.REGISTRY): + '''Create a WSGI app which serves the metrics from a registry.''' + def prometheus_app(environ, start_response): + status = str('200 OK') + headers = [(str('Content-type'), CONTENT_TYPE_LATEST)] + start_response(status, headers) + return [generate_latest(registry)] + return prometheus_app + + +def start_wsgi_server(port, addr='', registry=core.REGISTRY): + """Starts a WSGI server for prometheus metrics as a daemon thread.""" + class PrometheusMetricsServer(threading.Thread): + def run(self): + httpd = make_server(addr, port, make_wsgi_app(registry)) + httpd.serve_forever() + t = PrometheusMetricsServer() + t.daemon = True + t.start() + + def generate_latest(registry=core.REGISTRY): '''Returns the metrics from the registry in latest text format as a string.''' output = [] @@ -83,6 +105,16 @@ def write_to_textfile(path, registry): def push_to_gateway(gateway, job, registry, grouping_key=None, timeout=None): '''Push metrics to the given pushgateway. + `gateway` the url for your push gateway. Either of the form + 'http://pushgateway.local', or 'pushgateway.local'. + Scheme defaults to 'http' if none is provided + `job` is the job label to be attached to all pushed metrics + `registry` is an instance of CollectorRegistry + `grouping_key` please see the pushgateway documentation for details. + Defaults to None + `timeout` is how long push will attempt to connect before giving up. + Defaults to None + This overwrites all metrics with the same job and grouping_key. This uses the PUT HTTP method.''' _use_gateway('PUT', gateway, job, registry, grouping_key, timeout) @@ -91,6 +123,16 @@ def push_to_gateway(gateway, job, registry, grouping_key=None, timeout=None): def pushadd_to_gateway(gateway, job, registry, grouping_key=None, timeout=None): '''PushAdd metrics to the given pushgateway. + `gateway` the url for your push gateway. Either of the form + 'http://pushgateway.local', or 'pushgateway.local'. + Scheme defaults to 'http' if none is provided + `job` is the job label to be attached to all pushed metrics + `registry` is an instance of CollectorRegistry + `grouping_key` please see the pushgateway documentation for details. + Defaults to None + `timeout` is how long push will attempt to connect before giving up. + Defaults to None + This replaces metrics with the same name, job and grouping_key. This uses the POST HTTP method.''' _use_gateway('POST', gateway, job, registry, grouping_key, timeout) @@ -99,13 +141,24 @@ def pushadd_to_gateway(gateway, job, registry, grouping_key=None, timeout=None): def delete_from_gateway(gateway, job, grouping_key=None, timeout=None): '''Delete metrics from the given pushgateway. + `gateway` the url for your push gateway. Either of the form + 'http://pushgateway.local', or 'pushgateway.local'. + Scheme defaults to 'http' if none is provided + `job` is the job label to be attached to all pushed metrics + `grouping_key` please see the pushgateway documentation for details. + Defaults to None + `timeout` is how long delete will attempt to connect before giving up. + Defaults to None + This deletes metrics with the given job and grouping_key. This uses the DELETE HTTP method.''' _use_gateway('DELETE', gateway, job, None, grouping_key, timeout) def _use_gateway(method, gateway, job, registry, grouping_key, timeout): - url = 'http://{0}/metrics/job/{1}'.format(gateway, quote_plus(job)) + if not (gateway.startswith('http://') or gateway.startswith('https://')): + gateway = 'http://{0}'.format(gateway) + url = '{0}/metrics/job/{1}'.format(gateway, quote_plus(job)) data = b'' if method != 'DELETE': diff --git a/prometheus_client/process_collector.py b/prometheus_client/process_collector.py index 56aa6561..b6e75cbc 100644 --- a/prometheus_client/process_collector.py +++ b/prometheus_client/process_collector.py @@ -3,16 +3,14 @@ from __future__ import unicode_literals import os -import time -import threading from . import core try: - import resource - _PAGESIZE = resource.getpagesize() + import resource + _PAGESIZE = resource.getpagesize() except ImportError: - # Not Unix - _PAGESIZE = 4096 + # Not Unix + _PAGESIZE = 4096 class ProcessCollector(object): @@ -38,7 +36,7 @@ def __init__(self, namespace='', pid=lambda: 'self', proc='/proc', registry=core except IOError: pass if registry: - registry.register(self) + registry.register(self) def _boot_time(self): with open(os.path.join(self._proc, 'stat')) as stat: @@ -50,27 +48,25 @@ def collect(self): if not self._btime: return [] - try: - pid = os.path.join(self._proc, str(self._pid()).strip()) - except: - # File likely didn't exist, fail silently. - raise - return [] + pid = os.path.join(self._proc, str(self._pid()).strip()) result = [] try: with open(os.path.join(pid, 'stat')) as stat: parts = (stat.read().split(')')[-1].split()) vmem = core.GaugeMetricFamily(self._prefix + 'virtual_memory_bytes', - 'Virtual memory size in bytes.', value=float(parts[20])) - rss = core.GaugeMetricFamily(self._prefix + 'resident_memory_bytes', 'Resident memory size in bytes.', value=float(parts[21]) * _PAGESIZE) + 'Virtual memory size in bytes.', value=float(parts[20])) + rss = core.GaugeMetricFamily(self._prefix + 'resident_memory_bytes', 'Resident memory size in bytes.', + value=float(parts[21]) * _PAGESIZE) start_time_secs = float(parts[19]) / self._ticks start_time = core.GaugeMetricFamily(self._prefix + 'start_time_seconds', - 'Start time of the process since unix epoch in seconds.', value=start_time_secs + self._btime) + 'Start time of the process since unix epoch in seconds.', + value=start_time_secs + self._btime) utime = float(parts[11]) / self._ticks stime = float(parts[12]) / self._ticks cpu = core.CounterMetricFamily(self._prefix + 'cpu_seconds_total', - 'Total user and system CPU time spent in seconds.', value=utime + stime) + 'Total user and system CPU time spent in seconds.', + value=utime + stime) result.extend([vmem, rss, start_time, cpu]) except IOError: pass @@ -80,10 +76,12 @@ def collect(self): for line in limits: if line.startswith('Max open file'): max_fds = core.GaugeMetricFamily(self._prefix + 'max_fds', - 'Maximum number of open file descriptors.', value=float(line.split()[3])) + 'Maximum number of open file descriptors.', + value=float(line.split()[3])) break open_fds = core.GaugeMetricFamily(self._prefix + 'open_fds', - 'Number of open file descriptors.', len(os.listdir(os.path.join(pid, 'fd')))) + 'Number of open file descriptors.', + len(os.listdir(os.path.join(pid, 'fd')))) result.extend([open_fds, max_fds]) except IOError: pass diff --git a/prometheus_client/twisted/__init__.py b/prometheus_client/twisted/__init__.py new file mode 100644 index 00000000..87e0b8a6 --- /dev/null +++ b/prometheus_client/twisted/__init__.py @@ -0,0 +1,3 @@ +from ._exposition import MetricsResource + +__all__ = ['MetricsResource'] diff --git a/prometheus_client/twisted/_exposition.py b/prometheus_client/twisted/_exposition.py new file mode 100644 index 00000000..66c548c7 --- /dev/null +++ b/prometheus_client/twisted/_exposition.py @@ -0,0 +1,18 @@ +from __future__ import absolute_import, unicode_literals +from .. import REGISTRY, generate_latest, CONTENT_TYPE_LATEST + +from twisted.web.resource import Resource + + +class MetricsResource(Resource): + """ + Twisted ``Resource`` that serves prometheus metrics. + """ + isLeaf = True + + def __init__(self, registry=REGISTRY): + self.registry = registry + + def render_GET(self, request): + request.setHeader(b'Content-Type', CONTENT_TYPE_LATEST.encode('ascii')) + return generate_latest(self.registry) diff --git a/setup.py b/setup.py index 14f34895..858a54c0 100644 --- a/setup.py +++ b/setup.py @@ -3,7 +3,7 @@ setup( name = "prometheus_client", - version = "0.0.13", + version = "0.0.14", author = "Brian Brazil", author_email = "brian.brazil@robustperception.io", description = ("Python client for the Prometheus monitoring system."), @@ -11,13 +11,25 @@ license = "Apache Software License 2.0", keywords = "prometheus monitoring instrumentation client", url = "https://github.com/prometheus/client_python", - packages=['prometheus_client', 'prometheus_client.bridge'], + packages=['prometheus_client', 'prometheus_client.bridge', 'prometheus_client.twisted'], + extras_requires={ + 'twisted': ['twisted'], + }, test_suite="tests", classifiers=[ "Development Status :: 4 - Beta", "Intended Audience :: Developers", "Intended Audience :: Information Technology", "Intended Audience :: System Administrators", + "Programming Language :: Python", + "Programming Language :: Python :: 2", + "Programming Language :: Python :: 2.6", + "Programming Language :: Python :: 2.7", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.4", + "Programming Language :: Python :: 3.5", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", "Topic :: System :: Monitoring", "License :: OSI Approved :: Apache Software License", ], diff --git a/tests/test_client.py b/tests/test_core.py similarity index 93% rename from tests/test_client.py rename to tests/test_core.py index 83b15e86..ce3d3467 100644 --- a/tests/test_client.py +++ b/tests/test_core.py @@ -1,4 +1,6 @@ from __future__ import unicode_literals + +import inspect import os import threading import time @@ -29,6 +31,8 @@ def f(r): else: raise TypeError + self.assertEqual((["r"], None, None, None), inspect.getargspec(f)) + try: f(False) except TypeError: @@ -77,6 +81,8 @@ def test_function_decorator(self): def f(): self.assertEqual(1, self.registry.get_sample_value('g')) + self.assertEqual(([], None, None, None), inspect.getargspec(f)) + f() self.assertEqual(0, self.registry.get_sample_value('g')) @@ -102,6 +108,8 @@ def test_function_decorator(self): def f(): time.sleep(.001) + self.assertEqual(([], None, None, None), inspect.getargspec(f)) + f() self.assertNotEqual(0, self.registry.get_sample_value('g')) @@ -131,6 +139,8 @@ def test_function_decorator(self): def f(): pass + self.assertEqual(([], None, None, None), inspect.getargspec(f)) + f() self.assertEqual(1, self.registry.get_sample_value('s_count')) @@ -207,6 +217,8 @@ def test_function_decorator(self): def f(): pass + self.assertEqual(([], None, None, None), inspect.getargspec(f)) + f() self.assertEqual(1, self.registry.get_sample_value('h_count')) self.assertEqual(1, self.registry.get_sample_value('h_bucket', {'le': '+Inf'})) @@ -249,7 +261,7 @@ def test_incorrect_label_count_raises(self): def test_labels_coerced_to_string(self): self.counter.labels(None).inc() - self.counter.labels({'l': None}).inc() + self.counter.labels(l=None).inc() self.assertEqual(2, self.registry.get_sample_value('c', {'l': 'None'})) self.counter.remove(None) @@ -259,26 +271,27 @@ def test_non_string_labels_raises(self): class Test(object): __str__ = None self.assertRaises(TypeError, self.counter.labels, Test()) - self.assertRaises(TypeError, self.counter.labels, {'l': Test()}) + self.assertRaises(TypeError, self.counter.labels, l=Test()) def test_namespace_subsystem_concatenated(self): c = Counter('c', 'help', namespace='a', subsystem='b', registry=self.registry) c.inc() self.assertEqual(1, self.registry.get_sample_value('a_b_c')) - def test_labels_by_dict(self): - self.counter.labels({'l': 'x'}).inc() + def test_labels_by_kwarg(self): + self.counter.labels(l='x').inc() self.assertEqual(1, self.registry.get_sample_value('c', {'l': 'x'})) - self.assertRaises(ValueError, self.counter.labels, {'l': 'x', 'm': 'y'}) - self.assertRaises(ValueError, self.counter.labels, {'m': 'y'}) - self.assertRaises(ValueError, self.counter.labels, {}) - self.two_labels.labels({'a': 'x', 'b': 'y'}).inc() + self.assertRaises(ValueError, self.counter.labels, l='x', m='y') + self.assertRaises(ValueError, self.counter.labels, m='y') + self.assertRaises(ValueError, self.counter.labels) + self.two_labels.labels(a='x', b='y').inc() self.assertEqual(1, self.registry.get_sample_value('two', {'a': 'x', 'b': 'y'})) - self.assertRaises(ValueError, self.two_labels.labels, {'a': 'x', 'b': 'y', 'c': 'z'}) - self.assertRaises(ValueError, self.two_labels.labels, {'a': 'x', 'c': 'z'}) - self.assertRaises(ValueError, self.two_labels.labels, {'b': 'y', 'c': 'z'}) - self.assertRaises(ValueError, self.two_labels.labels, {'c': 'z'}) - self.assertRaises(ValueError, self.two_labels.labels, {}) + self.assertRaises(ValueError, self.two_labels.labels, a='x', b='y', c='z') + self.assertRaises(ValueError, self.two_labels.labels, a='x', c='z') + self.assertRaises(ValueError, self.two_labels.labels, b='y', c='z') + self.assertRaises(ValueError, self.two_labels.labels, c='z') + self.assertRaises(ValueError, self.two_labels.labels) + self.assertRaises(ValueError, self.two_labels.labels, {'a': 'x'}, b='y') def test_invalid_names_raise(self): self.assertRaises(ValueError, Counter, '', 'help') diff --git a/tests/test_exposition.py b/tests/test_exposition.py index be555f98..fa5cfdb0 100644 --- a/tests/test_exposition.py +++ b/tests/test_exposition.py @@ -1,9 +1,13 @@ from __future__ import unicode_literals -import os + +import sys import threading -import time -import unittest +if sys.version_info < (2, 7): + # We need the skip decorators from unittest2 on Python 2.6. + import unittest2 as unittest +else: + import unittest from prometheus_client import Gauge, Counter, Summary, Histogram, Metric from prometheus_client import CollectorRegistry, generate_latest @@ -38,6 +42,7 @@ def test_summary(self): s.labels('c', 'd').observe(17) self.assertEqual(b'# HELP ss A summary\n# TYPE ss summary\nss_count{a="c",b="d"} 1.0\nss_sum{a="c",b="d"} 17.0\n', generate_latest(self.registry)) + @unittest.skipIf(sys.version_info < (2, 7), "Test requires Python 2.7+.") def test_histogram(self): s = Histogram('hh', 'A histogram', registry=self.registry) s.observe(0.05) @@ -102,7 +107,7 @@ def do_PUT(self): do_POST = do_PUT do_DELETE = do_PUT - httpd = HTTPServer(('', 0), TestHandler) + httpd = HTTPServer(('localhost', 0), TestHandler) self.address = ':'.join([str(x) for x in httpd.server_address]) class TestServer(threading.Thread): def run(self): @@ -160,6 +165,10 @@ def test_delete_with_groupingkey(self): self.assertEqual(self.requests[0][0].headers.get('content-type'), CONTENT_TYPE_LATEST) self.assertEqual(self.requests[0][1], b'') + @unittest.skipIf( + sys.platform == "darwin", + "instance_ip_grouping_key() does not work on macOS." + ) def test_instance_ip_grouping_key(self): self.assertTrue('' != instance_ip_grouping_key()['instance']) diff --git a/tests/graphite_bridge.py b/tests/test_graphite_bridge.py similarity index 100% rename from tests/graphite_bridge.py rename to tests/test_graphite_bridge.py diff --git a/tests/test_parser.py b/tests/test_parser.py index f207a221..73d30447 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -1,6 +1,12 @@ from __future__ import unicode_literals -import unittest +import sys + +if sys.version_info < (2, 7): + # We need the skip decorators from unittest2 on Python 2.6. + import unittest2 as unittest +else: + import unittest from prometheus_client.core import * from prometheus_client.exposition import * @@ -36,7 +42,7 @@ def test_summary_quantiles(self): a_count 1 a_sum 2 a{quantile="0.5"} 0.7 -""") +""") # The Python client doesn't support quantiles, but we # still need to be able to parse them. metric_family = SummaryMetricFamily("a", "help", count_value=1, sum_value=2) @@ -131,6 +137,7 @@ def test_escaping(self): metric_family.add_metric(["b\\a\\z"], 2) self.assertEqual([metric_family], list(families)) + @unittest.skipIf(sys.version_info < (2, 7), "Test requires Python 2.7+.") def test_roundtrip(self): text = """# HELP go_gc_duration_seconds A summary of the GC invocation durations. # TYPE go_gc_duration_seconds summary diff --git a/tests/test_process_collector.py b/tests/test_process_collector.py index 6455d43f..bee9263a 100644 --- a/tests/test_process_collector.py +++ b/tests/test_process_collector.py @@ -2,9 +2,9 @@ import os import unittest - from prometheus_client import CollectorRegistry, ProcessCollector + class TestProcessCollector(unittest.TestCase): def setUp(self): self.registry = CollectorRegistry() diff --git a/tests/test_twisted.py b/tests/test_twisted.py new file mode 100644 index 00000000..79abb590 --- /dev/null +++ b/tests/test_twisted.py @@ -0,0 +1,53 @@ +from __future__ import absolute_import, unicode_literals + +import sys + +if sys.version_info < (2, 7): + from unittest2 import skipUnless +else: + from unittest import skipUnless + +from prometheus_client import Counter +from prometheus_client import CollectorRegistry, generate_latest + +try: + from prometheus_client.twisted import MetricsResource + + from twisted.trial.unittest import TestCase + from twisted.web.server import Site + from twisted.web.resource import Resource + from twisted.internet import reactor + from twisted.web.client import Agent + from twisted.web.client import readBody + HAVE_TWISTED = True +except ImportError: + from unittest import TestCase + HAVE_TWISTED = False + + +class MetricsResourceTest(TestCase): + @skipUnless(HAVE_TWISTED, "Don't have twisted installed.") + def setUp(self): + self.registry = CollectorRegistry() + + def test_reports_metrics(self): + """ + ``MetricsResource`` serves the metrics from the provided registry. + """ + c = Counter('cc', 'A counter', registry=self.registry) + c.inc() + + root = Resource() + root.putChild(b'metrics', MetricsResource(registry=self.registry)) + server = reactor.listenTCP(0, Site(root)) + self.addCleanup(server.stopListening) + + agent = Agent(reactor) + port = server.getHost().port + url = "http://localhost:{port}/metrics".format(port=port) + d = agent.request(b"GET", url.encode("ascii")) + + d.addCallback(readBody) + d.addCallback(self.assertEqual, generate_latest(self.registry)) + + return d diff --git a/tox.ini b/tox.ini new file mode 100644 index 00000000..160cc22b --- /dev/null +++ b/tox.ini @@ -0,0 +1,40 @@ +[tox] +envlist = coverage-clean,py26,py27,py34,py35,pypy,{py27,py35}-nooptionals,coverage-report + + +[base] +deps = + coverage + pytest + +[testenv] +deps = + {[base]deps} + py26: unittest2 + ; Twisted does not support Python 2.6. + {py27,py34,py35,pypy}: twisted +commands = coverage run --parallel -m pytest {posargs} + + +; Ensure test suite passes if no optional dependencies are present. +[testenv:py27-nooptionals] +deps = {[base]deps} +commands = coverage run --parallel -m pytest {posargs} + +[testenv:py35-nooptionals] +deps = {[base]deps} +commands = coverage run --parallel -m pytest {posargs} + + +[testenv:coverage-clean] +deps = coverage +skip_install = true +commands = coverage erase + + +[testenv:coverage-report] +deps = coverage +skip_install = true +commands = + coverage combine + coverage report \ No newline at end of file