Repository: Qiskit/qiskit

Issue:
`Pauli('')` confusion
### Environment

- **Qiskit Terra version**: 0.23.2
- **Python version**: 3.9.7
- **Operating system**: Linux


### What is happening?

I find the following code example puzzling:

```python
>>> from qiskit.quantum_info import Pauli
>>> Pauli("X")[[]]
Pauli('')
>>> Pauli('')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/home/garrison/serverless/.direnv/python-3.9.7/lib/python3.9/site-packages/qiskit/quantum_info/operators/symplectic/pauli.py", line 182, in __init__
    base_z, base_x, base_phase = self._from_label(data)
  File "/home/garrison/serverless/.direnv/python-3.9.7/lib/python3.9/site-packages/qiskit/quantum_info/operators/symplectic/pauli.py", line 619, in _from_label
    raise QiskitError(f'Pauli string label "{label}" is not valid.')
qiskit.exceptions.QiskitError: 'Pauli string label "" is not valid.'
```

The first REPL line allows me to construct an object whose `__repr__` is `Pauli('')`.  However, if I try to instantiate `Pauli('')` directly, it fails.

### How can we reproduce the issue?

Code snippet above.

### What should happen?

Either both statements should fail, or both should be allowed.

### Any suggestions?

I currently see no reason not to simply support `Pauli('')`, such that it works without raising an exception.


Discussion:

Resolving patch:
diff --git a/qiskit/quantum_info/operators/symplectic/pauli.py b/qiskit/quantum_info/operators/symplectic/pauli.py
--- a/qiskit/quantum_info/operators/symplectic/pauli.py
+++ b/qiskit/quantum_info/operators/symplectic/pauli.py
@@ -147,7 +147,8 @@ class initialization (``Pauli('-iXYZ')``). A ``Pauli`` object can be
     # Set the max Pauli string size before truncation
     __truncate__ = 50
 
-    _VALID_LABEL_PATTERN = re.compile(r"^[+-]?1?[ij]?[IXYZ]+$")
+    _VALID_LABEL_PATTERN = re.compile(r"(?P<coeff>[+-]?1?[ij]?)(?P<pauli>[IXYZ]*)")
+    _CANONICAL_PHASE_LABEL = {"": 0, "-i": 1, "-": 2, "i": 3}
 
     def __init__(self, data=None, x=None, *, z=None, label=None):
         """Initialize the Pauli.
@@ -613,17 +614,15 @@ def _from_label(label):
         Raises:
             QiskitError: if Pauli string is not valid.
         """
-        if Pauli._VALID_LABEL_PATTERN.match(label) is None:
+        match_ = Pauli._VALID_LABEL_PATTERN.fullmatch(label)
+        if match_ is None:
             raise QiskitError(f'Pauli string label "{label}" is not valid.')
-
-        # Split string into coefficient and Pauli
-        pauli, coeff = _split_pauli_label(label)
-
-        # Convert coefficient to phase
-        phase = 0 if not coeff else _phase_from_label(coeff)
+        phase = Pauli._CANONICAL_PHASE_LABEL[
+            (match_["coeff"] or "").replace("1", "").replace("+", "").replace("j", "i")
+        ]
 
         # Convert to Symplectic representation
-        pauli_bytes = np.frombuffer(pauli.encode("ascii"), dtype=np.uint8)[::-1]
+        pauli_bytes = np.frombuffer(match_["pauli"].encode("ascii"), dtype=np.uint8)[::-1]
         ys = pauli_bytes == ord("Y")
         base_x = np.logical_or(pauli_bytes == ord("X"), ys).reshape(1, -1)
         base_z = np.logical_or(pauli_bytes == ord("Z"), ys).reshape(1, -1)
@@ -698,33 +697,5 @@ def _from_circuit(cls, instr):
         return ret._z, ret._x, ret._phase
 
 
-# ---------------------------------------------------------------------
-# Label parsing helper functions
-# ---------------------------------------------------------------------
-
-
-def _split_pauli_label(label):
-    """Split Pauli label into unsigned group label and coefficient label"""
-    span = re.search(r"[IXYZ]+", label).span()
-    pauli = label[span[0] :]
-    coeff = label[: span[0]]
-    if span[1] != len(label):
-        invalid = set(re.sub(r"[IXYZ]+", "", label[span[0] :]))
-        raise QiskitError(
-            f"Pauli string contains invalid characters {invalid} ∉ ['I', 'X', 'Y', 'Z']"
-        )
-    return pauli, coeff
-
-
-def _phase_from_label(label):
-    """Return the phase from a label"""
-    # Returns None if label is invalid
-    label = label.replace("+", "", 1).replace("1", "", 1).replace("j", "i", 1)
-    phases = {"": 0, "-i": 1, "-": 2, "i": 3}
-    if label not in phases:
-        raise QiskitError(f"Invalid Pauli phase label '{label}'")
-    return phases[label]
-
-
 # Update docstrings for API docs
 generate_apidocs(Pauli)


Test patch:
diff --git a/test/python/opflow/test_op_construction.py b/test/python/opflow/test_op_construction.py
--- a/test/python/opflow/test_op_construction.py
+++ b/test/python/opflow/test_op_construction.py
@@ -63,7 +63,6 @@
     Zero,
 )
 from qiskit.quantum_info import Operator, Pauli, Statevector
-from qiskit.quantum_info.operators.symplectic.pauli import _phase_from_label, _split_pauli_label
 
 # pylint: disable=invalid-name
 
@@ -1242,9 +1241,7 @@ def pauli_group_labels(nq, full_group=True):
 
 def operator_from_label(label):
     """Construct operator from full Pauli group label"""
-    pauli, coeff = _split_pauli_label(label)
-    coeff = (-1j) ** _phase_from_label(coeff)
-    return coeff * Operator.from_label(pauli)
+    return Operator(Pauli(label))
 
 
 @ddt
diff --git a/test/python/primitives/test_estimator.py b/test/python/primitives/test_estimator.py
--- a/test/python/primitives/test_estimator.py
+++ b/test/python/primitives/test_estimator.py
@@ -696,7 +696,7 @@ def test_validate_observables(self, obsevables, expected):
         """Test obsevables standardization."""
         self.assertEqual(BaseEstimator._validate_observables(obsevables), expected)
 
-    @data(None, "ERROR", "")
+    @data(None, "ERROR")
     def test_qiskit_error(self, observables):
         """Test qiskit error if invalid input."""
         with self.assertRaises(QiskitError):
diff --git a/test/python/quantum_info/operators/symplectic/test_pauli.py b/test/python/quantum_info/operators/symplectic/test_pauli.py
--- a/test/python/quantum_info/operators/symplectic/test_pauli.py
+++ b/test/python/quantum_info/operators/symplectic/test_pauli.py
@@ -14,6 +14,7 @@
 
 """Tests for Pauli operator class."""
 
+import re
 import unittest
 import itertools as it
 from functools import lru_cache
@@ -41,7 +42,19 @@
 
 from qiskit.quantum_info.random import random_clifford, random_pauli
 from qiskit.quantum_info.operators import Pauli, Operator
-from qiskit.quantum_info.operators.symplectic.pauli import _split_pauli_label, _phase_from_label
+
+LABEL_REGEX = re.compile(r"(?P<coeff>[+-]?1?[ij]?)(?P<pauli>[IXYZ]*)")
+PHASE_MAP = {"": 0, "-i": 1, "-": 2, "i": 3}
+
+
+def _split_pauli_label(label):
+    match_ = LABEL_REGEX.fullmatch(label)
+    return match_["pauli"], match_["coeff"]
+
+
+def _phase_from_label(label):
+    coeff = LABEL_REGEX.fullmatch(label)["coeff"] or ""
+    return PHASE_MAP[coeff.replace("+", "").replace("1", "").replace("j", "i")]
 
 
 @lru_cache(maxsize=8)
@@ -462,6 +475,15 @@ def test_barrier_delay_sim(self):
         value = Pauli(circ)
         self.assertEqual(value, target)
 
+    @data(("", 0), ("-", 2), ("i", 3), ("-1j", 1))
+    @unpack
+    def test_zero_qubit_pauli_construction(self, label, phase):
+        """Test that Paulis of zero qubits can be constructed."""
+        expected = Pauli(label + "X")[0:0]  # Empty slice from a 1q Pauli, which becomes phaseless
+        expected.phase = phase
+        test = Pauli(label)
+        self.assertEqual(expected, test)
+
 
 if __name__ == "__main__":
     unittest.main()