diff --git a/.github/workflows/pydoclint.yml b/.github/workflows/pydoclint.yml new file mode 100644 index 00000000..7a72b218 --- /dev/null +++ b/.github/workflows/pydoclint.yml @@ -0,0 +1,54 @@ +name: pydoclint (report only) + +on: [push, pull_request] + +jobs: + pydoclint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install pydoclint + run: pip install pydoclint + + - name: Run pydoclint + run: | + echo "## pydoclint report" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + + exit_code=0 + for f in $(find graphix -name '*.py' | sort); do + output=$(pydoclint "$f" --style=numpy 2>&1) || true + + # Check for actual parser crash (traceback in output) + if echo "$output" | grep -q "^Traceback"; then + echo "### ❌ CRASH: \`$f\`" >> "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" + echo "$output" | tail -20 >> "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" + exit_code=2 + continue + fi + + # Check for violations (lines matching " 123: DOC456:") + violations=$(echo "$output" | grep -E '^\s+[0-9]+: DOC[0-9]{3}:' || true) + if [ -n "$violations" ]; then + count=$(echo "$violations" | wc -l) + echo "
$f — $count violation(s)" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" + echo "$violations" >> "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" + echo "
" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + fi + done + + if [ "$exit_code" -eq 2 ]; then + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "**Job failed due to parser crash(es). Fix the docstring(s) above.**" >> "$GITHUB_STEP_SUMMARY" + exit 1 + fi + + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "_Violations are reported but do not fail the job._" >> "$GITHUB_STEP_SUMMARY" \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a4d4cda8..4665bc4a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v5.0.0 + rev: v6.0.0 hooks: # Non-fixable - id: check-case-conflict @@ -10,3 +10,16 @@ repos: - id: fix-byte-order-marker - id: mixed-line-ending - id: trailing-whitespace + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.1 + hooks: + - id: ruff + args: ["--fix"] + - id: ruff-format + + # - repo: https://github.com/jsh9/pydoclint + # rev: 0.8.3 + # hooks: + # - id: pydoclint + # args: ["--style=numpy", "--exclude=examples/|benchmarks/"] diff --git a/graphix/_linalg.py b/graphix/_linalg.py index 3667b597..2e1e75e6 100644 --- a/graphix/_linalg.py +++ b/graphix/_linalg.py @@ -20,7 +20,7 @@ def __new__(cls, data: npt.ArrayLike, copy: bool = True) -> Self: Parameters ---------- - data : array + data : npt.ArrayLike Data in array copy : bool Optional, defaults to `True`. If `False` and if possible, data @@ -28,7 +28,8 @@ def __new__(cls, data: npt.ArrayLike, copy: bool = True) -> Self: Return ------- - MatGF2 + MatGF2 + New `MatGF2` object. """ arr = np.array(data, dtype=np.uint8, copy=copy) return super().__new__(cls, shape=arr.shape, dtype=arr.dtype, buffer=arr) @@ -38,7 +39,7 @@ def mat_mul(self, other: MatGF2 | npt.NDArray[np.uint8]) -> MatGF2: Parameters ---------- - other : array + other : MatGF2 | npt.NDArray[np.uint8] Matrix that right-multiplies `self`. Returns @@ -67,7 +68,7 @@ def compute_rank(self) -> np.intp: Returns ------- - int : int + int Rank of the matrix. """ mat_a = self.row_reduction(copy=True) @@ -78,10 +79,8 @@ def right_inverse(self) -> MatGF2 | None: Returns ------- - rinv : MatGF2 - Any right inverse of the matrix. - or `None` - If the matrix does not have a right inverse. + rinv : MatGF2 | None + Any right inverse of the matrix. None if the matrix does not have a right inverse. Notes ----- @@ -101,12 +100,11 @@ def right_inverse(self) -> MatGF2 | None: # We don't use `MatGF2.compute_rank()` to avoid row-reducing twice. if m != np.count_nonzero(red[:, :n].any(axis=1)): return None - rinv = np.zeros((n, m), dtype=np.uint8).view(MatGF2) + rinv: MatGF2 = np.zeros((n, m), dtype=np.uint8).view(MatGF2) for i, row in enumerate(red): j = np.flatnonzero(row)[0] # Column index corresponding to the leading 1 in row `i`. rinv[j, :] = red[i, n:] - return rinv def null_space(self) -> MatGF2: diff --git a/graphix/graphsim.py b/graphix/graphsim.py index 186392c7..8e8522be 100644 --- a/graphix/graphsim.py +++ b/graphix/graphsim.py @@ -469,8 +469,8 @@ def draw(self, fill_color: str = "C0") -> None: Parameters ---------- - fill_color : str - optional, fill color of nodes + fill_color : str (optional) + fill color of nodes """ nqubit = len(self.nodes) nodes = list(self.nodes) @@ -490,7 +490,13 @@ def draw(self, fill_color: str = "C0") -> None: nx.draw(g, labels=labels, node_color=colors, edgecolors="k") def to_statevector(self) -> Statevec: - """Convert the graph state into a state vector.""" + """Convert the graph state into a state vector. + + Returns + ------- + gstate : Statevec + state vector representation of the graph state + """ node_list = list(self.nodes) nqubit = len(self.nodes) gstate = Statevec(nqubit=nqubit) @@ -511,5 +517,11 @@ def to_statevector(self) -> Statevec: return gstate def isolated_nodes(self) -> list[int]: - """Return a list of isolated nodes (nodes with no edges).""" + """Return a list of isolated nodes (nodes with no edges). + + Returns + ------- + list[int] + list of isolated nodes + """ return list(nx.isolates(self)) diff --git a/graphix/sim/base_backend.py b/graphix/sim/base_backend.py index 6cef7e33..2f01da7c 100644 --- a/graphix/sim/base_backend.py +++ b/graphix/sim/base_backend.py @@ -158,7 +158,7 @@ def vdot(a: Matrix, b: Matrix) -> ExpressionOrComplex: Returns ------- - ExpressionOrFloat + ExpressionOrComplex Dot product. Raises diff --git a/graphix/sim/density_matrix.py b/graphix/sim/density_matrix.py index cb5570e2..77061c70 100644 --- a/graphix/sim/density_matrix.py +++ b/graphix/sim/density_matrix.py @@ -33,7 +33,18 @@ class DensityMatrix(DenseState): - """DensityMatrix object.""" + """DensityMatrix object. + + Attributes + ---------- + rho : Matrix + density matrix + + See Also + -------- + graphix.statevec.Statevec + + """ rho: Matrix @@ -106,7 +117,13 @@ def cast_row( @property def nqubit(self) -> int: - """Return the number of qubits.""" + """Return the number of qubits. + + Returns + ------- + int + number of qubits + """ # Circumvent typing bug with numpy>=2.3 # `shape` field is typed `tuple[Any, ...]` instead of `tuple[int, ...]` # See https://github.com/numpy/numpy/issues/29830 @@ -114,7 +131,13 @@ def nqubit(self) -> int: return nqubit def __str__(self) -> str: - """Return a string description.""" + """Return a string description. + + Returns + ------- + str + string description + """ return f"DensityMatrix object, with density matrix {self.rho} and shape {self.dims()}." @override @@ -127,7 +150,7 @@ def add_nodes(self, nqubit: int, data: Data) -> None: nqubit : int The number of qubits to add to the density matrix. - data : Data, optional + data : Data The state in which to initialize the newly added nodes. - If a single basic state is provided, all new nodes are initialized in that state. @@ -141,6 +164,7 @@ def add_nodes(self, nqubit: int, data: Data) -> None: Notes ----- Previously existing nodes remain unchanged. + """ dm_to_add = DensityMatrix(nqubit=nqubit, data=data) self.tensor(dm_to_add) @@ -151,16 +175,15 @@ def evolve_single(self, op: Matrix, i: int) -> None: Parameters ---------- - op : np.ndarray - 2*2 matrix. - i : int - Index of qubit to apply operator. + op : Matrix + 2*2 matrix. + i : int + Index of qubit to apply operator. """ assert i >= 0 assert i < self.nqubit if op.shape != (2, 2): raise ValueError("op must be 2*2 matrix.") - rho_tensor = self.rho.reshape((2,) * self.nqubit * 2) rho_tensor = tensordot(tensordot(op, rho_tensor, axes=(1, i)), op.conj().T, axes=(i + self.nqubit, 0)) rho_tensor = np.moveaxis(rho_tensor, (0, -1), (i, i + self.nqubit)) @@ -170,9 +193,12 @@ def evolve_single(self, op: Matrix, i: int) -> None: def evolve(self, op: Matrix, qargs: Sequence[int]) -> None: """Multi-qubit operation. - Args: - op (np.array): 2^n*2^n matrix - qargs (list of ints): target qubits' indexes + Parameters + ---------- + op : Matrix + 2^n*2^n matrix + qargs : list of ints + target qubits' indexes """ d = op.shape # check it is a matrix. @@ -218,13 +244,17 @@ def evolve(self, op: Matrix, qargs: Sequence[int]) -> None: def expectation_single(self, op: Matrix, loc: int) -> complex: """Return the expectation value of single-qubit operator. - Args: - op (np.array): 2*2 Hermite operator - loc (int): Index of qubit on which to apply operator. + Parameters + ---------- + op : Matrix + 2*2 matrix. + loc : int + Index of qubit to apply operator. Returns ------- - complex: expectation value (real for hermitian ops!). + complex + expectation value (real for hermitian ops). """ if not (0 <= loc < self.nqubit): raise ValueError(f"Wrong target qubit {loc}. Must between 0 and {self.nqubit - 1}.") @@ -244,7 +274,13 @@ def expectation_single(self, op: Matrix, loc: int) -> complex: return complex(np.trace(rho_tensor.reshape((2**nqubit, 2**nqubit)))) def dims(self) -> tuple[int, ...]: - """Return the dimensions of the density matrix.""" + """Return the dimensions of the density matrix. + + Returns + ------- + tuple[int, int] + dimensions of the density matrix + """ return self.rho.shape def tensor(self, other: DensityMatrix) -> None: @@ -254,8 +290,8 @@ def tensor(self, other: DensityMatrix) -> None: Parameters ---------- - other : :class: `DensityMatrix` object - DensityMatrix object to be tensored with self. + other : DensityMatrix + DensityMatrix object to be tensored with self. """ if not isinstance(other, DensityMatrix): other = DensityMatrix(other) @@ -266,8 +302,8 @@ def cnot(self, edge: tuple[int, int]) -> None: Parameters ---------- - edge : (int, int) or [int, int] - Edge to apply CNOT gate. + edge : tuple[int, int] + Edge to apply CNOT gate. """ self.evolve(CNOT_TENSOR.reshape(4, 4), edge) @@ -277,8 +313,8 @@ def swap(self, qubits: tuple[int, int]) -> None: Parameters ---------- - qubits : (int, int) - (control, target) qubits indices. + qubits : tuple[int, int] + (control, target) qubits indices. """ self.evolve(SWAP_TENSOR.reshape(4, 4), qubits) @@ -287,8 +323,8 @@ def entangle(self, edge: tuple[int, int]) -> None: Parameters ---------- - edge : (int, int) or [int, int] - (control, target) qubit indices. + edge : tuple[int, int] + (control, target) qubit indices. """ self.evolve(CZ_TENSOR.reshape(4, 4), edge) @@ -307,7 +343,14 @@ def normalize(self) -> None: @override def remove_qubit(self, qarg: int) -> None: - """Remove a qubit.""" + """Remove a qubit. + + Parameters + ---------- + qarg : int + Index of qubit to remove. + + """ self.ptrace(qarg) self.normalize() @@ -316,8 +359,8 @@ def ptrace(self, qargs: Collection[int] | int) -> None: Parameters ---------- - qargs : list of ints or int - Indices of qubit to trace out. + qargs : list[int] or int + Indices of qubit to trace out. """ n = int(np.log2(self.rho.shape[0])) if isinstance(qargs, int): @@ -341,8 +384,13 @@ def fidelity(self, statevec: Statevec) -> ExpressionOrFloat: Parameters ---------- - statevec : numpy array - statevector (flattened numpy array) to compare with + statevec : Statevec + statevector (flattened numpy array) to compare with + + Returns + ------- + ExpressionOrFloat + fidelity between the density matrix and the reference statevector """ result = vdot(statevec.psi, matmul(self.rho, statevec.psi)) if isinstance(result, Expression): @@ -351,7 +399,13 @@ def fidelity(self, statevec: Statevec) -> ExpressionOrFloat: return result.real def flatten(self) -> Matrix: - """Return flattened density matrix.""" + """Return flattened density matrix. + + Returns + ------- + Matrix + flattened density matrix + """ return self.rho.flatten() def apply_channel(self, channel: KrausChannel, qargs: Sequence[int]) -> None: @@ -359,21 +413,18 @@ def apply_channel(self, channel: KrausChannel, qargs: Sequence[int]) -> None: Parameters ---------- - :rho: density matrix. - channel: :class:`graphix.channel.KrausChannel` object + channel : KrausChannel KrausChannel to be applied to the density matrix - qargs: target qubit indices - - Returns - ------- - nothing + qargs : list[int] + target qubits' indices Raises ------ ValueError If the final density matrix is not normalized after application of the channel. This shouldn't happen since :class:`graphix.channel.KrausChannel` objects are normalized by construction. - .... + TypeError + If the provided channel is not a :class:`graphix.channel.KrausChannel` object. """ result_array = np.zeros((2**self.nqubit, 2**self.nqubit), dtype=np.complex128) @@ -406,13 +457,37 @@ def apply_noise(self, qubits: Sequence[int], noise: Noise) -> None: self.apply_channel(channel, qubits) def subs(self, variable: Parameter, substitute: ExpressionOrSupportsFloat) -> DensityMatrix: - """Return a copy of the density matrix where all occurrences of the given variable in measurement angles are substituted by the given value.""" + """Return a copy of the density matrix where all occurrences of the given variable in measurement angles are substituted by the given value. + + Parameters + ---------- + variable : Parameter + The symbolic expression to be replaced within the measurement angles. + substitute : ExpressionOrSupportsFloat + The value or symbolic expression to substitute in place of variable. + + Returns + ------- + DensityMatrix + A new DensityMatrix instance with the specified substitutions applied to the measurement angles. + """ result = copy.copy(self) result.rho = np.vectorize(lambda value: parameter.subs(value, variable, substitute))(self.rho) return result def xreplace(self, assignment: Mapping[Parameter, ExpressionOrSupportsFloat]) -> DensityMatrix: - """Return a copy of the density matrix where all occurrences of the given keys in measurement angles are substituted by the given values in parallel.""" + """Return a copy of the density matrix where all occurrences of the given keys in measurement angles are substituted by the given values in parallel. + + Parameters + ---------- + assignment : Mapping[Parameter, ExpressionOrSupportsFloat] + A dictionary-like mapping where keys are the `Parameter` objects to be replaced and values are the new expressions or numerical values. + + Returns + ------- + DensityMatrix + A new DensityMatrix instance with the specified substitutions applied to the measurement angles. + """ result = copy.copy(self) result.rho = np.vectorize(lambda value: parameter.xreplace(value, assignment))(self.rho) return result @@ -420,6 +495,12 @@ def xreplace(self, assignment: Mapping[Parameter, ExpressionOrSupportsFloat]) -> @dataclass(frozen=True) class DensityMatrixBackend(DenseStateBackend[DensityMatrix]): - """MBQC simulator with density matrix method.""" + """MBQC simulator with density matrix method. + + Attributes + ---------- + state : DensityMatrix + density matrix + """ state: DensityMatrix = dataclasses.field(init=False, default_factory=lambda: DensityMatrix(nqubit=0)) diff --git a/pyproject.toml b/pyproject.toml index 5288e18c..4fca0654 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -125,6 +125,12 @@ ban-relative-imports = "all" [tool.ruff.lint.isort] required-imports = ["from __future__ import annotations"] +[tool.pydoclint] +style = "numpy" +check-arg-order = true +check-return = true +check-yield = true + [tool.pytest.ini_options] addopts = ["--ignore=examples", "--ignore=docs", "--ignore=benchmarks", "--benchmark-autosave"] # Silence cotengra warning diff --git a/requirements-dev.txt b/requirements-dev.txt index 12c6dcc2..df822dae 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -26,4 +26,4 @@ qiskit_qasm3_import qiskit-aer; python_version < "3.14" openqasm-parser>=3.1.0 -graphix-qasm-parser>=0.1.1 +# graphix-qasm-parser>=0.1.1