Repository: prometheus/client_python
Pull request #1075 (2024-11-22 19:09:23)

Remove Python 3.8 support
Python 3.8 already reached its EOL last month. Remove support and testing of Python 3.8 .


Patch:
diff --git a/.circleci/config.yml b/.circleci/config.yml
index 2605a50..26109fc 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -75,7 +75,6 @@ workflows:
           matrix:
             parameters:
                python:
-                 - "3.8.18"
                  - "3.9.18"
                  - "3.10"
                  - "3.11"
@@ -89,4 +88,4 @@ workflows:
           matrix:
             parameters:
                python:
-                 - "3.8"
+                 - "3.9"
diff --git a/prometheus_client/exposition.py b/prometheus_client/exposition.py
index 4bcf1c7..7427cf9 100644
--- a/prometheus_client/exposition.py
+++ b/prometheus_client/exposition.py
@@ -20,6 +20,7 @@ from wsgiref.simple_server import make_server, WSGIRequestHandler, WSGIServer
 from .openmetrics import exposition as openmetrics
 from .registry import CollectorRegistry, REGISTRY
 from .utils import floatToGoString
+from .validation import _is_valid_legacy_metric_name
 
 __all__ = (
     'CONTENT_TYPE_LATEST',
@@ -247,19 +248,26 @@ start_http_server = start_wsgi_server
 def generate_latest(registry: CollectorRegistry = REGISTRY) -> bytes:
     """Returns the metrics from the registry in latest text format as a string."""
 
-    def sample_line(line):
-        if line.labels:
-            labelstr = '{{{0}}}'.format(','.join(
+    def sample_line(samples):
+        if samples.labels:
+            labelstr = '{0}'.format(','.join(
                 ['{}="{}"'.format(
-                    k, v.replace('\\', r'\\').replace('\n', r'\n').replace('"', r'\"'))
-                    for k, v in sorted(line.labels.items())]))
+                    openmetrics.escape_label_name(k), openmetrics._escape(v))
+                    for k, v in sorted(samples.labels.items())]))
         else:
             labelstr = ''
         timestamp = ''
-        if line.timestamp is not None:
+        if samples.timestamp is not None:
             # Convert to milliseconds.
-            timestamp = f' {int(float(line.timestamp) * 1000):d}'
-        return f'{line.name}{labelstr} {floatToGoString(line.value)}{timestamp}\n'
+            timestamp = f' {int(float(samples.timestamp) * 1000):d}'
+        if _is_valid_legacy_metric_name(samples.name):
+            if labelstr:
+                labelstr = '{{{0}}}'.format(labelstr)
+            return f'{samples.name}{labelstr} {floatToGoString(samples.value)}{timestamp}\n'
+        maybe_comma = ''
+        if labelstr:
+            maybe_comma = ','
+        return f'{{{openmetrics.escape_metric_name(samples.name)}{maybe_comma}{labelstr}}} {floatToGoString(samples.value)}{timestamp}\n'
 
     output = []
     for metric in registry.collect():
@@ -282,8 +290,8 @@ def generate_latest(registry: CollectorRegistry = REGISTRY) -> bytes:
                 mtype = 'untyped'
 
             output.append('# HELP {} {}\n'.format(
-                mname, metric.documentation.replace('\\', r'\\').replace('\n', r'\n')))
-            output.append(f'# TYPE {mname} {mtype}\n')
+                openmetrics.escape_metric_name(mname), metric.documentation.replace('\\', r'\\').replace('\n', r'\n')))
+            output.append(f'# TYPE {openmetrics.escape_metric_name(mname)} {mtype}\n')
 
             om_samples: Dict[str, List[str]] = {}
             for s in metric.samples:
@@ -299,9 +307,9 @@ def generate_latest(registry: CollectorRegistry = REGISTRY) -> bytes:
             raise
 
         for suffix, lines in sorted(om_samples.items()):
-            output.append('# HELP {}{} {}\n'.format(metric.name, suffix,
-                                                    metric.documentation.replace('\\', r'\\').replace('\n', r'\n')))
-            output.append(f'# TYPE {metric.name}{suffix} gauge\n')
+            output.append('# HELP {} {}\n'.format(openmetrics.escape_metric_name(metric.name + suffix),
+                                                  metric.documentation.replace('\\', r'\\').replace('\n', r'\n')))
+            output.append(f'# TYPE {openmetrics.escape_metric_name(metric.name + suffix)} gauge\n')
             output.extend(lines)
     return ''.join(output).encode('utf-8')
 
diff --git a/prometheus_client/metrics.py b/prometheus_client/metrics.py
index cceaafd..9b25127 100644
--- a/prometheus_client/metrics.py
+++ b/prometheus_client/metrics.py
@@ -1,5 +1,5 @@
 import os
-from threading import RLock
+from threading import Lock
 import time
 import types
 from typing import (
@@ -10,19 +10,21 @@ import warnings
 
 from . import values  # retain this import style for testability
 from .context_managers import ExceptionCounter, InprogressTracker, Timer
-from .metrics_core import (
-    Metric, METRIC_LABEL_NAME_RE, METRIC_NAME_RE,
-    RESERVED_METRIC_LABEL_NAME_RE,
-)
+from .metrics_core import Metric
 from .registry import Collector, CollectorRegistry, REGISTRY
 from .samples import Exemplar, Sample
 from .utils import floatToGoString, INF
+from .validation import (
+    _validate_exemplar, _validate_labelnames, _validate_metric_name,
+)
 
 T = TypeVar('T', bound='MetricWrapperBase')
 F = TypeVar("F", bound=Callable[..., Any])
 
 
 def _build_full_name(metric_type, name, namespace, subsystem, unit):
+    if not name:
+        raise ValueError('Metric name should not be empty')
     full_name = ''
     if namespace:
         full_name += namespace + '_'
@@ -38,31 +40,6 @@ def _build_full_name(metric_type, name, namespace, subsystem, unit):
     return full_name
 
 
-def _validate_labelname(l):
-    if not METRIC_LABEL_NAME_RE.match(l):
-        raise ValueError('Invalid label metric name: ' + l)
-    if RESERVED_METRIC_LABEL_NAME_RE.match(l):
-        raise ValueError('Reserved label metric name: ' + l)
-
-
-def _validate_labelnames(cls, labelnames):
-    labelnames = tuple(labelnames)
-    for l in labelnames:
-        _validate_labelname(l)
-        if l in cls._reserved_labelnames:
-            raise ValueError('Reserved label metric name: ' + l)
-    return labelnames
-
-
-def _validate_exemplar(exemplar):
-    runes = 0
-    for k, v in exemplar.items():
-        _validate_labelname(k)
-        runes += len(k)
-        runes += len(v)
-    if runes > 128:
-        raise ValueError('Exemplar labels have %d UTF-8 characters, exceeding the limit of 128')
-
 
 def _get_use_created() -> bool:
     return os.environ.get("PROMETHEUS_DISABLE_CREATED_SERIES", 'False').lower() not in ('true', '1', 't')
@@ -139,12 +116,11 @@ class MetricWrapperBase(Collector):
         self._documentation = documentation
         self._unit = unit
 
-        if not METRIC_NAME_RE.match(self._name):
-            raise ValueError('Invalid metric name: ' + self._name)
+        _validate_metric_name(self._name)
 
         if self._is_parent():
             # Prepare the fields needed for child metrics.
-            self._lock = RLock()
+            self._lock = Lock()
             self._metrics: Dict[Sequence[str], T] = {}
 
         if self._is_observable():
@@ -697,7 +673,7 @@ class Info(MetricWrapperBase):
 
     def _metric_init(self):
         self._labelname_set = set(self._labelnames)
-        self._lock = RLock()
+        self._lock = Lock()
         self._value = {}
 
     def info(self, val: Dict[str, str]) -> None:
@@ -759,7 +735,7 @@ class Enum(MetricWrapperBase):
 
     def _metric_init(self) -> None:
         self._value = 0
-        self._lock = RLock()
+        self._lock = Lock()
 
     def state(self, state: str) -> None:
         """Set enum metric state."""
diff --git a/prometheus_client/metrics_core.py b/prometheus_client/metrics_core.py
index b09cea0..27d1712 100644
--- a/prometheus_client/metrics_core.py
+++ b/prometheus_client/metrics_core.py
@@ -1,15 +1,12 @@
-import re
 from typing import Dict, List, Optional, Sequence, Tuple, Union
 
 from .samples import Exemplar, NativeHistogram, Sample, Timestamp
+from .validation import _validate_metric_name
 
 METRIC_TYPES = (
     'counter', 'gauge', 'summary', 'histogram',
     'gaugehistogram', 'unknown', 'info', 'stateset',
 )
-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'^__.*$')
 
 
 class Metric:
@@ -24,8 +21,7 @@ class Metric:
     def __init__(self, name: str, documentation: str, typ: str, unit: str = ''):
         if unit and not name.endswith("_" + unit):
             name += "_" + unit
-        if not METRIC_NAME_RE.match(name):
-            raise ValueError('Invalid metric name: ' + name)
+        _validate_metric_name(name)
         self.name: str = name
         self.documentation: str = documentation
         self.unit: str = unit
diff --git a/prometheus_client/openmetrics/exposition.py b/prometheus_client/openmetrics/exposition.py
index 1959847..8460060 100644
--- a/prometheus_client/openmetrics/exposition.py
+++ b/prometheus_client/openmetrics/exposition.py
@@ -2,6 +2,9 @@
 
 
 from ..utils import floatToGoString
+from ..validation import (
+    _is_valid_legacy_labelname, _is_valid_legacy_metric_name,
+)
 
 CONTENT_TYPE_LATEST = 'application/openmetrics-text; version=1.0.0; charset=utf-8'
 """Content type of the latest OpenMetrics text format"""
@@ -24,18 +27,27 @@ def generate_latest(registry):
         try:
             mname = metric.name
             output.append('# HELP {} {}\n'.format(
-                mname, metric.documentation.replace('\\', r'\\').replace('\n', r'\n').replace('"', r'\"')))
-            output.append(f'# TYPE {mname} {metric.type}\n')
+                escape_metric_name(mname), _escape(metric.documentation)))
+            output.append(f'# TYPE {escape_metric_name(mname)} {metric.type}\n')
             if metric.unit:
-                output.append(f'# UNIT {mname} {metric.unit}\n')
+                output.append(f'# UNIT {escape_metric_name(mname)} {metric.unit}\n')
             for s in metric.samples:
-                if s.labels:
-                    labelstr = '{{{0}}}'.format(','.join(
-                        ['{}="{}"'.format(
-                            k, v.replace('\\', r'\\').replace('\n', r'\n').replace('"', r'\"'))
-                            for k, v in sorted(s.labels.items())]))
+                if not _is_valid_legacy_metric_name(s.name):
+                    labelstr = escape_metric_name(s.name)
+                    if s.labels:
+                        labelstr += ', '
                 else:
                     labelstr = ''
+                
+                if s.labels:
+                    items = sorted(s.labels.items())
+                    labelstr += ','.join(
+                        ['{}="{}"'.format(
+                            escape_label_name(k), _escape(v))
+                            for k, v in items])
+                if labelstr:
+                    labelstr = "{" + labelstr + "}"
+                    
                 if s.exemplar:
                     if not _is_valid_exemplar_metric(metric, s):
                         raise ValueError(f"Metric {metric.name} has exemplars, but is not a histogram bucket or counter")
@@ -59,16 +71,47 @@ def generate_latest(registry):
                 timestamp = ''
                 if s.timestamp is not None:
                     timestamp = f' {s.timestamp}'
-                output.append('{}{} {}{}{}\n'.format(
-                    s.name,
-                    labelstr,
-                    floatToGoString(s.value),
-                    timestamp,
-                    exemplarstr,
-                ))
+                if _is_valid_legacy_metric_name(s.name):
+                    output.append('{}{} {}{}{}\n'.format(
+                        s.name,
+                        labelstr,
+                        floatToGoString(s.value),
+                        timestamp,
+                        exemplarstr,
+                    ))
+                else:
+                    output.append('{} {}{}{}\n'.format(
+                        labelstr,
+                        floatToGoString(s.value),
+                        timestamp,
+                        exemplarstr,
+                    ))
         except Exception as exception:
             exception.args = (exception.args or ('',)) + (metric,)
             raise
 
     output.append('# EOF\n')
     return ''.join(output).encode('utf-8')
+
+
+def escape_metric_name(s: str) -> str:
+    """Escapes the metric name and puts it in quotes iff the name does not
+    conform to the legacy Prometheus character set.
+    """
+    if _is_valid_legacy_metric_name(s):
+        return s
+    return '"{}"'.format(_escape(s))
+
+
+def escape_label_name(s: str) -> str:
+    """Escapes the label name and puts it in quotes iff the name does not
+    conform to the legacy Prometheus character set.
+    """
+    if _is_valid_legacy_labelname(s):
+        return s
+    return '"{}"'.format(_escape(s))
+
+
+def _escape(s: str) -> str:
+    """Performs backslash escaping on backslash, newline, and double-quote characters."""
+    return s.replace('\\', r'\\').replace('\n', r'\n').replace('"', r'\"')
diff --git a/prometheus_client/openmetrics/parser.py b/prometheus_client/openmetrics/parser.py
index 39a44dc..1d27091 100644
--- a/prometheus_client/openmetrics/parser.py
+++ b/prometheus_client/openmetrics/parser.py
@@ -5,9 +5,14 @@ import io as StringIO
 import math
 import re
 
-from ..metrics_core import Metric, METRIC_LABEL_NAME_RE
+from ..metrics_core import Metric
+from ..parser import (
+    _last_unquoted_char, _next_unquoted_char, _parse_value, _split_quoted,
+    _unquote_unescape, parse_labels,
+)
 from ..samples import BucketSpan, Exemplar, NativeHistogram, Sample, Timestamp
 from ..utils import floatToGoString
+from ..validation import _is_valid_legacy_metric_name, _validate_metric_name
 
 
 def text_string_to_metric_families(text):
@@ -73,16 +78,6 @@ def _unescape_help(text):
     return ''.join(result)
 
 
-def _parse_value(value):
-    value = ''.join(value)
-    if value != value.strip() or '_' in value:
-        raise ValueError(f"Invalid value: {value!r}")
-    try:
-        return int(value)
-    except ValueError:
-        return float(value)
-
-
 def _parse_timestamp(timestamp):
     timestamp = ''.join(timestamp)
     if not timestamp:
@@ -113,165 +108,31 @@ def _is_character_escaped(s, charpos):
     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 text:
-        if state == 'startoflabelname':
-            if char == '}':
-                state = 'endoflabels'
-            else:
-                state = 'labelname'
-                labelname.append(char)
-        elif state == 'labelname':
-            if char == '=':
-                state = 'labelvaluequote'
-            else:
-                labelname.append(char)
-        elif state == 'labelvaluequote':
-            if char == '"':
-                state = 'labelvalue'
-            else:
-                raise ValueError("Invalid line: " + text)
-        elif state == 'labelvalue':
-            if char == '\\':
-                state = 'labelvalueslash'
-            elif char == '"':
-                ln = ''.join(labelname)
-                if not METRIC_LABEL_NAME_RE.match(ln):
-                    raise ValueError("Invalid line, bad label name: " + text)
-                if ln in labels:
-                    raise ValueError("Invalid line, duplicate label name: " + text)
-                labels[ln] = ''.join(labelvalue)
-                labelname = []
-                labelvalue = []
-                state = 'endoflabelvalue'
-            else:
-                labelvalue.append(char)
-        elif state == 'endoflabelvalue':
-            if char == ',':
-                state = 'labelname'
-            elif char == '}':
-                state = 'endoflabels'
-            else:
-                raise ValueError("Invalid line: " + text)
-        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 == 'endoflabels':
-            if char == ' ':
-                break
-            else:
-                raise ValueError("Invalid line: " + text)
-        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 between 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)
-            if not METRIC_LABEL_NAME_RE.match(label_name):
-                raise ValueError("invalid line, bad label name: " + text)
-            if label_name in labels:
-                raise ValueError("invalid line, duplicate label name: " + text)
-            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):
     separator = " # "
     # Detect the labels in the text
-    label_start = text.find("{")
+    label_start = _next_unquoted_char(text, '{')
     if label_start == -1 or separator in text[:label_start]:
         # We don't have labels, but there could be an exemplar.
-        name_end = text.index(" ")
+        name_end = _next_unquoted_char(text, ' ')
         name = text[:name_end]
+        if not _is_valid_legacy_metric_name(name):
+            raise ValueError("invalid metric name:" + text)
         # 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]
-    if separator not in text:
-        # 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)
+    label_end = _next_unquoted_char(text, '}')
+    labels = parse_labels(text[label_start + 1:label_end], True)
+    if not name:
+        # Name might be in the labels
+        if '__name__' not in labels:
+            raise ValueError
+        name = labels['__name__']
+        del labels['__name__']
+    elif '__name__' in labels:
+        raise ValueError("metric name specified more than once")
     # Parsing labels succeeded, continue parsing the remaining text
     remaining_text = text[label_end + 2:]
     value, timestamp, exemplar = _parse_remaining_text(remaining_text)
@@ -294,7 +155,12 @@ def _parse_remaining_text(text):
     text = split_text[1]
 
     it = iter(text)
+    in_quotes = False
     for char in it:
+        if char == '"':
+            in_quotes = not in_quotes
+        if in_quotes:
+            continue
         if state == 'timestamp':
             if char == '#' and not timestamp:
                 state = 'exemplarspace'
@@ -314,8 +180,9 @@ def _parse_remaining_text(text):
                 raise ValueError("Invalid line: " + text)
         elif state == 'exemplarstartoflabels':
             if char == '{':
-                label_start, label_end = text.index("{"), text.rindex("}")
-                exemplar_labels = _parse_labels(text[label_start + 1:label_end])
+                label_start = _next_unquoted_char(text, '{')
+                label_end = _last_unquoted_char(text, '}')
+                exemplar_labels = parse_labels(text[label_start + 1:label_end], True)
                 state = 'exemplarparsedlabels'
             else:
                 raise ValueError("Invalid line: " + text)
@@ -365,35 +232,77 @@ def _parse_remaining_text(text):
 
 
 def _parse_nh_sample(text, suffixes):
-    labels_start = text.find("{")
-    # check if it's a native histogram with labels
-    re_nh_without_labels = re.compile(r'^[^{} ]+ {[^{}]+}$')
-    re_nh_with_labels = re.compile(r'[^{} ]+{[^{}]+} {[^{}]+}$')
-    if re_nh_with_labels.match(text):
-        nh_value_start = text.rindex("{")
-        labels_end = nh_value_start - 2
+    """Determines if the line has a native histogram sample, and parses it if so."""
+    labels_start = _next_unquoted_char(text, '{')
+    labels_end = -1
+
+    # Finding a native histogram sample requires careful parsing of
+    # possibly-quoted text, which can appear in metric names, label names, and
+    # values.
+    # 
+    # First, we need to determine if there are metric labels. Find the space
+    # between the metric definition and the rest of the line. Look for unquoted
+    # space or {.
+    i = 0
+    has_metric_labels = False
+    i = _next_unquoted_char(text, ' {')
+    if i == -1:
+        return
+
+    # If the first unquoted char was a {, then that is the metric labels (which
+    # could contain a UTF-8 metric name).
+    if text[i] == '{':
+        has_metric_labels = True
+        # Consume the labels -- jump ahead to the close bracket.
+        labels_end = i = _next_unquoted_char(text, '}', i)
+        if labels_end == -1:
+            raise ValueError
+    
+    # If there is no subsequent unquoted {, then it's definitely not a nh.
+    nh_value_start = _next_unquoted_char(text, '{', i + 1)
+    if nh_value_start == -1:
+        return
+    
+    # Edge case: if there is an unquoted # between the metric definition and the {,
+    # then this is actually an exemplar
+    exemplar = _next_unquoted_char(text, '#', i + 1)
+    if exemplar != -1 and exemplar < nh_value_start:
+        return
+    
+    nh_value_end = _next_unquoted_char(text, '}', nh_value_start)
+    if nh_value_end == -1:
+        raise ValueError
+    
+    if has_metric_labels:
         labelstext = text[labels_start + 1:labels_end]
-        labels = _parse_labels(labelstext)
+        labels = parse_labels(labelstext, True)
         name_end = labels_start
         name = text[:name_end]
         if name.endswith(suffixes):
-            raise ValueError("the sample name of a native histogram with labels should have no suffixes", name) 
+            raise ValueError("the sample name of a native histogram with labels should have no suffixes", name)
+        if not name:
+            # Name might be in the labels
+            if '__name__' not in labels:
+                raise ValueError
+            name = labels['__name__']
+            del labels['__name__']
+            # Edge case: the only "label" is the name definition.
+            if not labels:
+                labels = None
+             
         nh_value = text[nh_value_start:]
         nat_hist_value = _parse_nh_struct(nh_value)
         return Sample(name, labels, None, None, None, nat_hist_value)
     # check if it's a native histogram
-    if re_nh_without_labels.match(text):
-        nh_value_start = labels_start
+    else:
         nh_value = text[nh_value_start:]
         name_end = nh_value_start - 1
         name = text[:name_end]
         if name.endswith(suffixes):
             raise ValueError("the sample name of a native histogram should have no suffixes", name)
+        # Not possible for UTF-8 name here, that would have been caught as having a labelset.
         nat_hist_value = _parse_nh_struct(nh_value)
         return Sample(name, None, None, None, None, nat_hist_value)      
-    else:
-        # it's not a native histogram
-        return
 
 
 def _parse_nh_struct(text):
@@ -576,6 +485,7 @@ def text_fd_to_metric_families(fd):
             raise ValueError("Units not allowed for this metric type: " + name)
         if typ in ['histogram', 'gaugehistogram']:
             _check_histogram(samples, name)
+        _validate_metric_name(name)
         metric = Metric(name, documentation, typ, unit)
         # TODO: check labelvalues are valid utf8
         metric.samples = samples
@@ -596,16 +506,19 @@ def text_fd_to_metric_families(fd):
         if line == '# EOF':
             eof = True
         elif line.startswith('#'):
-            parts = line.split(' ', 3)
+            parts = _split_quoted(line, ' ', 3)
             if len(parts) < 4:
                 raise ValueError("Invalid line: " + line)
-            if parts[2] == name and samples:
+            candidate_name, quoted = _unquote_unescape(parts[2])
+            if not quoted and not _is_valid_legacy_metric_name(candidate_name):
+                raise ValueError
+            if candidate_name == name and samples:
                 raise ValueError("Received metadata after samples: " + line)
-            if parts[2] != name:
+            if candidate_name != name:
                 if name is not None:
                     yield build_metric(name, documentation, typ, unit, samples)
                 # New metric
-                name = parts[2]
+                name = candidate_name
                 unit = None
                 typ = None
                 documentation = None
@@ -614,7 +527,7 @@ def text_fd_to_metric_families(fd):
                 group_timestamp = None
                 group_timestamp_samples = set()
                 samples = []
-                allowed_names = [parts[2]]
+                allowed_names = [candidate_name]
             
             if parts[1] == 'HELP':
                 if documentation is not None:
@@ -649,7 +562,10 @@ def text_fd_to_metric_families(fd):
                 if name is not None:
                     yield build_metric(name, documentation, typ, unit, samples)
                 # Start an unknown metric.
-                name = sample.name
+                candidate_name, quoted = _unquote_unescape(sample.name)
+                if not quoted and not _is_valid_legacy_metric_name(candidate_name):
+                    raise ValueError
+                name = candidate_name
                 documentation = None
                 unit = None
                 typ = 'unknown'
diff --git a/prometheus_client/parser.py b/prometheus_client/parser.py
index dc8e30d..92d6672 100644
--- a/prometheus_client/parser.py
+++ b/prometheus_client/parser.py
@@ -1,9 +1,13 @@
 import io as StringIO
 import re
+import string
 from typing import Dict, Iterable, List, Match, Optional, TextIO, Tuple
 
 from .metrics_core import Metric
 from .samples import Sample
+from .validation import (
+    _is_valid_legacy_metric_name, _validate_labelname, _validate_metric_name,
+)
 
 
 def text_string_to_metric_families(text: str) -> Iterable[Metric]:
@@ -45,54 +49,169 @@ def _is_character_escaped(s: str, charpos: int) -> bool:
     return num_bslashes % 2 == 1
 
 
-def _parse_labels(labels_string: str) -> Dict[str, str]:
+def parse_labels(labels_string: str, openmetrics: bool = False) -> Dict[str, str]:
     labels: Dict[str, str] = {}
-    # Return if we don't have valid labels
-    if "=" not in labels_string:
-        return labels
-
-    escaping = False
-    if "\\" in labels_string:
-        escaping = True
 
     # Copy original labels
-    sub_labels = labels_string
+    sub_labels = labels_string.strip()
+    if openmetrics and sub_labels and sub_labels[0] == ',':
+        raise ValueError("leading comma: " + labels_string)
     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:].lstrip()
-            # Find the first quote after the equal
-            quote_start = sub_labels.index('"') + 1
-            value_substr = sub_labels[quote_start:]
-
-            # 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):
+            # The label name is before the equal, or if there's no equal, that's the
+            # metric name.
+            
+            term, sub_labels = _next_term(sub_labels, openmetrics)
+            if not term:
+                if openmetrics:
+                    raise ValueError("empty term in line: " + labels_string)
+                continue
+            
+            quoted_name = False
+            operator_pos = _next_unquoted_char(term, '=')
+            if operator_pos == -1:
+                quoted_name = True
+                label_name = "__name__"
+            else:
+                value_start = _next_unquoted_char(term, '=')
+                label_name, quoted_name = _unquote_unescape(term[:value_start])
+                term = term[value_start + 1:]
+                
+            if not quoted_name and not _is_valid_legacy_metric_name(label_name):
+                raise ValueError("unquoted UTF-8 metric name")
+                
+            # Check for missing quotes 
+            term = term.strip()
+            if not term or term[0] != '"':
+                raise ValueError
+
+            # The first quote is guaranteed to be after the equal.
+            # Find the last unescaped quote.
+            i = 1
+            while i < len(term):
+                i = term.index('"', i)
+                if not _is_character_escaped(term[:i], i):
                     break
                 i += 1
 
             # The label value is between the first and last quote
             quote_end = i + 1
-            label_value = sub_labels[quote_start:quote_end]
-            # Replace escaping if needed
-            if escaping:
-                label_value = _replace_escaping(label_value)
-            labels[label_name.strip()] = label_value
-
-            # Remove the processed label from the sub-slice for next iteration
-            sub_labels = sub_labels[quote_end + 1:]
-            next_comma = sub_labels.find(",") + 1
-            sub_labels = sub_labels[next_comma:].lstrip()
-
+            if quote_end != len(term):
+                raise ValueError("unexpected text after quote: " + labels_string)
+            label_value, _ = _unquote_unescape(term[:quote_end])
+            if label_name == '__name__':
+                _validate_metric_name(label_name)
+            else:
+                _validate_labelname(label_name)
+            if label_name in labels:
+                raise ValueError("invalid line, duplicate label name: " + labels_string)
+            labels[label_name] = label_value
         return labels
-
     except ValueError:
-        raise ValueError("Invalid labels: %s" % labels_string)
+        raise ValueError("Invalid labels: " + labels_string)
+    
+
+def _next_term(text: str, openmetrics: bool) -> Tuple[str, str]:
+    """Extract the next comma-separated label term from the text.
+    
+    Returns the stripped term and the stripped remainder of the string, 
+    including the comma.
+    
+    Raises ValueError if the term is empty and we're in openmetrics mode.
+    """
+    
+    # There may be a leading comma, which is fine here.
+    if text[0] == ',':
+        text = text[1:]
+        if not text:
+            return "", ""
+        if text[0] == ',':
+            raise ValueError("multiple commas")
+    splitpos = _next_unquoted_char(text, ',}')
+    if splitpos == -1:
+        splitpos = len(text)
+    term = text[:splitpos]
+    if not term and openmetrics:
+        raise ValueError("empty term:", term)
+    
+    sublabels = text[splitpos:]
+    return term.strip(), sublabels.strip()
+
+
+def _next_unquoted_char(text: str, chs: str, startidx: int = 0) -> int:
+    """Return position of next unquoted character in tuple, or -1 if not found.
+    
+    It is always assumed that the first character being checked is not already
+    inside quotes.
+    """
+    i = startidx
+    in_quotes = False
+    if chs is None:
+        chs = string.whitespace
+    while i < len(text):
+        if text[i] == '"' and not _is_character_escaped(text, i):
+            in_quotes = not in_quotes
+        if not in_quotes:
+            if text[i] in chs:
+                return i
+        i += 1
+    return -1
+
+
+def _last_unquoted_char(text: str, chs: str) -> int:
+    """Return position of last unquoted character in list, or -1 if not found."""
+    i = len(text) - 1
+    in_quotes = False
+    if chs is None:
+        chs = string.whitespace
+    while i > 0:
+        if text[i] == '"' and not _is_character_escaped(text, i):
+            in_quotes = not in_quotes
+            
+        if not in_quotes:
+            if text[i] in chs:
+                return i
+        i -= 1
+    return -1
+
+
+def _split_quoted(text, separator, maxsplit=0):
+    """Splits on split_ch similarly to strings.split, skipping separators if
+    they are inside quotes.
+    """
+
+    tokens = ['']
+    x = 0
+    while x < len(text):
+        split_pos = _next_unquoted_char(text, separator, x)
+        if split_pos == -1:
+            tokens[-1] = text[x:]
+            x = len(text)
+            continue
+        if maxsplit > 0 and len(tokens) > maxsplit:
+            tokens[-1] = text[x:]
+            break
+        tokens[-1] = text[x:split_pos]
+        x = split_pos + 1
+        tokens.append('')
+    return tokens
+
+
+def _unquote_unescape(text):
+    """Returns the string, and true if it was quoted."""
+    if not text:
+        return text, False
+    quoted = False
+    text = text.strip()
+    if text[0] == '"':
+        if len(text) == 1 or text[-1] != '"':
+            raise ValueError("missing close quote")
+        text = text[1:-1]
+        quoted = True
+    if "\\" in text:
+        text = _replace_escaping(text)
+    return text, quoted
 
 
 # If we have multiple values only consider the first
@@ -104,34 +223,50 @@ def _parse_value_and_timestamp(s: str) -> Tuple[float, Optional[float]]:
     values = [value.strip() for value in s.split(separator) if value.strip()]
     if not values:
         return float(s), None
-    value = float(values[0])
-    timestamp = (float(values[-1]) / 1000) if len(values) > 1 else None
+    value = _parse_value(values[0])
+    timestamp = (_parse_value(values[-1]) / 1000) if len(values) > 1 else None
     return value, timestamp
 
 
-def _parse_sample(text: str) -> Sample:
-    # Detect the labels in the text
+def _parse_value(value):
+    value = ''.join(value)
+    if value != value.strip() or '_' in value:
+        raise ValueError(f"Invalid value: {value!r}")
     try:
-        label_start, label_end = text.index("{"), text.rindex("}")
-        # The name is before the labels
-        name = text[:label_start].strip()
-        # We ignore the starting curly brace
-        label = text[label_start + 1:label_end]
-        # The value is after the label end (ignoring curly brace)
-        value, timestamp = _parse_value_and_timestamp(text[label_end + 1:])
-        return Sample(name, _parse_labels(label), value, timestamp)
-
-    # We don't have labels
+        return int(value)
     except ValueError:
-        # Detect what separator is used
-        separator = " "
-        if separator not in text:
-            separator = "\t"
-        name_end = text.index(separator)
-        name = text[:name_end]
-        # The value is after the name
-        value, timestamp = _parse_value_and_timestamp(text[name_end:])
+        return float(value)
+    
+
+def _parse_sample(text):
+    separator = " # "
+    # Detect the labels in the text
+    label_start = _next_unquoted_char(text, '{')
+    if label_start == -1 or separator in text[:label_start]:
+        # We don't have labels, but there could be an exemplar.
+        name_end = _next_unquoted_char(text, ' \t')
+        name = text[:name_end].strip()
+        if not _is_valid_legacy_metric_name(name):
+            raise ValueError("invalid metric name:" + text)
+        # Parse the remaining text after the name
+        remaining_text = text[name_end + 1:]
+        value, timestamp = _parse_value_and_timestamp(remaining_text)
         return Sample(name, {}, value, timestamp)
+    name = text[:label_start].strip()
+    label_end = _next_unquoted_char(text, '}')
+    labels = parse_labels(text[label_start + 1:label_end], False)
+    if not name:
+        # Name might be in the labels
+        if '__name__' not in labels:
+            raise ValueError
+        name = labels['__name__']
+        del labels['__name__']
+    elif '__name__' in labels:
+        raise ValueError("metric name specified more than once")
+    # Parsing labels succeeded, continue parsing the remaining text
+    remaining_text = text[label_end + 1:]
+    value, timestamp = _parse_value_and_timestamp(remaining_text)
+    return Sample(name, labels, value, timestamp)
 
 
 def text_fd_to_metric_families(fd: TextIO) -> Iterable[Metric]:
@@ -168,28 +303,35 @@ def text_fd_to_metric_families(fd: TextIO) -> Iterable[Metric]:
         line = line.strip()
 
         if line.startswith('#'):
-            parts = line.split(None, 3)
+            parts = _split_quoted(line, None, 3)
             if len(parts) < 2:
                 continue
+            candidate_name, quoted = '', False
+            if len(parts) > 2:
+                candidate_name, quoted = _unquote_unescape(parts[2])
+                if not quoted and not _is_valid_legacy_metric_name(candidate_name):
+                    raise ValueError
             if parts[1] == 'HELP':
-                if parts[2] != name:
+                if candidate_name != name:
                     if name != '':
                         yield build_metric(name, documentation, typ, samples)
                     # New metric
-                    name = parts[2]
+                    name = candidate_name
                     typ = 'untyped'
                     samples = []
-                    allowed_names = [parts[2]]
+                    allowed_names = [candidate_name]
                 if len(parts) == 4:
                     documentation = _replace_help_escaping(parts[3])
                 else:
                     documentation = ''
             elif parts[1] == 'TYPE':
-                if parts[2] != name:
+                if len(parts) < 4:
+                    raise ValueError
+                if candidate_name != name:
                     if name != '':
                         yield build_metric(name, documentation, typ, samples)
                     # New metric
-                    name = parts[2]
+                    name = candidate_name
                     documentation = ''
                     samples = []
                 typ = parts[3]
diff --git a/prometheus_client/registry.py b/prometheus_client/registry.py
index 4326b39..694e4bd 100644
--- a/prometheus_client/registry.py
+++ b/prometheus_client/registry.py
@@ -1,6 +1,6 @@
 from abc import ABC, abstractmethod
 import copy
-from threading import RLock
+from threading import Lock
 from typing import Dict, Iterable, List, Optional
 
 from .metrics_core import Metric
@@ -30,7 +30,7 @@ class CollectorRegistry(Collector):
         self._collector_to_names: Dict[Collector, List[str]] = {}
         self._names_to_collectors: Dict[str, Collector] = {}
         self._auto_describe = auto_describe
-        self._lock = RLock()
+        self._lock = Lock()
         self._target_info: Optional[Dict[str, str]] = {}
         self.set_target_info(target_info)
 
diff --git a/prometheus_client/validation.py b/prometheus_client/validation.py
new file mode 100644
index 0000000..bf19fc7
--- /dev/null
+++ b/prometheus_client/validation.py
@@ -0,0 +1,123 @@
+import os
+import re
+
+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'^__.*$')
+
+
+def _init_legacy_validation() -> bool:
+    """Retrieve name validation setting from environment."""
+    return os.environ.get("PROMETHEUS_LEGACY_NAME_VALIDATION", 'False').lower() in ('true', '1', 't')
+
+
+_legacy_validation = _init_legacy_validation()
+
+
+def get_legacy_validation() -> bool:
+    """Return the current status of the legacy validation setting."""
+    global _legacy_validation
+    return _legacy_validation
+
+
+def disable_legacy_validation():
+    """Disable legacy name validation, instead allowing all UTF8 characters."""
+    global _legacy_validation
+    _legacy_validation = False
+
+
+def enable_legacy_validation():
+    """Enable legacy name validation instead of allowing all UTF8 characters."""
+    global _legacy_validation
+    _legacy_validation = True
+
+
+def _validate_metric_name(name: str) -> None:
+    """Raises ValueError if the provided name is not a valid metric name.
+    
+    This check uses the global legacy validation setting to determine the validation scheme.
+    """
+    if not name:
+        raise ValueError("metric name cannot be empty")
+    global _legacy_validation
+    if _legacy_validation:
+        if not METRIC_NAME_RE.match(name):
+            raise ValueError("invalid metric name " + name)
+    try:
+        name.encode('utf-8')
+    except UnicodeDecodeError:
+        raise ValueError("invalid metric name " + name)
+
+
+def _is_valid_legacy_metric_name(name: str) -> bool:
+    """Returns true if the provided metric name conforms to the legacy validation scheme."""
+    return METRIC_NAME_RE.match(name) is not None
+
+
+def _validate_metric_label_name_token(tok: str) -> None:
+    """Raises ValueError if a parsed label name token is invalid. 
+    
+    UTF-8 names must be quoted.
+    """
+    if not tok:
+        raise ValueError("invalid label name token " + tok)
+    global _legacy_validation
+    quoted = tok[0] == '"' and tok[-1] == '"'
+    if not quoted or _legacy_validation:
+        if not METRIC_LABEL_NAME_RE.match(tok):
+            raise ValueError("invalid label name token " + tok)
+        return
+    try:
+        tok.encode('utf-8')
+    except UnicodeDecodeError:
+        raise ValueError("invalid label name token " + tok)
+
+
+def _validate_labelname(l):
+    """Raises ValueError if the provided name is not a valid label name.
+    
+    This check uses the global legacy validation setting to determine the validation scheme.
+    """
+    if get_legacy_validation():
+        if not METRIC_LABEL_NAME_RE.match(l):
+            raise ValueError('Invalid label metric name: ' + l)
+        if RESERVED_METRIC_LABEL_NAME_RE.match(l):
+            raise ValueError('Reserved label metric name: ' + l)
+    else:
+        try:
+            l.encode('utf-8')
+        except UnicodeDecodeError:
+            raise ValueError('Invalid label metric name: ' + l)
+        if RESERVED_METRIC_LABEL_NAME_RE.match(l):
+            raise ValueError('Reserved label metric name: ' + l)
+        
+
+def _is_valid_legacy_labelname(l: str) -> bool:
+    """Returns true if the provided label name conforms to the legacy validation scheme."""
+    if METRIC_LABEL_NAME_RE.match(l) is None:
+        return False
+    return RESERVED_METRIC_LABEL_NAME_RE.match(l) is None
+
+
+def _validate_labelnames(cls, labelnames):
+    """Raises ValueError if any of the provided names is not a valid label name.
+    
+    This check uses the global legacy validation setting to determine the validation scheme.
+    """
+    labelnames = tuple(labelnames)
+    for l in labelnames:
+        _validate_labelname(l)
+        if l in cls._reserved_labelnames:
+            raise ValueError('Reserved label methe fric name: ' + l)
+    return labelnames
+
+
+def _validate_exemplar(exemplar):
+    """Raises ValueError if the exemplar is invalid."""
+    runes = 0
+    for k, v in exemplar.items():
+        _validate_labelname(k)
+        runes += len(k)
+        runes += len(v)
+    if runes > 128:
+        raise ValueError('Exemplar labels have %d UTF-8 characters, exceeding the limit of 128')
diff --git a/prometheus_client/values.py b/prometheus_client/values.py
index 05331f8..6ff85e3 100644
--- a/prometheus_client/values.py
+++ b/prometheus_client/values.py
@@ -1,5 +1,5 @@
 import os
-from threading import RLock
+from threading import Lock
 import warnings
 
 from .mmap_dict import mmap_key, MmapedDict
@@ -13,7 +13,7 @@ class MutexValue:
     def __init__(self, typ, metric_name, name, labelnames, labelvalues, help_text, **kwargs):
         self._value = 0.0
         self._exemplar = None
-        self._lock = RLock()
+        self._lock = Lock()
 
     def inc(self, amount):
         with self._lock:
@@ -50,7 +50,7 @@ def MultiProcessValue(process_identifier=os.getpid):
     # Use a single global lock when in multi-processing mode
     # as we presume this means there is no threading going on.
     # This avoids the need to also have mutexes in __MmapDict.
-    lock = RLock()
+    lock = Lock()
 
     class MmapedValue:
         """A float protected by a mutex backed by a per-process mmaped file."""
diff --git a/setup.py b/setup.py
index 438f643..1665ed7 100644
--- a/setup.py
+++ b/setup.py
@@ -30,7 +30,7 @@ setup(
         'twisted': ['twisted'],
     },
     test_suite="tests",
-    python_requires=">=3.8",
+    python_requires=">=3.9",
     classifiers=[
         "Development Status :: 4 - Beta",
         "Intended Audience :: Developers",
@@ -38,7 +38,6 @@ setup(
         "Intended Audience :: System Administrators",
         "Programming Language :: Python",
         "Programming Language :: Python :: 3",
-        "Programming Language :: Python :: 3.8",
         "Programming Language :: Python :: 3.9",
         "Programming Language :: Python :: 3.10",
         "Programming Language :: Python :: 3.11",
diff --git a/tox.ini b/tox.ini
index 6d6b756..354ad69 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,15 +1,15 @@
 [tox]
-envlist = coverage-clean,py{3.8,3.9,3.10,3.11,3.12,py3.8,3.9-nooptionals},coverage-report,flake8,isort,mypy
+envlist = coverage-clean,py{3.9,3.10,3.11,3.12,py3.9,3.9-nooptionals},coverage-report,flake8,isort,mypy
 
 [testenv]
 deps =
     coverage
     pytest
     attrs
-    {py3.8,pypy3.8}: twisted
-    py3.8: asgiref
-    # See https://github.com/django/asgiref/issues/393 for why we need to pin asgiref for pypy
-    pypy3.8: asgiref==3.6.0
+    {py3.9,pypy3.9}: twisted
+    # NOTE: Pinned due to https://github.com/prometheus/client_python/issues/1020
+    py3.9: asgiref==3.7
+    pypy3.9: asgiref==3.7
 commands = coverage run --parallel -m pytest {posargs}
 
 [testenv:py3.9-nooptionals]


Test patch:
diff --git a/tests/openmetrics/test_exposition.py b/tests/openmetrics/test_exposition.py
index 28a9083..124e55e 100644
--- a/tests/openmetrics/test_exposition.py
+++ b/tests/openmetrics/test_exposition.py
@@ -33,6 +33,12 @@ class TestGenerateText(unittest.TestCase):
         c.inc()
         self.assertEqual(b'# HELP cc A counter\n# TYPE cc counter\ncc_total 1.0\ncc_created 123.456\n# EOF\n',
                          generate_latest(self.registry))
+        
+    def test_counter_utf8(self):
+        c = Counter('cc.with.dots', 'A counter', registry=self.registry)
+        c.inc()
+        self.assertEqual(b'# HELP "cc.with.dots" A counter\n# TYPE "cc.with.dots" counter\n{"cc.with.dots_total"} 1.0\n{"cc.with.dots_created"} 123.456\n# EOF\n',
+                         generate_latest(self.registry))
 
     def test_counter_total(self):
         c = Counter('cc_total', 'A counter', registry=self.registry)
diff --git a/tests/openmetrics/test_parser.py b/tests/openmetrics/test_parser.py
index dc5e991..019929e 100644
--- a/tests/openmetrics/test_parser.py
+++ b/tests/openmetrics/test_parser.py
@@ -29,6 +29,24 @@ a_total 9223372036854775808
 """)
         self.assertEqual([CounterMetricFamily("a", "help", value=9223372036854775808)], list(families))
 
+    def test_utf8_counter(self):
+        families = text_string_to_metric_families("""# TYPE "my.counter" counter
+# HELP "my.counter" help
+{"my.counter_total"} 1
+# EOF
+""")
+        self.assertEqual([CounterMetricFamily("my.counter", "help", value=1)], list(families))
+
+    def test_complex_name_counter(self):
+        families = text_string_to_metric_families("""# TYPE "my.counter{} # = \\" \\n" counter
+# HELP "my.counter{} # = \\" \\n" help
+{"my.counter{} # = \\" \\n_total", "awful. }}{{ # HELP EOF name"="\\n yikes } \\" value"} 1
+# EOF
+""")
+        metric = CounterMetricFamily("my.counter{} # = \" \n", "help", labels={'awful. }}{{ # HELP EOF name': '\n yikes } " value'})
+        metric.add_sample("my.counter{} # = \" \n_total", {'awful. }}{{ # HELP EOF name': '\n yikes } " value'}, 1)
+        self.assertEqual([metric], list(families))
+
     def test_simple_gauge(self):
         families = text_string_to_metric_families("""# TYPE a gauge
 # HELP a help
@@ -128,6 +146,18 @@ a_sum 2.0
         self.assertEqual([HistogramMetricFamily("a", "help", sum_value=2, buckets=[("1.0", 0.0), ("+Inf", 3.0)])],
                          list(families))
 
+    def test_utf8_histogram_float_values(self):
+        families = text_string_to_metric_families("""# TYPE "a.b" histogram
+# HELP "a.b" help
+{"a.b_bucket", le="1.0"} 0.0
+{"a.b_bucket", le="+Inf"} 3.0
+{"a.b_count"} 3.0
+{"a.b_sum"} 2.0
+# EOF
+""")
+        self.assertEqual([HistogramMetricFamily("a.b", "help", sum_value=2, buckets=[("1.0", 0.0), ("+Inf", 3.0)])],
+                         list(families))
+
     def test_histogram_noncanonical(self):
         families = text_string_to_metric_families("""# TYPE a histogram
 # HELP a help
@@ -175,7 +205,7 @@ a_bucket{le="+Inf"} 3 # {a="2345678901234567890123456789012345678901234567890123
                        Exemplar({"a": "2345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678"}, 4,
                                 Timestamp(123, 0)))
         self.assertEqual([hfm], list(families))
-    
+
     def test_native_histogram(self):
         families = text_string_to_metric_families("""# TYPE nativehistogram histogram
 # HELP nativehistogram Is a basic example of a native histogram
@@ -183,11 +213,35 @@ nativehistogram {count:24,sum:100,schema:0,zero_threshold:0.001,zero_count:4,pos
 # EOF
 """)
         families = list(families)
-       
+
         hfm = HistogramMetricFamily("nativehistogram", "Is a basic example of a native histogram")
         hfm.add_sample("nativehistogram", None, None, None, None, NativeHistogram(24, 100, 0, 0.001, 4, (BucketSpan(0, 2), BucketSpan(1, 2)), (BucketSpan(0, 2), BucketSpan(1, 2)), (2, 1, -3, 3), (2, 1, -2, 3)))
         self.assertEqual([hfm], families)
 
+    def test_native_histogram_utf8(self):
+        families = text_string_to_metric_families("""# TYPE "native{histogram" histogram
+# HELP "native{histogram" Is a basic example of a native histogram
+{"native{histogram"} {count:24,sum:100,schema:0,zero_threshold:0.001,zero_count:4,positive_spans:[0:2,1:2],negative_spans:[0:2,1:2],positive_deltas:[2,1,-3,3],negative_deltas:[2,1,-2,3]}
+# EOF
+""")
+        families = list(families)
+
+        hfm = HistogramMetricFamily("native{histogram", "Is a basic example of a native histogram")
+        hfm.add_sample("native{histogram", None, None, None, None, NativeHistogram(24, 100, 0, 0.001, 4, (BucketSpan(0, 2), BucketSpan(1, 2)), (BucketSpan(0, 2), BucketSpan(1, 2)), (2, 1, -3, 3), (2, 1, -2, 3)))
+        self.assertEqual([hfm], families)
+
+    def test_native_histogram_utf8_stress(self):
+        families = text_string_to_metric_families("""# TYPE "native{histogram" histogram
+# HELP "native{histogram" Is a basic example of a native histogram
+{"native{histogram", "xx{} # {}"=" EOF # {}}}"} {count:24,sum:100,schema:0,zero_threshold:0.001,zero_count:4,positive_spans:[0:2,1:2],negative_spans:[0:2,1:2],positive_deltas:[2,1,-3,3],negative_deltas:[2,1,-2,3]}
+# EOF
+""")
+        families = list(families)
+
+        hfm = HistogramMetricFamily("native{histogram", "Is a basic example of a native histogram")
+        hfm.add_sample("native{histogram", {'xx{} # {}': ' EOF # {}}}'}, None, None, None, NativeHistogram(24, 100, 0, 0.001, 4, (BucketSpan(0, 2), BucketSpan(1, 2)), (BucketSpan(0, 2), BucketSpan(1, 2)), (2, 1, -3, 3), (2, 1, -2, 3)))
+        self.assertEqual([hfm], families)
+
     def test_native_histogram_with_labels(self):
         families = text_string_to_metric_families("""# TYPE hist_w_labels histogram
 # HELP hist_w_labels Is a basic example of a native histogram with labels
@@ -195,11 +249,23 @@ hist_w_labels{foo="bar",baz="qux"} {count:24,sum:100,schema:0,zero_threshold:0.0
 # EOF
 """)
         families = list(families)
-       
+
         hfm = HistogramMetricFamily("hist_w_labels", "Is a basic example of a native histogram with labels")
         hfm.add_sample("hist_w_labels", {"foo": "bar", "baz": "qux"}, None, None, None, NativeHistogram(24, 100, 0, 0.001, 4, (BucketSpan(0, 2), BucketSpan(1, 2)), (BucketSpan(0, 2), BucketSpan(1, 2)), (2, 1, -3, 3), (2, 1, -2, 3)))
         self.assertEqual([hfm], families)
 
+    def test_native_histogram_with_labels_utf8(self):
+        families = text_string_to_metric_families("""# TYPE "hist.w.labels" histogram
+# HELP "hist.w.labels" Is a basic example of a native histogram with labels
+{"hist.w.labels", foo="bar",baz="qux"} {count:24,sum:100,schema:0,zero_threshold:0.001,zero_count:4,positive_spans:[0:2,1:2],negative_spans:[0:2,1:2],positive_deltas:[2,1,-3,3],negative_deltas:[2,1,-2,3]}
+# EOF
+""")
+        families = list(families)
+
+        hfm = HistogramMetricFamily("hist.w.labels", "Is a basic example of a native histogram with labels")
+        hfm.add_sample("hist.w.labels", {"foo": "bar", "baz": "qux"}, None, None, None, NativeHistogram(24, 100, 0, 0.001, 4, (BucketSpan(0, 2), BucketSpan(1, 2)), (BucketSpan(0, 2), BucketSpan(1, 2)), (2, 1, -3, 3), (2, 1, -2, 3)))
+        self.assertEqual([hfm], families)
+
     def test_native_histogram_with_classic_histogram(self):
         families = text_string_to_metric_families("""# TYPE hist_w_classic histogram
 # HELP hist_w_classic Is a basic example of a native histogram coexisting with a classic histogram
@@ -211,7 +277,7 @@ hist_w_classic_sum{foo="bar"} 100
 # EOF
 """)
         families = list(families)
-       
+
         hfm = HistogramMetricFamily("hist_w_classic", "Is a basic example of a native histogram coexisting with a classic histogram")
         hfm.add_sample("hist_w_classic", {"foo": "bar"}, None, None, None, NativeHistogram(24, 100, 0, 0.001, 4, (BucketSpan(0, 2), BucketSpan(1, 2)), (BucketSpan(0, 2), BucketSpan(1, 2)), (2, 1, -3, 3), (2, 1, -2, 3)))
         hfm.add_sample("hist_w_classic_bucket", {"foo": "bar", "le": "0.001"}, 4.0, None, None, None)
@@ -219,7 +285,7 @@ hist_w_classic_sum{foo="bar"} 100
         hfm.add_sample("hist_w_classic_count", {"foo": "bar"}, 24.0, None, None, None)
         hfm.add_sample("hist_w_classic_sum", {"foo": "bar"}, 100.0, None, None, None)
         self.assertEqual([hfm], families)
-    
+
     def test_native_plus_classic_histogram_two_labelsets(self):
         families = text_string_to_metric_families("""# TYPE hist_w_classic_two_sets histogram
 # HELP hist_w_classic_two_sets Is an example of a native histogram plus a classic histogram with two label sets
@@ -236,7 +302,7 @@ hist_w_classic_two_sets_sum{foo="baz"} 100
 # EOF
 """)
         families = list(families)
-       
+
         hfm = HistogramMetricFamily("hist_w_classic_two_sets", "Is an example of a native histogram plus a classic histogram with two label sets")
         hfm.add_sample("hist_w_classic_two_sets", {"foo": "bar"}, None, None, None, NativeHistogram(24, 100, 0, 0.001, 4, (BucketSpan(0, 2), BucketSpan(1, 2)), (BucketSpan(0, 2), BucketSpan(1, 2)), (2, 1, -3, 3), (2, 1, -2, 3)))
         hfm.add_sample("hist_w_classic_two_sets_bucket", {"foo": "bar", "le": "0.001"}, 4.0, None, None, None)
@@ -299,6 +365,16 @@ a_total 0 123 # {a="b"} 0.5
         cfm.add_sample("a_total", {}, 0.0, Timestamp(123, 0), Exemplar({"a": "b"}, 0.5))
         self.assertEqual([cfm], list(families))
 
+    def test_counter_exemplars_utf8(self):
+        families = text_string_to_metric_families("""# TYPE "a.b" counter
+# HELP "a.b" help
+{"a.b_total"} 0 123 # {"c{}d"="b"} 0.5
+# EOF
+""")
+        cfm = CounterMetricFamily("a.b", "help")
+        cfm.add_sample("a.b_total", {}, 0.0, Timestamp(123, 0), Exemplar({"c{}d": "b"}, 0.5))
+        self.assertEqual([cfm], list(families))
+
     def test_counter_exemplars_empty_brackets(self):
         families = text_string_to_metric_families("""# TYPE a counter
 # HELP a help
@@ -495,10 +571,10 @@ a_total{foo="bar"} 1
     def test_escaping(self):
         families = text_string_to_metric_families("""# TYPE a counter
 # HELP a he\\n\\\\l\\tp
-a_total{foo="b\\"a\\nr"} 1
-a_total{foo="b\\\\a\\z"} 2
-a_total{foo="b\\"a\\nr # "} 3
-a_total{foo="b\\\\a\\z # "} 4
+{"a_total", foo="b\\"a\\nr"} 1
+{"a_total", foo="b\\\\a\\z"} 2
+{"a_total", foo="b\\"a\\nr # "} 3
+{"a_total", foo="b\\\\a\\z # "} 4
 # EOF
 """)
         metric_family = CounterMetricFamily("a", "he\n\\l\\tp", labels=["foo"])
@@ -565,66 +641,6 @@ a_bucket{le="+Inf",foo="bar # "} 3 # {a="d",foo="bar # bar"} 4
         hfm.add_sample("a_bucket", {"le": "+Inf", "foo": "bar # "}, 3.0, None, Exemplar({"a": "d", "foo": "bar # bar"}, 4))
         self.assertEqual([hfm], list(families))
 
-    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')
 
     def test_roundtrip(self):
         text = """# HELP go_gc_duration_seconds A summary of the GC invocation durations.
@@ -710,8 +726,10 @@ bar_bucket{a="c",le="+Inf"} 0.0
             ('a{a=1} 1\n# EOF\n'),
             ('a{a="1} 1\n# EOF\n'),
             ('a{a=\'1\'} 1\n# EOF\n'),
+            ('"a" 1\n# EOF\n'),
             # Missing equal or label value.
             ('a{a} 1\n# EOF\n'),
+            ('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'),
@@ -897,6 +915,10 @@ bar_bucket{a="c",le="+Inf"} 0.0
             ('# TYPE a counter\n# TYPE a counter\n# EOF\n'),
             ('# TYPE a info\n# TYPE a counter\n# EOF\n'),
             ('# TYPE a_created gauge\n# TYPE a counter\n# EOF\n'),
+            # Bad native histograms.
+            ('# TYPE nh histogram\nnh {count:24\n# EOF\n'),
+            ('# TYPE nh histogram\nnh{} # {count:24\n# EOF\n'),
+
         ]:
             with self.assertRaises(ValueError, msg=case):
                 list(text_string_to_metric_families(case))
diff --git a/tests/test_core.py b/tests/test_core.py
index 056d8e5..f28c9ab 100644
--- a/tests/test_core.py
+++ b/tests/test_core.py
@@ -14,14 +14,9 @@ from prometheus_client.core import (
 )
 from prometheus_client.decorator import getargspec
 from prometheus_client.metrics import _get_use_created
-
-
-def is_locked(lock):
-    "Tries to obtain a lock, returns True on success, False on failure."
-    locked = lock.acquire(blocking=False)
-    if locked:
-        lock.release()
-    return not locked
+from prometheus_client.validation import (
+    disable_legacy_validation, enable_legacy_validation,
+)
 
 
 def assert_not_observable(fn, *args, **kwargs):
@@ -114,8 +109,12 @@ class TestCounter(unittest.TestCase):
         assert_not_observable(counter.inc)
 
     def test_exemplar_invalid_label_name(self):
+        enable_legacy_validation()
         self.assertRaises(ValueError, self.counter.inc, exemplar={':o)': 'smile'})
         self.assertRaises(ValueError, self.counter.inc, exemplar={'1': 'number'})
+        disable_legacy_validation()
+        self.counter.inc(exemplar={':o)': 'smile'})
+        self.counter.inc(exemplar={'1': 'number'})
 
     def test_exemplar_unicode(self):
         # 128 characters should not raise, even using characters larger than 1 byte.
@@ -510,10 +509,16 @@ class TestHistogram(unittest.TestCase):
         self.assertEqual(1, value('hl_count', {'l': 'a'}))
         self.assertEqual(1, value('hl_bucket', {'le': '+Inf', 'l': 'a'}))
 
-    def test_exemplar_invalid_label_name(self):
+    def test_exemplar_invalid_legacy_label_name(self):
+        enable_legacy_validation()
         self.assertRaises(ValueError, self.histogram.observe, 3.0, exemplar={':o)': 'smile'})
         self.assertRaises(ValueError, self.histogram.observe, 3.0, exemplar={'1': 'number'})
 
+    def test_exemplar_invalid_label_name(self):
+        disable_legacy_validation()
+        self.histogram.observe(3.0, exemplar={':o)': 'smile'})
+        self.histogram.observe(3.0, exemplar={'1': 'number'})
+
     def test_exemplar_too_long(self):
         # 129 characters in total should fail.
         self.assertRaises(ValueError, self.histogram.observe, 1.0, exemplar={
@@ -654,7 +659,8 @@ class TestMetricWrapper(unittest.TestCase):
         self.assertRaises(ValueError, self.two_labels.labels)
         self.assertRaises(ValueError, self.two_labels.labels, {'a': 'x'}, b='y')
 
-    def test_invalid_names_raise(self):
+    def test_invalid_legacy_names_raise(self):
+        enable_legacy_validation()
         self.assertRaises(ValueError, Counter, '', 'help')
         self.assertRaises(ValueError, Counter, '^', 'help')
         self.assertRaises(ValueError, Counter, '', 'help', namespace='&')
@@ -664,6 +670,14 @@ class TestMetricWrapper(unittest.TestCase):
         self.assertRaises(ValueError, Counter, 'c_total', '', labelnames=['__reserved'])
         self.assertRaises(ValueError, Summary, 'c_total', '', labelnames=['quantile'])
 
+    def test_invalid_names_raise(self):
+        disable_legacy_validation()
+        self.assertRaises(ValueError, Counter, '', 'help')
+        self.assertRaises(ValueError, Counter, '', 'help', namespace='&')
+        self.assertRaises(ValueError, Counter, '', 'help', subsystem='(')
+        self.assertRaises(ValueError, Counter, 'c_total', '', labelnames=['__reserved'])
+        self.assertRaises(ValueError, Summary, 'c_total', '', labelnames=['quantile'])
+
     def test_empty_labels_list(self):
         Histogram('h', 'help', [], registry=self.registry)
         self.assertEqual(0, self.registry.get_sample_value('h_sum'))
@@ -714,6 +728,10 @@ class TestMetricFamilies(unittest.TestCase):
         self.custom_collector(CounterMetricFamily('c_total', 'help', value=1))
         self.assertEqual(1, self.registry.get_sample_value('c_total', {}))
 
+    def test_counter_utf8(self):
+        self.custom_collector(CounterMetricFamily('my.metric', 'help', value=1))
+        self.assertEqual(1, self.registry.get_sample_value('my.metric_total', {}))
+
     def test_counter_total(self):
         self.custom_collector(CounterMetricFamily('c_total', 'help', value=1))
         self.assertEqual(1, self.registry.get_sample_value('c_total', {}))
@@ -986,7 +1004,7 @@ class TestCollectorRegistry(unittest.TestCase):
         m = Metric('target', 'Target metadata', 'info')
         m.samples = [Sample('target_info', {'foo': 'bar'}, 1)]
         for _ in registry.restricted_registry(['target_info', 's_sum']).collect():
-            self.assertFalse(is_locked(registry._lock))
+            self.assertFalse(registry._lock.locked())
 
 
 if __name__ == '__main__':
diff --git a/tests/test_exposition.py b/tests/test_exposition.py
index 54bdaa9..2a3f08c 100644
--- a/tests/test_exposition.py
+++ b/tests/test_exposition.py
@@ -46,6 +46,17 @@ cc_total 1.0
 # HELP cc_created A counter
 # TYPE cc_created gauge
 cc_created 123.456
+""", generate_latest(self.registry))
+        
+    def test_counter_utf8(self):
+        c = Counter('utf8.cc', 'A counter', registry=self.registry)
+        c.inc()
+        self.assertEqual(b"""# HELP "utf8.cc_total" A counter
+# TYPE "utf8.cc_total" counter
+{"utf8.cc_total"} 1.0
+# HELP "utf8.cc_created" A counter
+# TYPE "utf8.cc_created" gauge
+{"utf8.cc_created"} 123.456
 """, generate_latest(self.registry))
 
     def test_counter_name_unit_append(self):
diff --git a/tests/test_parser.py b/tests/test_parser.py
index 61b3c8a..10a2fc9 100644
--- a/tests/test_parser.py
+++ b/tests/test_parser.py
@@ -25,6 +25,22 @@ a 1
 """)
         self.assertEqualMetrics([CounterMetricFamily("a", "help", value=1)], list(families))
 
+    def test_utf8_counter(self):
+        families = text_string_to_metric_families("""# TYPE "a.b" counter
+# HELP "a.b" help
+{"a.b"} 1
+""")
+        self.assertEqualMetrics([CounterMetricFamily("a.b", "help", value=1)], list(families))
+
+    def test_complex_name_counter(self):
+        families = text_string_to_metric_families("""# TYPE "my.counter{} # = \\" \\n" counter
+# HELP "my.counter{} # = \\" \\n" help
+{"my.counter{} # = \\" \\n", "awful. }}{{ # HELP EOF name"="\\n yikes } \\" value"} 1
+""")
+        metric = CounterMetricFamily("my.counter{} # = \" \n", "help", labels={'awful. }}{{ # HELP EOF name': '\n yikes } " value'})
+        metric.add_sample("my.counter{} # = \" \n_total", {'awful. }}{{ # HELP EOF name': '\n yikes } " value'}, 1)
+        self.assertEqual([metric], list(families))
+
     def test_simple_gauge(self):
         families = text_string_to_metric_families("""# TYPE a gauge
 # HELP a help
@@ -322,6 +338,15 @@ 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
+# HELP "my.utf8.metric.#{}=" A fancy metric with dots.
+# TYPE "my.utf8.metric.#{}=" summary
+{"my.utf8.metric.#{}=",quantile="0"} 0.013300656000000001
+{"my.utf8.metric.#{}=",quantile="0.25"} 0.013638736
+{"my.utf8.metric.#{}=",quantile="0.5"} 0.013759906
+{"my.utf8.metric.#{}=",quantile="0.75"} 0.013962066
+{"my.utf8.metric.#{}=",quantile="1"} 0.021383540000000003
+{"my.utf8.metric.#{}=_sum"} 56.12904785
+{"my.utf8.metric.#{}=_count"} 7476.0
 """
         families = list(text_string_to_metric_families(text))