| # Licensed to the Apache Software Foundation (ASF) under one | |
| # or more contributor license agreements. See the NOTICE file | |
| # distributed with this work for additional information | |
| # regarding copyright ownership. The ASF licenses this file | |
| # to you under the Apache License, Version 2.0 (the | |
| # "License"); you may not use this file except in compliance | |
| # with the License. You may obtain a copy of the License at | |
| # | |
| # http://www.apache.org/licenses/LICENSE-2.0 | |
| # | |
| # Unless required by applicable law or agreed to in writing, | |
| # software distributed under the License is distributed on an | |
| # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | |
| # KIND, either express or implied. See the License for the | |
| # specific language governing permissions and limitations | |
| # under the License. | |
| """Ensure exposed pyclasses default to frozen.""" | |
| from __future__ import annotations | |
| import re | |
| from dataclasses import dataclass | |
| from pathlib import Path | |
| from typing import TYPE_CHECKING | |
| if TYPE_CHECKING: | |
| from collections.abc import Iterator | |
| PYCLASS_RE = re.compile( | |
| r"#\[\s*pyclass\s*(?:\((?P<args>.*?)\))?\s*\]", | |
| re.DOTALL, | |
| ) | |
| ARG_STRING_RE = re.compile( | |
| r"(?P<key>[A-Za-z_][A-Za-z0-9_]*)\s*=\s*\"(?P<value>[^\"]+)\"", | |
| ) | |
| STRUCT_NAME_RE = re.compile( | |
| r"\b(?:pub\s+)?(?:struct|enum)\s+" r"(?P<name>[A-Za-z_][A-Za-z0-9_]*)", | |
| ) | |
| @dataclass | |
| class PyClass: | |
| module: str | |
| name: str | |
| frozen: bool | |
| source: Path | |
| def iter_pyclasses(root: Path) -> Iterator[PyClass]: | |
| for path in root.rglob("*.rs"): | |
| text = path.read_text(encoding="utf8") | |
| for match in PYCLASS_RE.finditer(text): | |
| args = match.group("args") or "" | |
| frozen = re.search(r"\bfrozen\b", args) is not None | |
| module = None | |
| name = None | |
| for arg_match in ARG_STRING_RE.finditer(args): | |
| key = arg_match.group("key") | |
| value = arg_match.group("value") | |
| if key == "module": | |
| module = value | |
| elif key == "name": | |
| name = value | |
| remainder = text[match.end() :] | |
| struct_match = STRUCT_NAME_RE.search(remainder) | |
| struct_name = struct_match.group("name") if struct_match else None | |
| yield PyClass( | |
| module=module or "datafusion", | |
| name=name or struct_name or "<unknown>", | |
| frozen=frozen, | |
| source=path, | |
| ) | |
| def test_pyclasses_are_frozen() -> None: | |
| allowlist = { | |
| # NOTE: Any new exceptions must include a justification comment | |
| # in the Rust source and, ideally, a follow-up issue to remove | |
| # the exemption. | |
| ("datafusion.common", "SqlTable"), | |
| ("datafusion.common", "SqlView"), | |
| ("datafusion.common", "DataTypeMap"), | |
| ("datafusion.expr", "TryCast"), | |
| ("datafusion.expr", "WriteOp"), | |
| } | |
| unfrozen = [ | |
| pyclass | |
| for pyclass in iter_pyclasses(Path("src")) | |
| if not pyclass.frozen and (pyclass.module, pyclass.name) not in allowlist | |
| ] | |
| if unfrozen: | |
| msg = ( | |
| "Found pyclasses missing `frozen`; add them to the allowlist only " | |
| "with a justification comment and follow-up plan:\n" | |
| ) | |
| msg += "\n".join( | |
| (f"- {pyclass.module}.{pyclass.name} (defined in {pyclass.source})") | |
| for pyclass in unfrozen | |
| ) | |
| assert not unfrozen, msg |