Repository: prometheus/client_python
Pull request #403 (2019-05-08 11:24:35)

Compact types.MethodType between 2 and 3
In Python2, the `MethodType` constructor requires the obj‘s class to be passed. Compact code is copied from `six` https://github.com/benjaminp/six/blob/8da94b8a153ceb0d6417d76729ba75e80eaa75c1/six.py#L545-L556


Patch:
diff --git a/.travis.yml b/.travis.yml
index 91506bf..02ddd05 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -19,8 +19,14 @@ matrix:
       env: TOXENV=py35
     - python: "3.6"
       env: TOXENV=py36
-    - python: "3.6"
-      env: TOXENV=py36-nooptionals
+    - python: "3.7"
+      env: TOXENV=py37
+      dist: xenial
+      sudo: true
+    - python: "3.7"
+      env: TOXENV=py37-nooptionals
+      dist: xenial
+      sudo: true
     - python: "pypy"
       env: TOXENV=pypy
     - python: "pypy3"
diff --git a/README.md b/README.md
index 46671b7..aeffb06 100644
--- a/README.md
+++ b/README.md
@@ -461,7 +461,7 @@ return an empty list.
 
 ## Multiprocess Mode (Gunicorn)
 
-Prometheus client libaries presume a threaded model, where metrics are shared
+Prometheus client libraries presume a threaded model, where metrics are shared
 across workers. This doesn't work so well for languages such as Python where
 it's common to have processes rather than threads to handle large workloads.
 
diff --git a/prometheus_client/metrics.py b/prometheus_client/metrics.py
index b7c5e5a..b5dfe95 100644
--- a/prometheus_client/metrics.py
+++ b/prometheus_client/metrics.py
@@ -14,6 +14,10 @@ from .utils import floatToGoString, INF
 
 if sys.version_info > (3,):
     unicode = str
+    create_bound_method = types.MethodType
+else:
+    def create_bound_method(func, obj):
+        return types.MethodType(func, obj, obj.__class__)
 
 
 def _build_full_name(metric_type, name, namespace, subsystem, unit):
@@ -369,7 +373,7 @@ class Gauge(MetricWrapperBase):
         def samples(self):
             return (('', {}, float(f())),)
 
-        self._child_samples = types.MethodType(samples, self)
+        self._child_samples = create_bound_method(samples, self)
 
     def _child_samples(self):
         return (('', {}, self._value.get()),)
diff --git a/prometheus_client/mmap_dict.py b/prometheus_client/mmap_dict.py
index 679597f..274b738 100644
--- a/prometheus_client/mmap_dict.py
+++ b/prometheus_client/mmap_dict.py
@@ -22,6 +22,29 @@ def _pack_integer(data, pos, value):
     data[pos:pos + 4] = _pack_integer_func(value)
 
 
+def _read_all_values(data, used=0):
+    """Yield (key, value, pos). No locking is performed."""
+
+    if used <= 0:
+        # If not valid `used` value is passed in, read it from the file.
+        used = _unpack_integer(data, 0)[0]
+
+    pos = 8
+
+    while pos < used:
+        encoded_len = _unpack_integer(data, pos)[0]
+        # check we are not reading beyond bounds
+        if encoded_len + pos > used:
+            raise RuntimeError('Read beyond file size detected, file is corrupted.')
+        pos += 4
+        encoded_key = data[pos : pos + encoded_len]
+        padded_len = encoded_len + (8 - (encoded_len + 4) % 8)
+        pos += padded_len
+        value = _unpack_double(data, pos)[0]
+        yield encoded_key.decode('utf-8'), value, pos
+        pos += 8
+
+
 class MmapedDict(object):
     """A dict of doubles, backed by an mmapped file.
 
@@ -37,9 +60,11 @@ class MmapedDict(object):
     def __init__(self, filename, read_mode=False):
         self._f = open(filename, 'rb' if read_mode else 'a+b')
         self._fname = filename
-        if os.fstat(self._f.fileno()).st_size == 0:
+        capacity = os.fstat(self._f.fileno()).st_size
+        if capacity == 0:
             self._f.truncate(_INITIAL_MMAP_SIZE)
-        self._capacity = os.fstat(self._f.fileno()).st_size
+            capacity = _INITIAL_MMAP_SIZE
+        self._capacity = capacity
         self._m = mmap.mmap(self._f.fileno(), self._capacity,
                             access=mmap.ACCESS_READ if read_mode else mmap.ACCESS_WRITE)
 
@@ -53,6 +78,17 @@ class MmapedDict(object):
                 for key, _, pos in self._read_all_values():
                     self._positions[key] = pos
 
+    @staticmethod
+    def read_all_values_from_file(filename):
+        with open(filename, 'rb') as infp:
+            # Read the first block of data, including the first 4 bytes which tell us
+            # how much of the file (which is preallocated to _INITIAL_MMAP_SIZE bytes) is occupied.
+            data = infp.read(65535)
+            used = _unpack_integer(data, 0)[0]
+            if used > len(data):  # Then read in the rest, if needed.
+                data += infp.read(used - len(data))
+        return _read_all_values(data, used)
+
     def _init_value(self, key):
         """Initialize a value. Lock must be held by caller."""
         encoded = key.encode('utf-8')
@@ -72,31 +108,10 @@ class MmapedDict(object):
 
     def _read_all_values(self):
         """Yield (key, value, pos). No locking is performed."""
-
-        pos = 8
-
-        # cache variables to local ones and prevent attributes lookup
-        # on every loop iteration
-        used = self._used
-        data = self._m
-        unpack_from = struct.unpack_from
-
-        while pos < used:
-            encoded_len = _unpack_integer(data, pos)[0]
-            # check we are not reading beyond bounds
-            if encoded_len + pos > used:
-                msg = 'Read beyond file size detected, %s is corrupted.'
-                raise RuntimeError(msg % self._fname)
-            pos += 4
-            encoded = unpack_from(('%ss' % encoded_len).encode(), data, pos)[0]
-            padded_len = encoded_len + (8 - (encoded_len + 4) % 8)
-            pos += padded_len
-            value = _unpack_double(data, pos)[0]
-            yield encoded.decode('utf-8'), value, pos
-            pos += 8
+        return _read_all_values(data=self._m, used=self._used)
 
     def read_all_values(self):
-        """Yield (key, value, pos). No locking is performed."""
+        """Yield (key, value). No locking is performed."""
         for k, v, _ in self._read_all_values():
             yield k, v
 
diff --git a/prometheus_client/multiprocess.py b/prometheus_client/multiprocess.py
index e34ced0..5961207 100644
--- a/prometheus_client/multiprocess.py
+++ b/prometheus_client/multiprocess.py
@@ -12,6 +12,13 @@ from .mmap_dict import MmapedDict
 from .samples import Sample
 from .utils import floatToGoString
 
+try:  # Python3
+    FileNotFoundError
+except NameError:  # Python >= 2.5
+    FileNotFoundError = IOError
+
+MP_METRIC_HELP = 'Multiprocess metric'
+
 
 class MultiProcessCollector(object):
     """Collector for files for multi-process mode."""
@@ -33,18 +40,40 @@ class MultiProcessCollector(object):
         But if writing the merged data back to mmap files, use
         accumulate=False to avoid compound accumulation.
         """
+        metrics = MultiProcessCollector._read_metrics(files)
+        return MultiProcessCollector._accumulate_metrics(metrics, accumulate)
+
+    @staticmethod
+    def _read_metrics(files):
         metrics = {}
+        key_cache = {}
+
+        def _parse_key(key):
+            val = key_cache.get(key)
+            if not val:
+                metric_name, name, labels = json.loads(key)
+                labels_key = tuple(sorted(labels.items()))
+                val = key_cache[key] = (metric_name, name, labels, labels_key)
+            return val
+
         for f in files:
             parts = os.path.basename(f).split('_')
             typ = parts[0]
-            d = MmapedDict(f, read_mode=True)
-            for key, value in d.read_all_values():
-                metric_name, name, labels = json.loads(key)
-                labels_key = tuple(sorted(labels.items()))
+            try:
+                file_values = MmapedDict.read_all_values_from_file(f)
+            except FileNotFoundError:
+                if typ == 'gauge' and parts[1] in ('liveall', 'livesum'):
+                    # Those files can disappear between the glob of collect
+                    # and now (via a mark_process_dead call) so don't fail if
+                    # the file is missing
+                    continue
+                raise
+            for key, value, pos in file_values:
+                metric_name, name, labels, labels_key = _parse_key(key)
 
                 metric = metrics.get(metric_name)
                 if metric is None:
-                    metric = Metric(metric_name, 'Multiprocess metric', typ)
+                    metric = Metric(metric_name, MP_METRIC_HELP, typ)
                     metrics[metric_name] = metric
 
                 if typ == 'gauge':
@@ -54,43 +83,47 @@ class MultiProcessCollector(object):
                 else:
                     # The duplicates and labels are fixed in the next for.
                     metric.add_sample(name, labels_key, value)
-            d.close()
+        return metrics
 
+    @staticmethod
+    def _accumulate_metrics(metrics, accumulate):
         for metric in metrics.values():
             samples = defaultdict(float)
-            buckets = {}
+            buckets = defaultdict(lambda: defaultdict(float))
+            samples_setdefault = samples.setdefault
             for s in metric.samples:
-                name, labels, value = s.name, s.labels, s.value
+                name, labels, value, timestamp, exemplar = s
                 if metric.type == 'gauge':
-                    without_pid = tuple(l for l in labels if l[0] != 'pid')
+                    without_pid_key = (name, tuple([l for l in labels if l[0] != 'pid']))
                     if metric._multiprocess_mode == 'min':
-                        current = samples.setdefault((name, without_pid), value)
+                        current = samples_setdefault(without_pid_key, value)
                         if value < current:
-                            samples[(s.name, without_pid)] = value
+                            samples[without_pid_key] = value
                     elif metric._multiprocess_mode == 'max':
-                        current = samples.setdefault((name, without_pid), value)
+                        current = samples_setdefault(without_pid_key, value)
                         if value > current:
-                            samples[(s.name, without_pid)] = value
+                            samples[without_pid_key] = value
                     elif metric._multiprocess_mode == 'livesum':
-                        samples[(name, without_pid)] += value
+                        samples[without_pid_key] += value
                     else:  # all/liveall
                         samples[(name, labels)] = value
 
                 elif metric.type == 'histogram':
-                    bucket = tuple(float(l[1]) for l in labels if l[0] == 'le')
-                    if bucket:
-                        # _bucket
-                        without_le = tuple(l for l in labels if l[0] != 'le')
-                        buckets.setdefault(without_le, {})
-                        buckets[without_le].setdefault(bucket[0], 0.0)
-                        buckets[without_le][bucket[0]] += value
-                    else:
+                    # A for loop with early exit is faster than a genexpr
+                    # or a listcomp that ends up building unnecessary things
+                    for l in labels:
+                        if l[0] == 'le':
+                            bucket_value = float(l[1])
+                            # _bucket
+                            without_le = tuple(l for l in labels if l[0] != 'le')
+                            buckets[without_le][bucket_value] += value
+                            break
+                    else:  # did not find the `le` key
                         # _sum/_count
-                        samples[(s.name, labels)] += value
-
+                        samples[(name, labels)] += value
                 else:
                     # Counter and Summary.
-                    samples[(s.name, labels)] += value
+                    samples[(name, labels)] += value
 
             # Accumulate bucket values.
             if metric.type == 'histogram':
diff --git a/prometheus_client/openmetrics/parser.py b/prometheus_client/openmetrics/parser.py
index 93ca864..9fca8e6 100644
--- a/prometheus_client/openmetrics/parser.py
+++ b/prometheus_client/openmetrics/parser.py
@@ -3,6 +3,7 @@
 from __future__ import unicode_literals
 
 import math
+import re
 
 from ..metrics_core import Metric, METRIC_LABEL_NAME_RE
 from ..samples import Exemplar, Sample, Timestamp
@@ -24,6 +25,24 @@ def text_string_to_metric_families(text):
         yield metric_family
 
 
+ESCAPE_SEQUENCES = {
+    '\\\\': '\\',
+    '\\n': '\n',
+    '\\"': '"',
+}
+
+
+def _replace_escape_sequence(match):
+    return ESCAPE_SEQUENCES[match.group(0)]
+
+
+ESCAPING_RE = re.compile(r'\\[\\n"]')
+
+
+def _replace_escaping(s):
+    return ESCAPING_RE.sub(_replace_escape_sequence, s)
+
+
 def _unescape_help(text):
     result = []
     slash = False
@@ -83,14 +102,23 @@ def _parse_timestamp(timestamp):
             return ts
 
 
-def _parse_labels(it, text):
+def _is_character_escaped(s, charpos):
+    num_bslashes = 0
+    while (charpos > num_bslashes and
+           s[charpos - 1 - num_bslashes] == '\\'):
+        num_bslashes += 1
+    return num_bslashes % 2 == 1
+
+
+def _parse_labels_with_state_machine(text):
     # The { has already been parsed.
     state = 'startoflabelname'
     labelname = []
     labelvalue = []
     labels = {}
+    labels_len = 0
 
-    for char in it:
+    for char in text:
         if state == 'startoflabelname':
             if char == '}':
                 state = 'endoflabels'
@@ -141,37 +169,123 @@ def _parse_labels(it, text):
                 break
             else:
                 raise ValueError("Invalid line: " + text)
-    return labels
+        labels_len += 1
+    return labels, labels_len
+
+
+def _parse_labels(text):
+    labels = {}
+
+    # Raise error if we don't have valid labels
+    if text and "=" not in text:
+        raise ValueError
+
+    # Copy original labels
+    sub_labels = text
+    try:
+        # Process one label at a time
+        while sub_labels:
+            # The label name is before the equal
+            value_start = sub_labels.index("=")
+            label_name = sub_labels[:value_start]
+            sub_labels = sub_labels[value_start + 1:]
+
+            # Check for missing quotes 
+            if not sub_labels or sub_labels[0] != '"':
+                raise ValueError
+
+            # The first quote is guaranteed to be after the equal
+            value_substr = sub_labels[1:]
+
+            # Check for extra commas
+            if not label_name or label_name[0] == ',':
+                raise ValueError
+            if not value_substr or value_substr[-1] == ',':
+                raise ValueError
+
+            # Find the last unescaped quote
+            i = 0
+            while i < len(value_substr):
+                i = value_substr.index('"', i)
+                if not _is_character_escaped(value_substr[:i], i):
+                    break
+                i += 1
+
+            # The label value is inbetween the first and last quote
+            quote_end = i + 1
+            label_value = sub_labels[1:quote_end]
+            # Replace escaping if needed
+            if "\\" in label_value:
+                label_value = _replace_escaping(label_value)
+            labels[label_name] = label_value
+
+            # Remove the processed label from the sub-slice for next iteration
+            sub_labels = sub_labels[quote_end + 1:]
+            if sub_labels.startswith(","):
+                next_comma = 1
+            else:
+                next_comma = 0
+            sub_labels = sub_labels[next_comma:]
+
+            # Check for missing commas
+            if sub_labels and next_comma == 0:
+                raise ValueError
+            
+        return labels
+
+    except ValueError:
+        raise ValueError("Invalid labels: " + text)
 
 
 def _parse_sample(text):
-    name = []
-    value = []
+    # Detect the labels in the text
+    label_start = text.find("{")
+    if label_start == -1:
+        # We don't have labels
+        name_end = text.index(" ")
+        name = text[:name_end]
+        # Parse the remaining text after the name
+        remaining_text = text[name_end + 1:]
+        value, timestamp, exemplar = _parse_remaining_text(remaining_text)
+        return Sample(name, {}, value, timestamp, exemplar)
+    # The name is before the labels
+    name = text[:label_start]
+    seperator = " # "
+    if text.count(seperator) == 0:
+        # Line doesn't contain an exemplar
+        # We can use `rindex` to find `label_end`
+        label_end = text.rindex("}")
+        label = text[label_start + 1:label_end]
+        labels = _parse_labels(label)
+    else:
+        # Line potentially contains an exemplar
+        # Fallback to parsing labels with a state machine
+        labels, labels_len = _parse_labels_with_state_machine(text[label_start + 1:])
+        label_end = labels_len + len(name)      
+    # Parsing labels succeeded, continue parsing the remaining text
+    remaining_text = text[label_end + 2:]
+    value, timestamp, exemplar = _parse_remaining_text(remaining_text)
+    return Sample(name, labels, value, timestamp, exemplar)
+
+
+def _parse_remaining_text(text):
+    split_text = text.split(" ", 1)
+    val = _parse_value(split_text[0])
+    if len(split_text) == 1:
+        # We don't have timestamp or exemplar
+        return val, None, None  
+
     timestamp = []
-    labels = {}
     exemplar_value = []
     exemplar_timestamp = []
     exemplar_labels = None
 
-    state = 'name'
+    state = 'timestamp'
+    text = split_text[1]
 
     it = iter(text)
     for char in it:
-        if state == 'name':
-            if char == '{':
-                labels = _parse_labels(it, text)
-                # Space has already been parsed.
-                state = 'value'
-            elif char == ' ':
-                state = 'value'
-            else:
-                name.append(char)
-        elif state == 'value':
-            if char == ' ':
-                state = 'timestamp'
-            else:
-                value.append(char)
-        elif state == 'timestamp':
+        if state == 'timestamp':
             if char == '#' and not timestamp:
                 state = 'exemplarspace'
             elif char == ' ':
@@ -190,13 +304,23 @@ def _parse_sample(text):
                 raise ValueError("Invalid line: " + text)
         elif state == 'exemplarstartoflabels':
             if char == '{':
-                exemplar_labels = _parse_labels(it, text)
-                # Space has already been parsed.
+                label_start, label_end = text.index("{"), text.rindex("}")
+                exemplar_labels = _parse_labels(text[label_start + 1:label_end])
+                state = 'exemplarparsedlabels'
+            else:
+                raise ValueError("Invalid line: " + text)
+        elif state == 'exemplarparsedlabels':
+            if char == '}':
+                state = 'exemplarvaluespace'
+        elif state == 'exemplarvaluespace':
+            if char == ' ':
                 state = 'exemplarvalue'
             else:
                 raise ValueError("Invalid line: " + text)
         elif state == 'exemplarvalue':
-            if char == ' ':
+            if char == ' ' and not exemplar_value:
+                raise ValueError("Invalid line: " + text)
+            elif char == ' ':
                 state = 'exemplartimestamp'
             else:
                 exemplar_value.append(char)
@@ -212,13 +336,9 @@ def _parse_sample(text):
         raise ValueError("Invalid line: " + text)
 
     # Incomplete exemplar.
-    if state in ['exemplarhash', 'exemplarspace', 'exemplarstartoflabels']:
+    if state in ['exemplarhash', 'exemplarspace', 'exemplarstartoflabels', 'exemplarparsedlabels']:
         raise ValueError("Invalid line: " + text)
 
-    if not value:
-        raise ValueError("Invalid line: " + text)
-    value = ''.join(value)
-    val = _parse_value(value)
     ts = _parse_timestamp(timestamp)
     exemplar = None
     if exemplar_labels is not None:
@@ -231,7 +351,7 @@ def _parse_sample(text):
             _parse_timestamp(exemplar_timestamp),
         )
 
-    return Sample(''.join(name), labels, val, ts, exemplar)
+    return val, ts, exemplar
 
 
 def _group_for_sample(sample, name, typ):
diff --git a/setup.py b/setup.py
index 73b5048..1b728c9 100644
--- a/setup.py
+++ b/setup.py
@@ -2,7 +2,7 @@ from setuptools import setup
 
 setup(
     name="prometheus_client",
-    version="0.6.0",
+    version="0.7.0",
     author="Brian Brazil",
     author_email="brian.brazil@robustperception.io",
     description="Python client for the Prometheus monitoring system.",
@@ -35,6 +35,7 @@ setup(
         "Programming Language :: Python :: 3.4",
         "Programming Language :: Python :: 3.5",
         "Programming Language :: Python :: 3.6",
+        "Programming Language :: Python :: 3.7",
         "Programming Language :: Python :: Implementation :: CPython",
         "Programming Language :: Python :: Implementation :: PyPy",
         "Topic :: System :: Monitoring",
diff --git a/tox.ini b/tox.ini
index 8f7f81b..7e88c42 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,5 +1,5 @@
 [tox]
-envlist = coverage-clean,py26,py27,py34,py35,py36,pypy,pypy3,{py27,py36}-nooptionals,coverage-report,flake8
+envlist = coverage-clean,py26,py27,py34,py35,py36,py37,pypy,pypy3,{py27,py37}-nooptionals,coverage-report,flake8
 
 
 [base]
@@ -29,10 +29,9 @@ deps =
 [testenv]
 deps =
     {[base]deps}
-    {py27,py34,py35,py36,pypy,pypy3}: twisted
+    {py27,py37,pypy,pypy3}: twisted
 commands = coverage run --parallel -m pytest {posargs}
 
-
 ; Ensure test suite passes if no optional dependencies are present.
 [testenv:py27-nooptionals]
 deps = 
@@ -40,11 +39,9 @@ deps =
     futures
 commands = coverage run --parallel -m pytest {posargs}
 
-[testenv:py36-nooptionals]
-deps = {[base]deps}
+[testenv:py37-nooptionals]
 commands = coverage run --parallel -m pytest {posargs}
 
-
 [testenv:coverage-clean]
 deps = coverage
 skip_install = true


Test patch:
diff --git a/tests/openmetrics/test_parser.py b/tests/openmetrics/test_parser.py
index 0f51a18..815f749 100644
--- a/tests/openmetrics/test_parser.py
+++ b/tests/openmetrics/test_parser.py
@@ -374,6 +374,93 @@ b_total 2 1234567890
         b.add_metric([], 2, timestamp=Timestamp(1234567890, 0))
         self.assertEqual([a, b], list(families))
 
+    def test_hash_in_label_value(self):
+        families = text_string_to_metric_families("""# TYPE a counter
+# HELP a help
+a_total{foo="foo # bar"} 1
+a_total{foo="} foo # bar # "} 1
+# EOF
+""")
+        a = CounterMetricFamily("a", "help", labels=["foo"])
+        a.add_metric(["foo # bar"], 1)
+        a.add_metric(["} foo # bar # "], 1)
+        self.assertEqual([a], list(families))
+
+    def test_exemplars_with_hash_in_label_values(self):
+        families = text_string_to_metric_families("""# TYPE a histogram
+# HELP a help
+a_bucket{le="1.0",foo="bar # "} 0 # {a="b",foo="bar # bar"} 0.5
+a_bucket{le="2.0",foo="bar # "} 2 # {a="c",foo="bar # bar"} 0.5
+a_bucket{le="+Inf",foo="bar # "} 3 # {a="d",foo="bar # bar"} 4
+# EOF
+""")
+        hfm = HistogramMetricFamily("a", "help")
+        hfm.add_sample("a_bucket", {"le": "1.0", "foo": "bar # "}, 0.0, None, Exemplar({"a": "b", "foo": "bar # bar"}, 0.5))
+        hfm.add_sample("a_bucket", {"le": "2.0", "foo": "bar # "}, 2.0, None, Exemplar({"a": "c", "foo": "bar # bar"}, 0.5))
+        hfm.add_sample("a_bucket", {"le": "+Inf", "foo": "bar # "}, 3.0, None, Exemplar({"a": "d", "foo": "bar # bar"}, 4))
+        self.assertEqual([hfm], list(families))
+
+    @unittest.skipIf(sys.version_info < (3, 3), "Test requires Python 3.3+.")
+    def test_fallback_to_state_machine_label_parsing(self):
+        from unittest.mock import patch
+        from prometheus_client.openmetrics.parser import _parse_sample
+
+        parse_sample_function = "prometheus_client.openmetrics.parser._parse_sample"
+        parse_labels_function = "prometheus_client.openmetrics.parser._parse_labels"
+        parse_remaining_function = "prometheus_client.openmetrics.parser._parse_remaining_text"
+        state_machine_function = "prometheus_client.openmetrics.parser._parse_labels_with_state_machine"
+
+        parse_sample_return_value = Sample("a_total", {"foo": "foo # bar"}, 1)
+        with patch(parse_sample_function, return_value=parse_sample_return_value) as mock:
+            families = text_string_to_metric_families("""# TYPE a counter
+# HELP a help
+a_total{foo="foo # bar"} 1
+# EOF
+""")
+            a = CounterMetricFamily("a", "help", labels=["foo"])
+            a.add_metric(["foo # bar"], 1)
+            self.assertEqual([a], list(families))
+            mock.assert_called_once_with('a_total{foo="foo # bar"} 1')
+
+        # First fallback case
+        state_machine_return_values = [{"foo": "foo # bar"}, len('foo="foo # bar"}')]
+        parse_remaining_values = [1, None, None]
+        with patch(parse_labels_function) as mock1:
+            with patch(state_machine_function, return_value=state_machine_return_values) as mock2:
+                with patch(parse_remaining_function, return_value=parse_remaining_values) as mock3:
+                    sample = _parse_sample('a_total{foo="foo # bar"} 1')
+                    s = Sample("a_total", {"foo": "foo # bar"}, 1)
+                    self.assertEqual(s, sample)
+                    mock1.assert_not_called()
+                    mock2.assert_called_once_with('foo="foo # bar"} 1')
+                    mock3.assert_called_once_with('1')
+
+        # Second fallback case
+        state_machine_return_values = [{"le": "1.0"}, len('le="1.0"}')]
+        parse_remaining_values = [0.0, Timestamp(123, 0), Exemplar({"a": "b"}, 0.5)]
+        with patch(parse_labels_function) as mock1:
+            with patch(state_machine_function, return_value=state_machine_return_values) as mock2:
+                with patch(parse_remaining_function, return_value=parse_remaining_values) as mock3:
+                    sample = _parse_sample('a_bucket{le="1.0"} 0 123 # {a="b"} 0.5')
+                    s = Sample("a_bucket", {"le": "1.0"}, 0.0, Timestamp(123, 0), Exemplar({"a": "b"}, 0.5))
+                    self.assertEqual(s, sample)
+                    mock1.assert_not_called()
+                    mock2.assert_called_once_with('le="1.0"} 0 123 # {a="b"} 0.5')
+                    mock3.assert_called_once_with('0 123 # {a="b"} 0.5')
+
+        # No need to fallback case
+        parse_labels_return_values = {"foo": "foo#bar"}
+        parse_remaining_values = [1, None, None]
+        with patch(parse_labels_function, return_value=parse_labels_return_values) as mock1:
+            with patch(state_machine_function) as mock2:
+                with patch(parse_remaining_function, return_value=parse_remaining_values) as mock3:
+                    sample = _parse_sample('a_total{foo="foo#bar"} 1')
+                    s = Sample("a_total", {"foo": "foo#bar"}, 1)
+                    self.assertEqual(s, sample)
+                    mock1.assert_called_once_with('foo="foo#bar"')
+                    mock2.assert_not_called()
+                    mock3.assert_called_once_with('1')
+
     @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.
@@ -453,6 +540,12 @@ foo_created 1.520430000123e+09
             ('a{a=1} 1\n# EOF\n'),
             ('a{a="1} 1\n# EOF\n'),
             ('a{a=\'1\'} 1\n# EOF\n'),
+            # Missing equal or label value.
+            ('a{a} 1\n# EOF\n'),
+            ('a{a"value"} 1\n# EOF\n'),
+            ('a{a""} 1\n# EOF\n'),
+            ('a{a=} 1\n# EOF\n'),
+            ('a{a="} 1\n# EOF\n'),
             # Missing or extra commas.
             ('a{a="1"b="2"} 1\n# EOF\n'),
             ('a{a="1",,b="2"} 1\n# EOF\n'),
@@ -523,6 +616,9 @@ foo_created 1.520430000123e+09
             ('# TYPE a histogram\na_sum 1 # {a="b"} 0.5\n# EOF\n'),
             ('# TYPE a gaugehistogram\na_sum 1 # {a="b"} 0.5\n# EOF\n'),
             ('# TYPE a_bucket gauge\na_bucket 1 # {a="b"} 0.5\n# EOF\n'),
+            # Exemplars on unallowed metric types.
+            ('# TYPE a counter\na_total 1 # {a="b"} 1\n# EOF\n'),
+            ('# TYPE a gauge\na 1 # {a="b"} 1\n# EOF\n'),
             # Bad stateset/info values.
             ('# TYPE a stateset\na 2\n# EOF\n'),
             ('# TYPE a info\na 2\n# EOF\n'),
diff --git a/tests/test_gc_collector.py b/tests/test_gc_collector.py
index fd10f27..c0cda88 100644
--- a/tests/test_gc_collector.py
+++ b/tests/test_gc_collector.py
@@ -1,8 +1,8 @@
 from __future__ import unicode_literals
 
 import gc
-import sys
 import platform
+import sys
 
 if sys.version_info < (2, 7):
     # We need the skip decorators from unittest2 on Python 2.6.
diff --git a/tests/test_multiprocess.py b/tests/test_multiprocess.py
index be03152..ba5fc23 100644
--- a/tests/test_multiprocess.py
+++ b/tests/test_multiprocess.py
@@ -270,6 +270,16 @@ class TestMultiProcess(unittest.TestCase):
 
         self.assertEqual(metrics['h'].samples, expected_histogram)
 
+    def test_missing_gauge_file_during_merge(self):
+        # These files don't exist, just like if mark_process_dead(9999999) had been
+        # called during self.collector.collect(), after the glob found it
+        # but before the merge actually happened.
+        # This should not raise and return no metrics
+        self.assertFalse(self.collector.merge([
+            os.path.join(self.tempdir, 'gauge_liveall_9999999.db'),
+            os.path.join(self.tempdir, 'gauge_livesum_9999999.db'),
+        ]))
+
 
 class TestMmapedDict(unittest.TestCase):
     def setUp(self):