Repository: prometheus/client_python
Pull request #54 (2015-10-02 19:48:02)

WIP: Text format parser for Python


@beorn7 Would you mind taking a peek at this to see if I've missed anything major? Main code is in parser.py, the rest is surrounding changes for this to make sense and have the right data structures.

The motivation is that Zalando's Zmon has a partial implementation of the text parser, so I'd like to offer them an official option.


Patch:
diff --git a/prometheus_client/bridge/graphite.py b/prometheus_client/bridge/graphite.py
index 582f65b..b2bf5ad 100644
--- a/prometheus_client/bridge/graphite.py
+++ b/prometheus_client/bridge/graphite.py
@@ -19,11 +19,10 @@ def _sanitize(s):
 
 
 class _RegularPush(threading.Thread):
-    def __init__(self, pusher, interval, prefix):
+    def __init__(self, pusher, interval):
         super(_RegularPush, self).__init__()
         self._pusher = pusher
         self._interval = interval
-        self._prefix = prefix
 
     def run(self):
         wait_until = time.time()
@@ -38,7 +37,7 @@ class _RegularPush(threading.Thread):
                 # time.sleep can return early.
                 time.sleep(wait_until - now)
             try:
-                self._pusher.push(prefix=self._prefix)
+                self._pusher.push()
             except IOError:
                 logging.exception("Push failed")
 
@@ -50,31 +49,26 @@ class GraphiteBridge(object):
         self._timeout = timeout_seconds
         self._time = _time
 
-    def push(self, prefix=''):
+    def push(self):
         now = int(self._time.time())
         output = []
-
-        prefixstr = ''
-        if prefix:
-            prefixstr = prefix + '.'
-
         for metric in self._registry.collect():
-            for name, labels, value in metric._samples:
+            for name, labels, value in metric.samples:
                 if labels:
                     labelstr = '.' + '.'.join(
                         ['{0}.{1}'.format(
-                            _sanitize(k), _sanitize(v))
-                         for k, v in sorted(labels.items())])
+                             _sanitize(k), _sanitize(v))
+                             for k, v in sorted(labels.items())])
                 else:
                     labelstr = ''
-                output.append('{0}{1}{2} {3} {4}\n'.format(
-                    prefixstr, _sanitize(name), labelstr, float(value), now))
+                output.append('{0}{1} {2} {3}\n'.format(
+                    _sanitize(name), labelstr, float(value), now))
 
         conn = socket.create_connection(self._address, self._timeout)
         conn.sendall(''.join(output).encode('ascii'))
         conn.close()
 
-    def start(self, interval=60.0, prefix=''):
-        t = _RegularPush(self, interval, prefix)
+    def start(self, interval=60.0):
+        t = _RegularPush(self, interval)
         t.daemon = True
         t.start()
diff --git a/prometheus_client/core.py b/prometheus_client/core.py
index b13d519..14b5394 100644
--- a/prometheus_client/core.py
+++ b/prometheus_client/core.py
@@ -3,6 +3,7 @@
 from __future__ import unicode_literals
 
 import copy
+import math
 import re
 import time
 import types
@@ -61,7 +62,7 @@ class CollectorRegistry(object):
         if labels is None:
             labels = {}
         for metric in self.collect():
-            for n, l, value in metric._samples:
+            for n, l, value in metric.samples:
                 if n == name and l == labels:
                     return value
         return None
@@ -74,18 +75,145 @@ _METRIC_TYPES = ('counter', 'gauge', 'summary', 'histogram', 'untyped')
 
 
 class Metric(object):
-    '''A single metric and it's samples.'''
+    '''A single metric family and its samples.
+
+    This is intended only for internal use by the instrumentation client.
+
+    Custom collectors should use GaugeMetricFamily, CounterMetricFamily
+    and SummaryMetricFamily instead.
+    '''
     def __init__(self, name, documentation, typ):
-        self._name = name
-        self._documentation = documentation
+        self.name = name
+        self.documentation = documentation
         if typ not in _METRIC_TYPES:
             raise ValueError('Invalid metric type: ' + typ)
-        self._type = typ
-        self._samples = []
+        self.type = typ
+        self.samples = []
 
-    '''Add a sample to the metric'''
     def add_sample(self, name, labels, value):
-        self._samples.append((name, labels, value))
+        '''Add a sample to the metric.
+
+        Internal-only, do not use.'''
+        self.samples.append((name, labels, value))
+
+    def __eq__(self, other):
+        return (isinstance(other, Metric)
+                and self.name == other.name
+                and self.documentation == other.documentation
+                and self.type == other.type
+                and self.samples == other.samples)
+
+
+class CounterMetricFamily(Metric):
+    '''A single counter and its samples.
+
+    For use by custom collectors.
+    '''
+    def __init__(self, name, documentation, value=None, labels=None):
+        Metric.__init__(self, name, documentation, 'counter')
+        if labels is not None and value is not None:
+            raise ValueError('Can only specify at most one of value and labels.')
+        if labels is None:
+          labels = []
+        self._labelnames = labels
+        if value is not None:
+          self.add_metric([], value)
+
+    def add_metric(self, labels, value):
+        '''Add a metric to the metric family.
+
+        Args:
+          labels: A list of label values
+          value: The value of the metric.
+        '''
+        self.samples.append((self.name, dict(zip(self._labelnames, labels)), value))
+
+
+class GaugeMetricFamily(Metric):
+    '''A single gauge and its samples.
+
+    For use by custom collectors.
+    '''
+    def __init__(self, name, documentation, value=None, labels=None):
+        Metric.__init__(self, name, documentation, 'gauge')
+        if labels is not None and value is not None:
+            raise ValueError('Can only specify at most one of value and labels.')
+        if labels is None:
+          labels = []
+        self._labelnames = labels
+        if value is not None:
+          self.add_metric([], value)
+
+    def add_metric(self, labels, value):
+        '''Add a metric to the metric family.
+
+        Args:
+          labels: A list of label values
+          value: A float
+        '''
+        self.samples.append((self.name, dict(zip(self._labelnames, labels)), value))
+
+
+class SummaryMetricFamily(Metric):
+    '''A single summary and its samples.
+
+    For use by custom collectors.
+    '''
+    def __init__(self, name, documentation, count_value=None, sum_value=None, labels=None):
+        Metric.__init__(self, name, documentation, 'summary')
+        if (sum_value is None) != (count_value is None):
+            raise ValueError('count_value and sum_value must be provided together.')
+        if labels is not None and count_value is not None:
+            raise ValueError('Can only specify at most one of value and labels.')
+        if labels is None:
+          labels = []
+        self._labelnames = labels
+        if count_value is not None:
+          self.add_metric([], count_value, sum_value)
+
+    def add_metric(self, labels, count_value, sum_value):
+        '''Add a metric to the metric family.
+
+        Args:
+          labels: A list of label values
+          count_value: The count value of the metric.
+          sum_value: The sum value of the metric.
+        '''
+        self.samples.append((self.name + '_count', dict(zip(self._labelnames, labels)), count_value))
+        self.samples.append((self.name + '_sum', dict(zip(self._labelnames, labels)), sum_value))
+
+
+class HistogramMetricFamily(Metric):
+    '''A single histogram and its samples.
+
+    For use by custom collectors.
+    '''
+    def __init__(self, name, documentation, buckets=None, sum_value=None, labels=None):
+        Metric.__init__(self, name, documentation, 'histogram')
+        if (sum_value is None) != (buckets is None):
+            raise ValueError('buckets and sum_value must be provided together.')
+        if labels is not None and buckets is not None:
+            raise ValueError('Can only specify at most one of buckets and labels.')
+        if labels is None:
+          labels = []
+        self._labelnames = labels
+        if buckets is not None:
+          self.add_metric([], buckets, sum_value)
+
+    def add_metric(self, labels, buckets, sum_value):
+        '''Add a metric to the metric family.
+
+        Args:
+          labels: A list of label values
+          buckets: A list of pairs of bucket names and values.
+              The buckets must be sorted, and +Inf present.
+          sum_value: The sum value of the metric.
+        '''
+        for bucket, value in buckets:
+          self.samples.append((self.name + '_bucket', dict(list(zip(self._labelnames, labels)) + [('le', bucket)]), value))
+        # +Inf is last and provides the count value.
+        self.samples.append((self.name + '_count', dict(zip(self._labelnames, labels)), buckets[-1][1]))
+        self.samples.append((self.name + '_sum', dict(zip(self._labelnames, labels)), sum_value))
 
 
 class _MutexValue(object):
@@ -271,7 +399,7 @@ class Counter(object):
 @_MetricWrapper
 class Gauge(object):
     '''Gauge metric, to report instantaneous values.
-    
+
      Examples of Gauges include:
         Inprogress requests
         Number of items in a queue
@@ -450,6 +578,8 @@ def _floatToGoString(d):
         return '+Inf'
     elif d == _MINUS_INF:
         return '-Inf'
+    elif math.isnan(d):
+        return 'NaN'
     else:
         return repr(float(d))
 
diff --git a/prometheus_client/exposition.py b/prometheus_client/exposition.py
index e2f10a1..ff72d91 100644
--- a/prometheus_client/exposition.py
+++ b/prometheus_client/exposition.py
@@ -6,7 +6,6 @@ import os
 import socket
 import time
 import threading
-from contextlib import closing
 
 from . import core
 try:
@@ -32,9 +31,9 @@ def generate_latest(registry=core.REGISTRY):
     output = []
     for metric in registry.collect():
         output.append('# HELP {0} {1}'.format(
-            metric._name, metric._documentation.replace('\\', r'\\').replace('\n', r'\n')))
-        output.append('\n# TYPE {0} {1}\n'.format(metric._name, metric._type))
-        for name, labels, value in metric._samples:
+            metric.name, metric.documentation.replace('\\', r'\\').replace('\n', r'\n')))
+        output.append('\n# TYPE {0} {1}\n'.format(metric.name, metric.type))
+        for name, labels, value in metric.samples:
             if labels:
                 labelstr = '{{{0}}}'.format(','.join(
                     ['{0}="{1}"'.format(
@@ -126,6 +125,6 @@ def _use_gateway(method, gateway, job, registry, grouping_key, timeout):
 
 def instance_ip_grouping_key():
     '''Grouping key with instance set to the IP Address of this host.'''
-    with closing(socket.socket(socket.AF_INET, socket.SOCK_DGRAM)) as s:
-        s.connect(('localhost', 0))
-        return {'instance': s.getsockname()[0]}
+    s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+    s.connect(('localhost', 0))
+    return {'instance': s.getsockname()[0]}
diff --git a/prometheus_client/parser.py b/prometheus_client/parser.py
new file mode 100644
index 0000000..4ca3d7c
--- /dev/null
+++ b/prometheus_client/parser.py
@@ -0,0 +1,224 @@
+#!/usr/bin/python
+
+from __future__ import unicode_literals
+
+try:
+    import StringIO
+except ImportError:
+    # Python 3
+    import io as StringIO
+
+from . import core
+
+
+def text_string_to_metric_families(text):
+    """Parse Prometheus text format from a string.
+
+    See text_fd_to_metric_families.
+    """
+    for metric_family in text_fd_to_metric_families(StringIO.StringIO(text)):
+      yield metric_family
+
+
+def _unescape_help(text):
+    result = []
+    slash = False
+
+    for char in text:
+        if slash:
+            if char == '\\':
+                result.append('\\')
+            elif char == 'n':
+                result.append('\n')
+            else:
+                result.append('\\' + char)
+            slash = False
+        else:
+          if char == '\\':
+              slash = True
+          else:
+              result.append(char)
+
+    if slash:
+        result.append('\\')
+
+    return ''.join(result)
+
+
+def _parse_sample(text):
+    name = []
+    labelname = []
+    labelvalue = []
+    value = []
+    labels = {}
+
+    state = 'name'
+
+    for char in text:
+        if state == 'name':
+            if char == '{':
+                state = 'startoflabelname'
+            elif char == ' ' or char == '\t':
+                state = 'endofname'
+            else:
+                name.append(char)
+        elif state == 'endofname':
+            if char == ' ' or char == '\t':
+                pass
+            elif char == '{':
+                state = 'startoflabelname'
+            else:
+                value.append(char)
+                state = 'value'
+        elif state == 'startoflabelname':
+            if char == ' ' or char == '\t':
+                pass
+            elif char == '}':
+                state = 'endoflabels'
+            else:
+                state = 'labelname'
+                labelname.append(char)
+        elif state == 'labelname':
+            if char == '=':
+                state = 'labelvaluequote'
+            elif char == ' ' or char == '\t':
+                state = 'labelvalueequals'
+            else:
+                labelname.append(char)
+        elif state == 'labelvalueequals':
+            if char == '=':
+                state = 'labelvaluequote'
+            elif char == ' ' or char == '\t':
+                pass
+            else:
+                raise ValueError("Invalid line: " + text)
+        elif state == 'labelvaluequote':
+            if char == '"':
+                state = 'labelvalue'
+            elif char == ' ' or char == '\t':
+                pass
+            else:
+                raise ValueError("Invalid line: " + text)
+        elif state == 'labelvalue':
+            if char == '\\':
+                state = 'labelvalueslash'
+            elif char == '"':
+                labels[''.join(labelname)] = ''.join(labelvalue)
+                labelname = []
+                labelvalue = []
+                state = 'nextlabel'
+            else:
+                labelvalue.append(char)
+        elif state == 'labelvalueslash':
+            state = 'labelvalue'
+            if char == '\\':
+                labelvalue.append('\\')
+            elif char == 'n':
+                labelvalue.append('\n')
+            elif char == '"':
+                labelvalue.append('"')
+            else:
+                labelvalue.append('\\' + char)
+        elif state == 'nextlabel':
+            if char == ',':
+                state = 'labelname'
+            elif char == '}':
+                state = 'endoflabels'
+            elif char == ' ' or char == '\t':
+                pass
+            else:
+                raise ValueError("Invalid line: " + text)
+        elif state == 'endoflabels':
+            if char == ' ' or char == '\t':
+                pass
+            else:
+                value.append(char)
+                state = 'value'
+        elif state == 'value':
+            if char == ' ' or char == '\t':
+                # Timestamps are not supported, halt
+                break
+            else:
+                value.append(char)
+    return (''.join(name), labels, float(''.join(value)))
+    
+
+def text_fd_to_metric_families(fd):
+    """Parse Prometheus text format from a file descriptor.
+
+    This is a laxer parser than the main Go parser,
+    so successful parsing does not imply that the parsed
+    text meets the specification.
+
+    Yields core.Metric's.
+    """
+    name = ''
+    documentation = ''
+    typ = 'untyped'
+    samples = []
+    allowed_names = []
+
+    def build_metric(name, documentation, typ, samples):
+        metric = core.Metric(name, documentation, typ)
+        metric.samples = samples
+        return metric
+
+    for line in fd:
+        line = line.strip()
+
+        if line.startswith('#'):
+            parts = line.split(None, 3)
+            if len(parts) < 2:
+                continue
+            if parts[1] == 'HELP':
+                if parts[2] != name:
+                    if name != '':
+                        yield build_metric(name, documentation, typ, samples)
+                    # New metric
+                    name = parts[2]
+                    typ = 'untyped'
+                    samples = []
+                    allowed_names = [parts[2]]
+                if len(parts) == 4:
+                  documentation = _unescape_help(parts[3])
+                else:
+                  documentation = ''
+            elif parts[1] == 'TYPE':
+                if parts[2] != name:
+                    if name != '':
+                        yield build_metric(name, documentation, typ, samples)
+                    # New metric
+                    name = parts[2]
+                    documentation = ''
+                    samples = []
+                typ = parts[3]
+                allowed_names = {
+                    'counter': [''],
+                    'gauge': [''],
+                    'summary': ['_count', '_sum', ''],
+                    'histogram': ['_count', '_sum', '_bucket'],
+                    }.get(typ, [parts[2]])
+                allowed_names = [name + n for n in allowed_names]
+            else:
+                # Ignore other comment tokens
+                pass
+        elif line == '':
+            # Ignore blank lines
+            pass
+        else:
+            sample = _parse_sample(line)
+            if sample[0] not in allowed_names:
+                  if name != '':
+                      yield build_metric(name, documentation, typ, samples)
+                  # New metric, yield immediately as untyped singleton
+                  name = ''
+                  documentation = ''
+                  typ = 'untyped'
+                  samples = []
+                  allowed_names = []
+                  yield build_metric(sample[0], documentation, typ, [sample])
+            else:
+              samples.append(sample)
+
+    if name != '':
+        yield build_metric(name, documentation, typ, samples)
diff --git a/prometheus_client/process_collector.py b/prometheus_client/process_collector.py
index cacaaac..5c906c9 100644
--- a/prometheus_client/process_collector.py
+++ b/prometheus_client/process_collector.py
@@ -61,32 +61,29 @@ class ProcessCollector(object):
         try:
             with open(os.path.join(pid, 'stat')) as stat:
                 parts = (stat.read().split(')')[-1].split())
-            vmem = core.Metric(self._prefix + 'virtual_memory_bytes', 'Virtual memory size in bytes', 'gauge')
-            vmem.add_sample(self._prefix + 'virtual_memory_bytes', {}, float(parts[20]))
-            rss = core.Metric(self._prefix + 'resident_memory_bytes', 'Resident memory size in bytes', 'gauge')
-            rss.add_sample(self._prefix + 'resident_memory_bytes', {}, float(parts[21]) * _PAGESIZE)
-            start_time = core.Metric(self._prefix + 'start_time_seconds',
-                                'Start time of the process since unix epoch in seconds.', 'gauge')
+            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)
             start_time_secs = float(parts[19]) / self._ticks
-            start_time.add_sample(self._prefix + 'start_time_seconds',{} , start_time_secs + self._btime)
+            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)
             utime = float(parts[11]) / self._ticks
             stime = float(parts[12]) / self._ticks
-            cpu = core.Metric(self._prefix + 'cpu_seconds_total',
-                         'Total user and system CPU time spent in seconds.', 'counter')
-            cpu.add_sample(self._prefix + 'cpu_seconds_total', {}, utime + stime)
+            cpu = core.CounterMetricFamily(self._prefix + 'cpu_seconds_total',
+                    'Total user and system CPU time spent in seconds.', value=utime + stime)
             result.extend([vmem, rss, start_time, cpu])
         except IOError:
             pass
 
         try:
-            max_fds = core.Metric(self._prefix + 'max_fds', 'Maximum number of open file descriptors.', 'gauge')
             with open(os.path.join(pid, 'limits')) as limits:
                 for line in limits:
                     if line.startswith('Max open file'):
-                        max_fds.add_sample(self._prefix + 'max_fds', {}, float(line.split()[3]))
+                        max_fds = core.GaugeMetricFamily(self._prefix + 'max_fds',
+                                'Maximum number of open file descriptors.', value=float(line.split()[3]))
                         break
-            open_fds = core.Metric(self._prefix + 'open_fds', 'Number of open file descriptors.', 'gauge')
-            open_fds.add_sample(self._prefix + 'open_fds', {}, len(os.listdir(os.path.join(pid, 'fd'))))
+            open_fds = core.GaugeMetricFamily(self._prefix + 'open_fds',
+                    'Number of open file descriptors.', len(os.listdir(os.path.join(pid, 'fd'))))
             result.extend([open_fds, max_fds])
         except IOError:
             pass


Test patch:
diff --git a/tests/graphite_bridge.py b/tests/graphite_bridge.py
index 0ac2420..7536471 100644
--- a/tests/graphite_bridge.py
+++ b/tests/graphite_bridge.py
@@ -50,15 +50,6 @@ class TestGraphiteBridge(unittest.TestCase):
 
         self.assertEqual(b'labels.a.c.b.d 1.0 1434898897\n', self.data)
 
-    def test_prefix(self):
-        labels = Counter('labels', 'help', ['a', 'b'], registry=self.registry)
-        labels.labels('c', 'd').inc()
-
-        self.gb.push(prefix = 'pre.fix')
-        self.t.join()
-
-        self.assertEqual(b'pre.fix.labels.a.c.b.d 1.0 1434898897\n', self.data)
-
     def test_sanitizing(self):
         labels = Counter('labels', 'help', ['a'], registry=self.registry)
         labels.labels('c.:8').inc()
diff --git a/tests/test_client.py b/tests/test_client.py
index 16069c9..83b15e8 100644
--- a/tests/test_client.py
+++ b/tests/test_client.py
@@ -4,21 +4,7 @@ import threading
 import time
 import unittest
 
-
-from prometheus_client import Gauge, Counter, Summary, Histogram, Metric
-from prometheus_client import CollectorRegistry, generate_latest, ProcessCollector
-from prometheus_client import push_to_gateway, pushadd_to_gateway, delete_from_gateway
-from prometheus_client import CONTENT_TYPE_LATEST, instance_ip_grouping_key
-
-try:
-    from BaseHTTPServer import BaseHTTPRequestHandler
-    from BaseHTTPServer import HTTPServer
-except ImportError:
-    # Python 3
-    from http.server import BaseHTTPRequestHandler
-    from http.server import HTTPServer
-
-
+from prometheus_client.core import *
 
 class TestCounter(unittest.TestCase):
     def setUp(self):
@@ -304,178 +290,83 @@ class TestMetricWrapper(unittest.TestCase):
         self.assertRaises(ValueError, Summary, 'c', '', labelnames=['quantile'])
 
 
-class TestGenerateText(unittest.TestCase):
+class TestMetricFamilies(unittest.TestCase):
     def setUp(self):
         self.registry = CollectorRegistry()
 
+    def custom_collector(self, metric_family):
+        class CustomCollector(object):
+            def collect(self):
+                return [metric_family]
+        self.registry.register(CustomCollector())
+
     def test_counter(self):
-        c = Counter('cc', 'A counter', registry=self.registry)
-        c.inc()
-        self.assertEqual(b'# HELP cc A counter\n# TYPE cc counter\ncc 1.0\n', generate_latest(self.registry))
+        self.custom_collector(CounterMetricFamily('c', 'help', value=1))
+        self.assertEqual(1, self.registry.get_sample_value('c', {}))
+
+    def test_counter_labels(self):
+        cmf = CounterMetricFamily('c', 'help', labels=['a', 'c'])
+        cmf.add_metric(['b', 'd'], 2)
+        self.custom_collector(cmf)
+        self.assertEqual(2, self.registry.get_sample_value('c', {'a': 'b', 'c': 'd'}))
 
     def test_gauge(self):
-        g = Gauge('gg', 'A gauge', registry=self.registry)
-        g.set(17)
-        self.assertEqual(b'# HELP gg A gauge\n# TYPE gg gauge\ngg 17.0\n', generate_latest(self.registry))
+        self.custom_collector(GaugeMetricFamily('g', 'help', value=1))
+        self.assertEqual(1, self.registry.get_sample_value('g', {}))
+
+    def test_gauge_labels(self):
+        cmf = GaugeMetricFamily('g', 'help', labels=['a'])
+        cmf.add_metric(['b'], 2)
+        self.custom_collector(cmf)
+        self.assertEqual(2, self.registry.get_sample_value('g', {'a':'b'}))
 
     def test_summary(self):
-        s = Summary('ss', 'A summary', ['a', 'b'], registry=self.registry)
-        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))
-
-    def test_unicode(self):
-        c = Counter('cc', '\u4500', ['l'], registry=self.registry)
-        c.labels('\u4500').inc()
-        self.assertEqual(b'# HELP cc \xe4\x94\x80\n# TYPE cc counter\ncc{l="\xe4\x94\x80"} 1.0\n', generate_latest(self.registry))
-
-    def test_escaping(self):
-        c = Counter('cc', 'A\ncount\\er', ['a'], registry=self.registry)
-        c.labels('\\x\n"').inc(1)
-        self.assertEqual(b'# HELP cc A\\ncount\\\\er\n# TYPE cc counter\ncc{a="\\\\x\\n\\""} 1.0\n', generate_latest(self.registry))
-
-    def test_nonnumber(self):
-        class MyNumber():
-            def __repr__(self):
-              return "MyNumber(123)"
-            def __float__(self):
-              return 123.0
-        class MyCollector():
-            def collect(self):
-                metric = Metric("nonnumber", "Non number", 'untyped')
-                metric.add_sample("nonnumber", {}, MyNumber())
-                yield metric
-        self.registry.register(MyCollector())
-        self.assertEqual(b'# HELP nonnumber Non number\n# TYPE nonnumber untyped\nnonnumber 123.0\n', generate_latest(self.registry))
+        self.custom_collector(SummaryMetricFamily('s', 'help', count_value=1, sum_value=2))
+        self.assertEqual(1, self.registry.get_sample_value('s_count', {}))
+        self.assertEqual(2, self.registry.get_sample_value('s_sum', {}))
 
+    def test_summary_labels(self):
+        cmf = SummaryMetricFamily('s', 'help', labels=['a'])
+        cmf.add_metric(['b'], count_value=1, sum_value=2)
+        self.custom_collector(cmf)
+        self.assertEqual(1, self.registry.get_sample_value('s_count', {'a': 'b'}))
+        self.assertEqual(2, self.registry.get_sample_value('s_sum', {'a': 'b'}))
 
-class TestProcessCollector(unittest.TestCase):
-    def setUp(self):
-        self.registry = CollectorRegistry()
-        self.test_proc = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'proc')
-
-    def test_working(self):
-        collector = ProcessCollector(proc=self.test_proc, pid=lambda: 26231, registry=self.registry)
-        collector._ticks = 100
-
-        self.assertEqual(17.21, self.registry.get_sample_value('process_cpu_seconds_total'))
-        self.assertEqual(56274944.0, self.registry.get_sample_value('process_virtual_memory_bytes'))
-        self.assertEqual(8114176, self.registry.get_sample_value('process_resident_memory_bytes'))
-        self.assertEqual(1418184099.75, self.registry.get_sample_value('process_start_time_seconds'))
-        self.assertEqual(2048.0, self.registry.get_sample_value('process_max_fds'))
-        self.assertEqual(5.0, self.registry.get_sample_value('process_open_fds'))
-        self.assertEqual(None, self.registry.get_sample_value('process_fake_namespace'))
-
-    def test_namespace(self):
-        collector = ProcessCollector(proc=self.test_proc, pid=lambda: 26231, registry=self.registry, namespace='n')
-        collector._ticks = 100
-
-        self.assertEqual(17.21, self.registry.get_sample_value('n_process_cpu_seconds_total'))
-        self.assertEqual(56274944.0, self.registry.get_sample_value('n_process_virtual_memory_bytes'))
-        self.assertEqual(8114176, self.registry.get_sample_value('n_process_resident_memory_bytes'))
-        self.assertEqual(1418184099.75, self.registry.get_sample_value('n_process_start_time_seconds'))
-        self.assertEqual(2048.0, self.registry.get_sample_value('n_process_max_fds'))
-        self.assertEqual(5.0, self.registry.get_sample_value('n_process_open_fds'))
-        self.assertEqual(None, self.registry.get_sample_value('process_cpu_seconds_total'))
-
-    def test_working_584(self):
-        collector = ProcessCollector(proc=self.test_proc, pid=lambda: "584\n", registry=self.registry)
-        collector._ticks = 100
-
-        self.assertEqual(0.0, self.registry.get_sample_value('process_cpu_seconds_total'))
-        self.assertEqual(10395648.0, self.registry.get_sample_value('process_virtual_memory_bytes'))
-        self.assertEqual(634880, self.registry.get_sample_value('process_resident_memory_bytes'))
-        self.assertEqual(1418291667.75, self.registry.get_sample_value('process_start_time_seconds'))
-        self.assertEqual(None, self.registry.get_sample_value('process_max_fds'))
-        self.assertEqual(None, self.registry.get_sample_value('process_open_fds'))
-
-    def test_working_fake_pid(self):
-        collector = ProcessCollector(proc=self.test_proc, pid=lambda: 123, registry=self.registry)
-        collector._ticks = 100
-
-        self.assertEqual(None, self.registry.get_sample_value('process_cpu_seconds_total'))
-        self.assertEqual(None, self.registry.get_sample_value('process_virtual_memory_bytes'))
-        self.assertEqual(None, self.registry.get_sample_value('process_resident_memory_bytes'))
-        self.assertEqual(None, self.registry.get_sample_value('process_start_time_seconds'))
-        self.assertEqual(None, self.registry.get_sample_value('process_max_fds'))
-        self.assertEqual(None, self.registry.get_sample_value('process_open_fds'))
-        self.assertEqual(None, self.registry.get_sample_value('process_fake_namespace'))
-
-
-class TestPushGateway(unittest.TestCase):
-    def setUp(self):
-        self.registry = CollectorRegistry()
-        self.counter = Gauge('g', 'help', registry=self.registry)
-        self.requests = requests = []
-        class TestHandler(BaseHTTPRequestHandler):
-            def do_PUT(self):
-                length = int(self.headers['content-length'])
-                requests.append((self, self.rfile.read(length)))
-                self.send_response(201)
-                self.end_headers()
-
-            do_POST = do_PUT
-            do_DELETE = do_PUT
-
-        httpd = HTTPServer(('', 0), TestHandler)
-        self.address = 'localhost:' + str(httpd.server_address[1])
-        class TestServer(threading.Thread):
-            def run(self):
-                httpd.handle_request()
-        self.server = TestServer()
-        self.server.daemon = True
-        self.server.start()
-
-    def test_push(self):
-        push_to_gateway(self.address, "my_job", self.registry)
-        self.assertEqual(self.requests[0][0].command, 'PUT')
-        self.assertEqual(self.requests[0][0].path, '/job/my_job')
-        self.assertEqual(self.requests[0][0].headers.get('content-type'), CONTENT_TYPE_LATEST)
-        self.assertEqual(self.requests[0][1], b'# HELP g help\n# TYPE g gauge\ng 0.0\n')
-
-    def test_push_with_groupingkey(self):
-        push_to_gateway(self.address, "my_job", self.registry, {'a': 9})
-        self.assertEqual(self.requests[0][0].command, 'PUT')
-        self.assertEqual(self.requests[0][0].path, '/job/my_job/a/9')
-        self.assertEqual(self.requests[0][0].headers.get('content-type'), CONTENT_TYPE_LATEST)
-        self.assertEqual(self.requests[0][1], b'# HELP g help\n# TYPE g gauge\ng 0.0\n')
-
-    def test_push_with_complex_groupingkey(self):
-        push_to_gateway(self.address, "my_job", self.registry, {'a': 9, 'b': 'a/ z'})
-        self.assertEqual(self.requests[0][0].command, 'PUT')
-        self.assertEqual(self.requests[0][0].path, '/job/my_job/a/9/b/a%2F+z')
-        self.assertEqual(self.requests[0][0].headers.get('content-type'), CONTENT_TYPE_LATEST)
-        self.assertEqual(self.requests[0][1], b'# HELP g help\n# TYPE g gauge\ng 0.0\n')
-
-    def test_pushadd(self):
-        pushadd_to_gateway(self.address, "my_job", self.registry)
-        self.assertEqual(self.requests[0][0].command, 'POST')
-        self.assertEqual(self.requests[0][0].path, '/job/my_job')
-        self.assertEqual(self.requests[0][0].headers.get('content-type'), CONTENT_TYPE_LATEST)
-        self.assertEqual(self.requests[0][1], b'# HELP g help\n# TYPE g gauge\ng 0.0\n')
-
-    def test_pushadd_with_groupingkey(self):
-        pushadd_to_gateway(self.address, "my_job", self.registry, {'a': 9})
-        self.assertEqual(self.requests[0][0].command, 'POST')
-        self.assertEqual(self.requests[0][0].path, '/job/my_job/a/9')
-        self.assertEqual(self.requests[0][0].headers.get('content-type'), CONTENT_TYPE_LATEST)
-        self.assertEqual(self.requests[0][1], b'# HELP g help\n# TYPE g gauge\ng 0.0\n')
-
-    def test_delete(self):
-        delete_from_gateway(self.address, "my_job")
-        self.assertEqual(self.requests[0][0].command, 'DELETE')
-        self.assertEqual(self.requests[0][0].path, '/job/my_job')
-        self.assertEqual(self.requests[0][0].headers.get('content-type'), CONTENT_TYPE_LATEST)
-        self.assertEqual(self.requests[0][1], b'')
-
-    def test_delete_with_groupingkey(self):
-        delete_from_gateway(self.address, "my_job", {'a': 9})
-        self.assertEqual(self.requests[0][0].command, 'DELETE')
-        self.assertEqual(self.requests[0][0].path, '/job/my_job/a/9')
-        self.assertEqual(self.requests[0][0].headers.get('content-type'), CONTENT_TYPE_LATEST)
-        self.assertEqual(self.requests[0][1], b'')
-
-    def test_instance_ip_grouping_key(self):
-        self.assertTrue('' != instance_ip_grouping_key()['instance'])
+    def test_histogram(self):
+        self.custom_collector(HistogramMetricFamily('h', 'help', buckets=[('0', 1), ('+Inf', 2)], sum_value=3))
+        self.assertEqual(1, self.registry.get_sample_value('h_bucket', {'le': '0'}))
+        self.assertEqual(2, self.registry.get_sample_value('h_bucket', {'le': '+Inf'}))
+        self.assertEqual(2, self.registry.get_sample_value('h_count', {}))
+        self.assertEqual(3, self.registry.get_sample_value('h_sum', {}))
+
+    def test_histogram_labels(self):
+        cmf = HistogramMetricFamily('h', 'help', labels=['a'])
+        cmf.add_metric(['b'], buckets=[('0', 1), ('+Inf', 2)], sum_value=3)
+        self.custom_collector(cmf)
+        self.assertEqual(1, self.registry.get_sample_value('h_bucket', {'a': 'b', 'le': '0'}))
+        self.assertEqual(2, self.registry.get_sample_value('h_bucket', {'a': 'b', 'le': '+Inf'}))
+        self.assertEqual(2, self.registry.get_sample_value('h_count', {'a': 'b'}))
+        self.assertEqual(3, self.registry.get_sample_value('h_sum', {'a': 'b'}))
+
+    def test_bad_constructors(self):
+        self.assertRaises(ValueError, CounterMetricFamily, 'c', 'help', value=1, labels=[])
+        self.assertRaises(ValueError, CounterMetricFamily, 'c', 'help', value=1, labels=['a'])
+
+        self.assertRaises(ValueError, GaugeMetricFamily, 'g', 'help', value=1, labels=[])
+        self.assertRaises(ValueError, GaugeMetricFamily, 'g', 'help', value=1, labels=['a'])
+
+        self.assertRaises(ValueError, SummaryMetricFamily, 's', 'help', sum_value=1)
+        self.assertRaises(ValueError, SummaryMetricFamily, 's', 'help', count_value=1)
+        self.assertRaises(ValueError, SummaryMetricFamily, 's', 'help', count_value=1, labels=['a'])
+        self.assertRaises(ValueError, SummaryMetricFamily, 's', 'help', sum_value=1, labels=['a'])
+        self.assertRaises(ValueError, SummaryMetricFamily, 's', 'help', count_value=1, sum_value=1, labels=['a'])
+
+        self.assertRaises(ValueError, HistogramMetricFamily, 'h', 'help', sum_value=1)
+        self.assertRaises(ValueError, HistogramMetricFamily, 'h', 'help', buckets={})
+        self.assertRaises(ValueError, HistogramMetricFamily, 'h', 'help', sum_value=1, labels=['a'])
+        self.assertRaises(ValueError, HistogramMetricFamily, 'h', 'help', buckets={}, labels=['a'])
+        self.assertRaises(ValueError, HistogramMetricFamily, 'h', 'help', buckets={}, sum_value=1, labels=['a'])
+        self.assertRaises(KeyError, HistogramMetricFamily, 'h', 'help', buckets={}, sum_value=1)
 
 
 if __name__ == '__main__':
diff --git a/tests/test_exposition.py b/tests/test_exposition.py
new file mode 100644
index 0000000..003884e
--- /dev/null
+++ b/tests/test_exposition.py
@@ -0,0 +1,167 @@
+from __future__ import unicode_literals
+import os
+import threading
+import time
+import unittest
+
+
+from prometheus_client import Gauge, Counter, Summary, Histogram, Metric
+from prometheus_client import CollectorRegistry, generate_latest
+from prometheus_client import push_to_gateway, pushadd_to_gateway, delete_from_gateway
+from prometheus_client import CONTENT_TYPE_LATEST, instance_ip_grouping_key
+
+try:
+    from BaseHTTPServer import BaseHTTPRequestHandler
+    from BaseHTTPServer import HTTPServer
+except ImportError:
+    # Python 3
+    from http.server import BaseHTTPRequestHandler
+    from http.server import HTTPServer
+
+
+class TestGenerateText(unittest.TestCase):
+    def setUp(self):
+        self.registry = CollectorRegistry()
+
+    def test_counter(self):
+        c = Counter('cc', 'A counter', registry=self.registry)
+        c.inc()
+        self.assertEqual(b'# HELP cc A counter\n# TYPE cc counter\ncc 1.0\n', generate_latest(self.registry))
+
+    def test_gauge(self):
+        g = Gauge('gg', 'A gauge', registry=self.registry)
+        g.set(17)
+        self.assertEqual(b'# HELP gg A gauge\n# TYPE gg gauge\ngg 17.0\n', generate_latest(self.registry))
+
+    def test_summary(self):
+        s = Summary('ss', 'A summary', ['a', 'b'], registry=self.registry)
+        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))
+
+    def test_histogram(self):
+        s = Histogram('hh', 'A histogram', registry=self.registry)
+        s.observe(0.05)
+        self.assertEqual(b'''# HELP hh A histogram
+# TYPE hh histogram
+hh_bucket{le="0.005"} 0.0
+hh_bucket{le="0.01"} 0.0
+hh_bucket{le="0.025"} 0.0
+hh_bucket{le="0.05"} 1.0
+hh_bucket{le="0.075"} 1.0
+hh_bucket{le="0.1"} 1.0
+hh_bucket{le="0.25"} 1.0
+hh_bucket{le="0.5"} 1.0
+hh_bucket{le="0.75"} 1.0
+hh_bucket{le="1.0"} 1.0
+hh_bucket{le="2.5"} 1.0
+hh_bucket{le="5.0"} 1.0
+hh_bucket{le="7.5"} 1.0
+hh_bucket{le="10.0"} 1.0
+hh_bucket{le="+Inf"} 1.0
+hh_count 1.0
+hh_sum 0.05
+''', generate_latest(self.registry))
+
+    def test_unicode(self):
+        c = Counter('cc', '\u4500', ['l'], registry=self.registry)
+        c.labels('\u4500').inc()
+        self.assertEqual(b'# HELP cc \xe4\x94\x80\n# TYPE cc counter\ncc{l="\xe4\x94\x80"} 1.0\n', generate_latest(self.registry))
+
+    def test_escaping(self):
+        c = Counter('cc', 'A\ncount\\er', ['a'], registry=self.registry)
+        c.labels('\\x\n"').inc(1)
+        self.assertEqual(b'# HELP cc A\\ncount\\\\er\n# TYPE cc counter\ncc{a="\\\\x\\n\\""} 1.0\n', generate_latest(self.registry))
+
+    def test_nonnumber(self):
+        class MyNumber():
+            def __repr__(self):
+              return "MyNumber(123)"
+            def __float__(self):
+              return 123.0
+        class MyCollector():
+            def collect(self):
+                metric = Metric("nonnumber", "Non number", 'untyped')
+                metric.add_sample("nonnumber", {}, MyNumber())
+                yield metric
+        self.registry.register(MyCollector())
+        self.assertEqual(b'# HELP nonnumber Non number\n# TYPE nonnumber untyped\nnonnumber 123.0\n', generate_latest(self.registry))
+
+
+class TestPushGateway(unittest.TestCase):
+    def setUp(self):
+        self.registry = CollectorRegistry()
+        self.counter = Gauge('g', 'help', registry=self.registry)
+        self.requests = requests = []
+        class TestHandler(BaseHTTPRequestHandler):
+            def do_PUT(self):
+                self.send_response(201)
+                length = int(self.headers['content-length'])
+                requests.append((self, self.rfile.read(length)))
+
+            do_POST = do_PUT
+            do_DELETE = do_PUT
+
+        httpd = HTTPServer(('', 0), TestHandler)
+        self.address = ':'.join([str(x) for x in httpd.server_address])
+        class TestServer(threading.Thread):
+            def run(self):
+                httpd.handle_request()
+        self.server = TestServer()
+        self.server.daemon = True
+        self.server.start()
+
+    def test_push(self):
+        push_to_gateway(self.address, "my_job", self.registry)
+        self.assertEqual(self.requests[0][0].command, 'PUT')
+        self.assertEqual(self.requests[0][0].path, '/job/my_job')
+        self.assertEqual(self.requests[0][0].headers.get('content-type'), CONTENT_TYPE_LATEST)
+        self.assertEqual(self.requests[0][1], b'# HELP g help\n# TYPE g gauge\ng 0.0\n')
+
+    def test_push_with_groupingkey(self):
+        push_to_gateway(self.address, "my_job", self.registry, {'a': 9})
+        self.assertEqual(self.requests[0][0].command, 'PUT')
+        self.assertEqual(self.requests[0][0].path, '/job/my_job/a/9')
+        self.assertEqual(self.requests[0][0].headers.get('content-type'), CONTENT_TYPE_LATEST)
+        self.assertEqual(self.requests[0][1], b'# HELP g help\n# TYPE g gauge\ng 0.0\n')
+
+    def test_push_with_complex_groupingkey(self):
+        push_to_gateway(self.address, "my_job", self.registry, {'a': 9, 'b': 'a/ z'})
+        self.assertEqual(self.requests[0][0].command, 'PUT')
+        self.assertEqual(self.requests[0][0].path, '/job/my_job/a/9/b/a%2F+z')
+        self.assertEqual(self.requests[0][0].headers.get('content-type'), CONTENT_TYPE_LATEST)
+        self.assertEqual(self.requests[0][1], b'# HELP g help\n# TYPE g gauge\ng 0.0\n')
+
+    def test_pushadd(self):
+        pushadd_to_gateway(self.address, "my_job", self.registry)
+        self.assertEqual(self.requests[0][0].command, 'POST')
+        self.assertEqual(self.requests[0][0].path, '/job/my_job')
+        self.assertEqual(self.requests[0][0].headers.get('content-type'), CONTENT_TYPE_LATEST)
+        self.assertEqual(self.requests[0][1], b'# HELP g help\n# TYPE g gauge\ng 0.0\n')
+
+    def test_pushadd_with_groupingkey(self):
+        pushadd_to_gateway(self.address, "my_job", self.registry, {'a': 9})
+        self.assertEqual(self.requests[0][0].command, 'POST')
+        self.assertEqual(self.requests[0][0].path, '/job/my_job/a/9')
+        self.assertEqual(self.requests[0][0].headers.get('content-type'), CONTENT_TYPE_LATEST)
+        self.assertEqual(self.requests[0][1], b'# HELP g help\n# TYPE g gauge\ng 0.0\n')
+
+    def test_delete(self):
+        delete_from_gateway(self.address, "my_job")
+        self.assertEqual(self.requests[0][0].command, 'DELETE')
+        self.assertEqual(self.requests[0][0].path, '/job/my_job')
+        self.assertEqual(self.requests[0][0].headers.get('content-type'), CONTENT_TYPE_LATEST)
+        self.assertEqual(self.requests[0][1], b'')
+
+    def test_delete_with_groupingkey(self):
+        delete_from_gateway(self.address, "my_job", {'a': 9})
+        self.assertEqual(self.requests[0][0].command, 'DELETE')
+        self.assertEqual(self.requests[0][0].path, '/job/my_job/a/9')
+        self.assertEqual(self.requests[0][0].headers.get('content-type'), CONTENT_TYPE_LATEST)
+        self.assertEqual(self.requests[0][1], b'')
+
+    def test_instance_ip_grouping_key(self):
+        self.assertTrue('' != instance_ip_grouping_key()['instance'])
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/tests/test_parser.py b/tests/test_parser.py
new file mode 100644
index 0000000..f207a22
--- /dev/null
+++ b/tests/test_parser.py
@@ -0,0 +1,188 @@
+from __future__ import unicode_literals
+
+import unittest
+
+from prometheus_client.core import *
+from prometheus_client.exposition import *
+from prometheus_client.parser import *
+
+
+class TestParse(unittest.TestCase):
+
+    def test_simple_counter(self):
+        families = text_string_to_metric_families("""# TYPE a counter
+# HELP a help
+a 1
+""")
+        self.assertEqual([CounterMetricFamily("a", "help", value=1)], list(families))
+
+    def test_simple_gauge(self):
+        families = text_string_to_metric_families("""# TYPE a gauge
+# HELP a help
+a 1
+""")
+        self.assertEqual([GaugeMetricFamily("a", "help", value=1)], list(families))
+
+    def test_simple_summary(self):
+        families = text_string_to_metric_families("""# TYPE a summary
+# HELP a help
+a_count 1
+a_sum 2
+""")
+
+    def test_summary_quantiles(self):
+        families = text_string_to_metric_families("""# TYPE a summary
+# HELP a help
+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)
+        metric_family.add_sample("a", {"quantile": "0.5"}, 0.7)
+        self.assertEqual([metric_family], list(families))
+
+    def test_simple_histogram(self):
+        families = text_string_to_metric_families("""# TYPE a histogram
+# HELP a help
+a_bucket{le="1"} 0
+a_bucket{le="+Inf"} 3
+a_count 3
+a_sum 2
+""")
+        self.assertEqual([HistogramMetricFamily("a", "help", sum_value=2, buckets=[("1", 0.0), ("+Inf", 3.0)])], list(families))
+
+    def test_no_metadata(self):
+        families = text_string_to_metric_families("""a 1
+""")
+        metric_family = Metric("a", "", "untyped")
+        metric_family.add_sample("a", {}, 1)
+        self.assertEqual([metric_family], list(families))
+
+    def test_type_help_switched(self):
+        families = text_string_to_metric_families("""# HELP a help
+# TYPE a counter
+a 1
+""")
+        self.assertEqual([CounterMetricFamily("a", "help", value=1)], list(families))
+
+    def test_blank_lines_and_comments(self):
+        families = text_string_to_metric_families("""
+# TYPE a counter
+# FOO a
+# BAR b
+# HELP a help
+
+a 1
+""")
+        self.assertEqual([CounterMetricFamily("a", "help", value=1)], list(families))
+
+    def test_tabs(self):
+        families = text_string_to_metric_families("""#\tTYPE\ta\tcounter
+#\tHELP\ta\thelp
+a\t1
+""")
+        self.assertEqual([CounterMetricFamily("a", "help", value=1)], list(families))
+
+    def test_empty_help(self):
+        families = text_string_to_metric_families("""# TYPE a counter
+# HELP a
+a 1
+""")
+        self.assertEqual([CounterMetricFamily("a", "", value=1)], list(families))
+
+    def test_labels_and_infinite(self):
+        families = text_string_to_metric_families("""# TYPE a counter
+# HELP a help
+a{foo="bar"} +Inf
+a{foo="baz"} -Inf
+""")
+        metric_family = CounterMetricFamily("a", "help", labels=["foo"])
+        metric_family.add_metric(["bar"], core._INF)
+        metric_family.add_metric(["baz"], core._MINUS_INF)
+        self.assertEqual([metric_family], list(families))
+
+    def test_spaces(self):
+        families = text_string_to_metric_families("""# TYPE a counter
+# HELP a help
+a{ foo = "bar" } 1
+a\t\t{\t\tfoo\t\t=\t\t"baz"\t\t}\t\t2
+""")
+        metric_family = CounterMetricFamily("a", "help", labels=["foo"])
+        metric_family.add_metric(["bar"], 1)
+        metric_family.add_metric(["baz"], 2)
+        self.assertEqual([metric_family], list(families))
+
+    def test_nan(self):
+        families = text_string_to_metric_families("""a NaN
+""")
+        # Can't use a simple comparison as nan != nan.
+        self.assertTrue(math.isnan(list(families)[0].samples[0][2]))
+
+    def test_escaping(self):
+        families = text_string_to_metric_families("""# TYPE a counter
+# HELP a he\\n\\\\l\\tp
+a{foo="b\\"a\\nr"} 1
+a{foo="b\\\\a\\z"} 2
+""")
+        metric_family = CounterMetricFamily("a", "he\n\\l\\tp", labels=["foo"])
+        metric_family.add_metric(["b\"a\nr"], 1)
+        metric_family.add_metric(["b\\a\\z"], 2)
+        self.assertEqual([metric_family], list(families))
+
+    def test_roundtrip(self):
+        text = """# HELP go_gc_duration_seconds A summary of the GC invocation durations.
+# TYPE go_gc_duration_seconds summary
+go_gc_duration_seconds{quantile="0"} 0.013300656000000001
+go_gc_duration_seconds{quantile="0.25"} 0.013638736
+go_gc_duration_seconds{quantile="0.5"} 0.013759906
+go_gc_duration_seconds{quantile="0.75"} 0.013962066
+go_gc_duration_seconds{quantile="1"} 0.021383540000000003
+go_gc_duration_seconds_sum 56.12904785
+go_gc_duration_seconds_count 7476.0
+# HELP go_goroutines Number of goroutines that currently exist.
+# TYPE go_goroutines gauge
+go_goroutines 166.0
+# HELP prometheus_local_storage_indexing_batch_duration_milliseconds Quantiles for batch indexing duration in milliseconds.
+# TYPE prometheus_local_storage_indexing_batch_duration_milliseconds summary
+prometheus_local_storage_indexing_batch_duration_milliseconds{quantile="0.5"} NaN
+prometheus_local_storage_indexing_batch_duration_milliseconds{quantile="0.9"} NaN
+prometheus_local_storage_indexing_batch_duration_milliseconds{quantile="0.99"} NaN
+prometheus_local_storage_indexing_batch_duration_milliseconds_sum 871.5665949999999
+prometheus_local_storage_indexing_batch_duration_milliseconds_count 229.0
+# HELP process_cpu_seconds_total Total user and system CPU time spent in seconds.
+# TYPE process_cpu_seconds_total counter
+process_cpu_seconds_total 29323.4
+# HELP process_virtual_memory_bytes Virtual memory size in bytes.
+# TYPE process_virtual_memory_bytes gauge
+process_virtual_memory_bytes 2478268416.0
+# HELP prometheus_build_info A metric with a constant '1' value labeled by version, revision, and branch from which Prometheus was built.
+# TYPE prometheus_build_info gauge
+prometheus_build_info{branch="HEAD",revision="ef176e5",version="0.16.0rc1"} 1.0
+# HELP prometheus_local_storage_chunk_ops_total The total number of chunk operations by their type.
+# TYPE prometheus_local_storage_chunk_ops_total counter
+prometheus_local_storage_chunk_ops_total{type="clone"} 28.0
+prometheus_local_storage_chunk_ops_total{type="create"} 997844.0
+prometheus_local_storage_chunk_ops_total{type="drop"} 1345758.0
+prometheus_local_storage_chunk_ops_total{type="load"} 1641.0
+prometheus_local_storage_chunk_ops_total{type="persist"} 981408.0
+prometheus_local_storage_chunk_ops_total{type="pin"} 32662.0
+prometheus_local_storage_chunk_ops_total{type="transcode"} 980180.0
+prometheus_local_storage_chunk_ops_total{type="unpin"} 32662.0
+"""
+        families = list(text_string_to_metric_families(text))
+
+        class TextCollector(object):
+          def collect(self):
+            return families
+
+
+        registry = CollectorRegistry()
+        registry.register(TextCollector())
+        self.assertEqual(text.encode('utf-8'), generate_latest(registry))
+
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/tests/test_process_collector.py b/tests/test_process_collector.py
new file mode 100644
index 0000000..6455d43
--- /dev/null
+++ b/tests/test_process_collector.py
@@ -0,0 +1,62 @@
+from __future__ import unicode_literals
+import os
+import unittest
+
+
+from prometheus_client import CollectorRegistry, ProcessCollector
+
+class TestProcessCollector(unittest.TestCase):
+    def setUp(self):
+        self.registry = CollectorRegistry()
+        self.test_proc = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'proc')
+
+    def test_working(self):
+        collector = ProcessCollector(proc=self.test_proc, pid=lambda: 26231, registry=self.registry)
+        collector._ticks = 100
+
+        self.assertEqual(17.21, self.registry.get_sample_value('process_cpu_seconds_total'))
+        self.assertEqual(56274944.0, self.registry.get_sample_value('process_virtual_memory_bytes'))
+        self.assertEqual(8114176, self.registry.get_sample_value('process_resident_memory_bytes'))
+        self.assertEqual(1418184099.75, self.registry.get_sample_value('process_start_time_seconds'))
+        self.assertEqual(2048.0, self.registry.get_sample_value('process_max_fds'))
+        self.assertEqual(5.0, self.registry.get_sample_value('process_open_fds'))
+        self.assertEqual(None, self.registry.get_sample_value('process_fake_namespace'))
+
+    def test_namespace(self):
+        collector = ProcessCollector(proc=self.test_proc, pid=lambda: 26231, registry=self.registry, namespace='n')
+        collector._ticks = 100
+
+        self.assertEqual(17.21, self.registry.get_sample_value('n_process_cpu_seconds_total'))
+        self.assertEqual(56274944.0, self.registry.get_sample_value('n_process_virtual_memory_bytes'))
+        self.assertEqual(8114176, self.registry.get_sample_value('n_process_resident_memory_bytes'))
+        self.assertEqual(1418184099.75, self.registry.get_sample_value('n_process_start_time_seconds'))
+        self.assertEqual(2048.0, self.registry.get_sample_value('n_process_max_fds'))
+        self.assertEqual(5.0, self.registry.get_sample_value('n_process_open_fds'))
+        self.assertEqual(None, self.registry.get_sample_value('process_cpu_seconds_total'))
+
+    def test_working_584(self):
+        collector = ProcessCollector(proc=self.test_proc, pid=lambda: "584\n", registry=self.registry)
+        collector._ticks = 100
+
+        self.assertEqual(0.0, self.registry.get_sample_value('process_cpu_seconds_total'))
+        self.assertEqual(10395648.0, self.registry.get_sample_value('process_virtual_memory_bytes'))
+        self.assertEqual(634880, self.registry.get_sample_value('process_resident_memory_bytes'))
+        self.assertEqual(1418291667.75, self.registry.get_sample_value('process_start_time_seconds'))
+        self.assertEqual(None, self.registry.get_sample_value('process_max_fds'))
+        self.assertEqual(None, self.registry.get_sample_value('process_open_fds'))
+
+    def test_working_fake_pid(self):
+        collector = ProcessCollector(proc=self.test_proc, pid=lambda: 123, registry=self.registry)
+        collector._ticks = 100
+
+        self.assertEqual(None, self.registry.get_sample_value('process_cpu_seconds_total'))
+        self.assertEqual(None, self.registry.get_sample_value('process_virtual_memory_bytes'))
+        self.assertEqual(None, self.registry.get_sample_value('process_resident_memory_bytes'))
+        self.assertEqual(None, self.registry.get_sample_value('process_start_time_seconds'))
+        self.assertEqual(None, self.registry.get_sample_value('process_max_fds'))
+        self.assertEqual(None, self.registry.get_sample_value('process_open_fds'))
+        self.assertEqual(None, self.registry.get_sample_value('process_fake_namespace'))
+
+
+if __name__ == '__main__':
+    unittest.main()