From 23dcafe69aa512ca55218597006bf2d2978223d4 Mon Sep 17 00:00:00 2001 From: Remco de Boer <29308176+redeboer@users.noreply.github.com> Date: Sun, 10 Mar 2024 18:20:00 +0100 Subject: [PATCH 1/4] FIX: fix `executor` class typo --- src/compwa_policy/utilities/executor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/compwa_policy/utilities/executor.py b/src/compwa_policy/utilities/executor.py index 58da424c..cee8f8a2 100644 --- a/src/compwa_policy/utilities/executor.py +++ b/src/compwa_policy/utilities/executor.py @@ -2,7 +2,7 @@ .. autolink-preface:: from compwa_policy.errors import PrecommitError - from compwa_policy.utilities.executor import executor + from compwa_policy.utilities.executor import Executor """ from __future__ import annotations From f0995faecf543bec1d9123c868f59581e82265e0 Mon Sep 17 00:00:00 2001 From: Remco de Boer <29308176+redeboer@users.noreply.github.com> Date: Sun, 10 Mar 2024 18:20:00 +0100 Subject: [PATCH 2/4] ENH: allow using `PyprojectTOML` as context manager --- src/compwa_policy/check_dev_files/pyright.py | 9 +++------ .../utilities/pyproject/__init__.py | 16 +++++++++++++++- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/compwa_policy/check_dev_files/pyright.py b/src/compwa_policy/check_dev_files/pyright.py index 35fe1964..9b5ad5e0 100644 --- a/src/compwa_policy/check_dev_files/pyright.py +++ b/src/compwa_policy/check_dev_files/pyright.py @@ -6,17 +6,14 @@ import os from compwa_policy.utilities import CONFIG_PATH -from compwa_policy.utilities.executor import Executor from compwa_policy.utilities.pyproject import PyprojectTOML, complies_with_subset from compwa_policy.utilities.toml import to_toml_array def main() -> None: - pyproject = PyprojectTOML.load() - with Executor() as do: - do(_merge_config_into_pyproject, pyproject) - do(_update_settings, pyproject) - do(pyproject.finalize) + with PyprojectTOML.load() as pyproject: + _merge_config_into_pyproject(pyproject) + _update_settings(pyproject) def _merge_config_into_pyproject(pyproject: PyprojectTOML) -> None: diff --git a/src/compwa_policy/utilities/pyproject/__init__.py b/src/compwa_policy/utilities/pyproject/__init__.py index 5cdb2ada..98bf7f0b 100644 --- a/src/compwa_policy/utilities/pyproject/__init__.py +++ b/src/compwa_policy/utilities/pyproject/__init__.py @@ -4,6 +4,7 @@ import io import sys +from contextlib import AbstractContextManager from pathlib import Path from textwrap import indent from typing import IO, TYPE_CHECKING, Iterable, Sequence, overload @@ -33,12 +34,14 @@ else: from typing import Literal if TYPE_CHECKING: + from types import TracebackType + from tomlkit.items import Table from tomlkit.toml_document import TOMLDocument @frozen -class PyprojectTOML: +class PyprojectTOML(AbstractContextManager): """Stateful representation of a :code:`pyproject.toml` file. Use this class to apply multiple modifications to a :code:`pyproject.toml` file in @@ -50,6 +53,17 @@ class PyprojectTOML: source: IO | Path | None = field(default=None) modifications: list[str] = field(factory=list, init=False) + def __enter__(self) -> PyprojectTOML: + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + tb: TracebackType | None, + ) -> None: + self.finalize() + @classmethod def load(cls, source: IO | Path | str = CONFIG_PATH.pyproject) -> PyprojectTOML: """Load a :code:`pyproject.toml` file from a file, I/O stream, or `str`.""" From ae04ce94449a4849f4412b0c33f18ac453d1fdf9 Mon Sep 17 00:00:00 2001 From: Remco de Boer <29308176+redeboer@users.noreply.github.com> Date: Sun, 10 Mar 2024 18:23:03 +0100 Subject: [PATCH 3/4] BREAK: rename `PyprojectToml` to `Pyproject` --- src/compwa_policy/check_dev_files/black.py | 8 ++-- .../check_dev_files/github_workflows.py | 8 +--- src/compwa_policy/check_dev_files/gitpod.py | 4 +- src/compwa_policy/check_dev_files/jupyter.py | 4 +- src/compwa_policy/check_dev_files/mypy.py | 8 ++-- .../check_dev_files/precommit.py | 4 +- src/compwa_policy/check_dev_files/pyright.py | 8 ++-- src/compwa_policy/check_dev_files/pytest.py | 10 ++-- .../check_dev_files/pyupgrade.py | 4 +- src/compwa_policy/check_dev_files/ruff.py | 48 +++++++++---------- src/compwa_policy/check_dev_files/toml.py | 4 +- src/compwa_policy/set_nb_cells.py | 4 +- .../utilities/pyproject/__init__.py | 12 ++--- .../utilities/pyproject/getters.py | 4 +- tests/utilities/test_pyproject.py | 12 ++--- 15 files changed, 69 insertions(+), 73 deletions(-) diff --git a/src/compwa_policy/check_dev_files/black.py b/src/compwa_policy/check_dev_files/black.py index f14506aa..c1f76808 100644 --- a/src/compwa_policy/check_dev_files/black.py +++ b/src/compwa_policy/check_dev_files/black.py @@ -10,14 +10,14 @@ remove_precommit_hook, update_single_hook_precommit_repo, ) -from compwa_policy.utilities.pyproject import PyprojectTOML, complies_with_subset +from compwa_policy.utilities.pyproject import Pyproject, complies_with_subset from compwa_policy.utilities.toml import to_toml_array def main(has_notebooks: bool) -> None: if not CONFIG_PATH.pyproject.exists(): return - pyproject = PyprojectTOML.load() + pyproject = Pyproject.load() with Executor() as do: do(_remove_outdated_settings, pyproject) do(_update_black_settings, pyproject) @@ -50,7 +50,7 @@ def main(has_notebooks: bool) -> None: do(pyproject.finalize) -def _remove_outdated_settings(pyproject: PyprojectTOML) -> None: +def _remove_outdated_settings(pyproject: Pyproject) -> None: settings = pyproject.get_table("tool.black", create=True) forbidden_options = ("line-length",) removed_options = set() @@ -66,7 +66,7 @@ def _remove_outdated_settings(pyproject: PyprojectTOML) -> None: pyproject.modifications.append(msg) -def _update_black_settings(pyproject: PyprojectTOML) -> None: +def _update_black_settings(pyproject: Pyproject) -> None: settings = pyproject.get_table("tool.black", create=True) versions = pyproject.get_supported_python_versions() target_version = to_toml_array(sorted("py" + v.replace(".", "") for v in versions)) diff --git a/src/compwa_policy/check_dev_files/github_workflows.py b/src/compwa_policy/check_dev_files/github_workflows.py index 8d2e194c..b6d82ae9 100644 --- a/src/compwa_policy/check_dev_files/github_workflows.py +++ b/src/compwa_policy/check_dev_files/github_workflows.py @@ -19,11 +19,7 @@ ) from compwa_policy.utilities.executor import Executor from compwa_policy.utilities.precommit import load_precommit_config -from compwa_policy.utilities.pyproject import ( - PyprojectTOML, - PythonVersion, - get_build_system, -) +from compwa_policy.utilities.pyproject import Pyproject, PythonVersion, get_build_system from compwa_policy.utilities.yaml import create_prettier_round_trip_yaml if TYPE_CHECKING: @@ -238,7 +234,7 @@ def __update_with_section(config: dict, job_name: str) -> None: def __get_package_name() -> str: - pypi_name = PyprojectTOML.load().get_package_name(raise_on_missing=True) + pypi_name = Pyproject.load().get_package_name(raise_on_missing=True) package_name = pypi_name.replace("-", "_").lower() if os.path.exists(f"src/{package_name}/"): return package_name diff --git a/src/compwa_policy/check_dev_files/gitpod.py b/src/compwa_policy/check_dev_files/gitpod.py index aee5f0d8..828f7691 100644 --- a/src/compwa_policy/check_dev_files/gitpod.py +++ b/src/compwa_policy/check_dev_files/gitpod.py @@ -8,7 +8,7 @@ from compwa_policy.errors import PrecommitError from compwa_policy.utilities import COMPWA_POLICY_DIR, CONFIG_PATH from compwa_policy.utilities.pyproject import ( - PyprojectTOML, + Pyproject, PythonVersion, get_constraints_file, ) @@ -37,7 +37,7 @@ def main(no_gitpod: bool, python_version: PythonVersion) -> None: error_message += ". Problem has been fixed." raise PrecommitError(error_message) try: - repo_url = PyprojectTOML.load().get_repo_url() + repo_url = Pyproject.load().get_repo_url() add_badge( f"[![GitPod](https://img.shields.io/badge/gitpod-open-blue?logo=gitpod)](https://gitpod.io/#{repo_url})" ) diff --git a/src/compwa_policy/check_dev_files/jupyter.py b/src/compwa_policy/check_dev_files/jupyter.py index 70720dcd..4a573659 100644 --- a/src/compwa_policy/check_dev_files/jupyter.py +++ b/src/compwa_policy/check_dev_files/jupyter.py @@ -1,7 +1,7 @@ """Update the developer setup when using Jupyter notebooks.""" from compwa_policy.utilities.executor import Executor -from compwa_policy.utilities.pyproject import PyprojectTOML, get_build_system +from compwa_policy.utilities.pyproject import Pyproject, get_build_system def main() -> None: @@ -11,7 +11,7 @@ def main() -> None: def _update_dev_requirements() -> None: if get_build_system() is None: return - pyproject = PyprojectTOML.load() + pyproject = Pyproject.load() supported_python_versions = pyproject.get_supported_python_versions() if "3.6" in supported_python_versions: return diff --git a/src/compwa_policy/check_dev_files/mypy.py b/src/compwa_policy/check_dev_files/mypy.py index 58fc8fca..0c9fdadd 100644 --- a/src/compwa_policy/check_dev_files/mypy.py +++ b/src/compwa_policy/check_dev_files/mypy.py @@ -8,18 +8,18 @@ from compwa_policy.errors import PrecommitError from compwa_policy.utilities import CONFIG_PATH, vscode from compwa_policy.utilities.executor import Executor -from compwa_policy.utilities.pyproject import PyprojectTOML +from compwa_policy.utilities.pyproject import Pyproject def main() -> None: - pyproject = PyprojectTOML.load() + pyproject = Pyproject.load() with Executor() as do: do(_merge_mypy_into_pyproject, pyproject) do(_update_vscode_settings, pyproject) do(pyproject.finalize) -def _update_vscode_settings(pyproject: PyprojectTOML) -> None: +def _update_vscode_settings(pyproject: Pyproject) -> None: mypy_config = pyproject.get_table("tool.mypy") with Executor() as do: if not mypy_config: @@ -40,7 +40,7 @@ def _update_vscode_settings(pyproject: PyprojectTOML) -> None: do(vscode.update_settings, settings) -def _merge_mypy_into_pyproject(pyproject: PyprojectTOML) -> None: +def _merge_mypy_into_pyproject(pyproject: Pyproject) -> None: config_path = ".mypy.ini" if not os.path.exists(config_path): return diff --git a/src/compwa_policy/check_dev_files/precommit.py b/src/compwa_policy/check_dev_files/precommit.py index 6b4b26ee..4220f895 100644 --- a/src/compwa_policy/check_dev_files/precommit.py +++ b/src/compwa_policy/check_dev_files/precommit.py @@ -19,7 +19,7 @@ load_precommit_config, load_roundtrip_precommit_config, ) -from compwa_policy.utilities.pyproject import PyprojectTOML, get_constraints_file +from compwa_policy.utilities.pyproject import Pyproject, get_constraints_file from compwa_policy.utilities.yaml import create_prettier_round_trip_yaml @@ -91,7 +91,7 @@ def _update_precommit_ci_commit_msg() -> None: def __has_constraint_files() -> bool: - python_versions = PyprojectTOML.load().get_supported_python_versions() + python_versions = Pyproject.load().get_supported_python_versions() constraint_files = [get_constraints_file(v) for v in python_versions] constraint_paths = [Path(path) for path in constraint_files if path is not None] return any(path.exists() for path in constraint_paths) diff --git a/src/compwa_policy/check_dev_files/pyright.py b/src/compwa_policy/check_dev_files/pyright.py index 9b5ad5e0..2db35062 100644 --- a/src/compwa_policy/check_dev_files/pyright.py +++ b/src/compwa_policy/check_dev_files/pyright.py @@ -6,17 +6,17 @@ import os from compwa_policy.utilities import CONFIG_PATH -from compwa_policy.utilities.pyproject import PyprojectTOML, complies_with_subset +from compwa_policy.utilities.pyproject import Pyproject, complies_with_subset from compwa_policy.utilities.toml import to_toml_array def main() -> None: - with PyprojectTOML.load() as pyproject: + with Pyproject.load() as pyproject: _merge_config_into_pyproject(pyproject) _update_settings(pyproject) -def _merge_config_into_pyproject(pyproject: PyprojectTOML) -> None: +def _merge_config_into_pyproject(pyproject: Pyproject) -> None: config_path = "pyrightconfig.json" # cspell:ignore pyrightconfig if not os.path.exists(config_path): return @@ -32,7 +32,7 @@ def _merge_config_into_pyproject(pyproject: PyprojectTOML) -> None: pyproject.modifications.append(msg) -def _update_settings(pyproject: PyprojectTOML) -> None: +def _update_settings(pyproject: Pyproject) -> None: table_key = "tool.pyright" if not pyproject.has_table(table_key): return diff --git a/src/compwa_policy/check_dev_files/pytest.py b/src/compwa_policy/check_dev_files/pytest.py index fbbf4c34..579a7af5 100644 --- a/src/compwa_policy/check_dev_files/pytest.py +++ b/src/compwa_policy/check_dev_files/pytest.py @@ -11,7 +11,7 @@ from compwa_policy.utilities import CONFIG_PATH from compwa_policy.utilities.cfg import open_config from compwa_policy.utilities.executor import Executor -from compwa_policy.utilities.pyproject import PyprojectTOML +from compwa_policy.utilities.pyproject import Pyproject from compwa_policy.utilities.toml import to_toml_array if TYPE_CHECKING: @@ -19,7 +19,7 @@ def main() -> None: - pyproject = PyprojectTOML.load() + pyproject = Pyproject.load() with Executor() as do: do(_merge_coverage_into_pyproject, pyproject) do(_merge_pytest_into_pyproject, pyproject) @@ -27,7 +27,7 @@ def main() -> None: do(pyproject.finalize) -def _merge_coverage_into_pyproject(pyproject: PyprojectTOML) -> None: +def _merge_coverage_into_pyproject(pyproject: Pyproject) -> None: if not CONFIG_PATH.pytest_ini.exists(): return pytest_ini = open_config(CONFIG_PATH.pytest_ini) @@ -46,7 +46,7 @@ def _merge_coverage_into_pyproject(pyproject: PyprojectTOML) -> None: pyproject.modifications.append(msg) -def _merge_pytest_into_pyproject(pyproject: PyprojectTOML) -> None: +def _merge_pytest_into_pyproject(pyproject: Pyproject) -> None: if not CONFIG_PATH.pytest_ini.exists(): return with open(CONFIG_PATH.pytest_ini) as stream: @@ -61,7 +61,7 @@ def _merge_pytest_into_pyproject(pyproject: PyprojectTOML) -> None: raise PrecommitError(msg) -def _update_settings(pyproject: PyprojectTOML) -> None: +def _update_settings(pyproject: Pyproject) -> None: if not pyproject.has_table("tool.pytest.ini_options"): return config = pyproject.get_table("tool.pytest.ini_options") diff --git a/src/compwa_policy/check_dev_files/pyupgrade.py b/src/compwa_policy/check_dev_files/pyupgrade.py index b46a2b30..5d75015e 100644 --- a/src/compwa_policy/check_dev_files/pyupgrade.py +++ b/src/compwa_policy/check_dev_files/pyupgrade.py @@ -11,7 +11,7 @@ update_precommit_hook, update_single_hook_precommit_repo, ) -from compwa_policy.utilities.pyproject import PyprojectTOML +from compwa_policy.utilities.pyproject import Pyproject def main(no_ruff: bool) -> None: @@ -53,7 +53,7 @@ def __get_pyupgrade_version_argument() -> CommentedSeq: >>> __get_pyupgrade_version_argument() ['--py37-plus'] """ - supported_python_versions = PyprojectTOML.load().get_supported_python_versions() + supported_python_versions = Pyproject.load().get_supported_python_versions() lowest_version = supported_python_versions[0] version_repr = lowest_version.replace(".", "") yaml = YAML(typ="rt") diff --git a/src/compwa_policy/check_dev_files/ruff.py b/src/compwa_policy/check_dev_files/ruff.py index e130df1f..f15ab0e4 100644 --- a/src/compwa_policy/check_dev_files/ruff.py +++ b/src/compwa_policy/check_dev_files/ruff.py @@ -17,7 +17,7 @@ update_single_hook_precommit_repo, ) from compwa_policy.utilities.pyproject import ( - PyprojectTOML, + Pyproject, complies_with_subset, get_build_system, ) @@ -26,7 +26,7 @@ def main(has_notebooks: bool) -> None: - pyproject = PyprojectTOML.load() + pyproject = Pyproject.load() with Executor() as do: do( add_badge, @@ -46,7 +46,7 @@ def main(has_notebooks: bool) -> None: do(pyproject.finalize) -def _remove_black(pyproject: PyprojectTOML) -> None: +def _remove_black(pyproject: Pyproject) -> None: with Executor() as do: do( vscode.remove_extension_recommendation, @@ -66,7 +66,7 @@ def _remove_black(pyproject: PyprojectTOML) -> None: do(vscode.remove_settings, ["black-formatter.importStrategy"]) -def _remove_flake8(pyproject: PyprojectTOML) -> None: +def _remove_flake8(pyproject: Pyproject) -> None: with Executor() as do: do(remove_configs, [".flake8"]) do(__remove_nbqa_option, pyproject, "flake8") @@ -79,7 +79,7 @@ def _remove_flake8(pyproject: PyprojectTOML) -> None: do(vscode.remove_settings, ["flake8.importStrategy"]) -def _remove_isort(pyproject: PyprojectTOML) -> None: +def _remove_isort(pyproject: Pyproject) -> None: with Executor() as do: do(__remove_nbqa_option, pyproject, "black") do(__remove_nbqa_option, pyproject, "isort") @@ -91,7 +91,7 @@ def _remove_isort(pyproject: PyprojectTOML) -> None: do(remove_badge, r".*https://img\.shields\.io/badge/%20imports\-isort") -def __remove_nbqa_option(pyproject: PyprojectTOML, option: str) -> None: +def __remove_nbqa_option(pyproject: Pyproject, option: str) -> None: # cspell:ignore addopts table_key = "tool.nbqa.addopts" if not pyproject.has_table(table_key): @@ -104,7 +104,7 @@ def __remove_nbqa_option(pyproject: PyprojectTOML, option: str) -> None: pyproject.modifications.append(msg) -def __remove_tool_table(pyproject: PyprojectTOML, tool_table: str) -> None: +def __remove_tool_table(pyproject: Pyproject, tool_table: str) -> None: table_key = f"tool.{tool_table}" if not pyproject.has_table(table_key): return @@ -113,7 +113,7 @@ def __remove_tool_table(pyproject: PyprojectTOML, tool_table: str) -> None: pyproject.modifications.append(msg) -def _remove_pydocstyle(pyproject: PyprojectTOML) -> None: +def _remove_pydocstyle(pyproject: Pyproject) -> None: with Executor() as do: do( remove_configs, @@ -127,7 +127,7 @@ def _remove_pydocstyle(pyproject: PyprojectTOML) -> None: do(remove_precommit_hook, "pydocstyle") -def _remove_pylint(pyproject: PyprojectTOML) -> None: +def _remove_pylint(pyproject: Pyproject) -> None: with Executor() as do: do(remove_configs, [".pylintrc"]) # cspell:ignore pylintrc do(pyproject.remove_dependency, "pylint") @@ -137,7 +137,7 @@ def _remove_pylint(pyproject: PyprojectTOML) -> None: do(vscode.remove_settings, ["pylint.importStrategy"]) -def _move_ruff_lint_config(pyproject: PyprojectTOML) -> None: +def _move_ruff_lint_config(pyproject: Pyproject) -> None: """Migrate linting configuration to :code:`tool.ruff.lint`. See `this blog `_ for details. @@ -170,7 +170,7 @@ def _move_ruff_lint_config(pyproject: PyprojectTOML) -> None: ) -def _update_ruff_config(pyproject: PyprojectTOML, has_notebooks: bool) -> None: +def _update_ruff_config(pyproject: Pyproject, has_notebooks: bool) -> None: with Executor() as do: do(__update_global_settings, pyproject, has_notebooks) do(__update_ruff_format_settings, pyproject) @@ -181,7 +181,7 @@ def _update_ruff_config(pyproject: PyprojectTOML, has_notebooks: bool) -> None: do(__remove_nbqa, pyproject) -def __update_global_settings(pyproject: PyprojectTOML, has_notebooks: bool) -> None: +def __update_global_settings(pyproject: Pyproject, has_notebooks: bool) -> None: settings = pyproject.get_table("tool.ruff", create=True) minimal_settings = { "preview": True, @@ -206,10 +206,10 @@ def __update_global_settings(pyproject: PyprojectTOML, has_notebooks: bool) -> N pyproject.modifications.append(msg) -def ___get_target_version(pyproject: PyprojectTOML) -> str: +def ___get_target_version(pyproject: Pyproject) -> str: """Get minimal :code:`target-version` for Ruff. - >>> pyproject = PyprojectTOML.load() + >>> pyproject = Pyproject.load() >>> ___get_target_version(pyproject) 'py37' """ @@ -241,7 +241,7 @@ def ___get_src_directories() -> list[str]: return to_toml_array(sorted(directories)) -def __update_ruff_format_settings(pyproject: PyprojectTOML) -> None: +def __update_ruff_format_settings(pyproject: Pyproject) -> None: settings = pyproject.get_table("tool.ruff.format", create=True) minimal_settings = { "docstring-code-format": True, @@ -253,7 +253,7 @@ def __update_ruff_format_settings(pyproject: PyprojectTOML) -> None: pyproject.modifications.append(msg) -def __update_ruff_lint_settings(pyproject: PyprojectTOML) -> None: +def __update_ruff_lint_settings(pyproject: Pyproject) -> None: settings = pyproject.get_table("tool.ruff.lint", create=True) ignored_rules = [ "D101", # class docstring @@ -284,7 +284,7 @@ def __update_ruff_lint_settings(pyproject: PyprojectTOML) -> None: pyproject.modifications.append(msg) -def ___get_selected_ruff_rules(pyproject: PyprojectTOML) -> Array: +def ___get_selected_ruff_rules(pyproject: Pyproject) -> Array: rules = { "A", "B", @@ -329,7 +329,7 @@ def ___get_task_tags(ruff_settings: Table) -> Array: return to_toml_array(sorted(existing | expected)) -def __update_per_file_ignores(pyproject: PyprojectTOML, has_notebooks: bool) -> None: +def __update_per_file_ignores(pyproject: Pyproject, has_notebooks: bool) -> None: settings = pyproject.get_table("tool.ruff.lint.per-file-ignores", create=True) minimal_settings = {} if has_notebooks: @@ -414,7 +414,7 @@ def ___merge_rules(*rule_sets: Iterable[str], enforce_multiline: bool = False) - return to_toml_array(sorted(filtered), enforce_multiline) -def ___get_existing_nbqa_ignores(pyproject: PyprojectTOML) -> set[str]: +def ___get_existing_nbqa_ignores(pyproject: Pyproject) -> set[str]: nbqa_table = pyproject.get_table("tool.nbqa.addopts", create=True) if not nbqa_table: return set() @@ -441,7 +441,7 @@ def ___ban( return to_toml_array(sorted(filtered), enforce_multiline) -def __update_isort_settings(pyproject: PyprojectTOML) -> None: +def __update_isort_settings(pyproject: Pyproject) -> None: settings = pyproject.get_table("tool.ruff.lint.isort", create=True) minimal_settings = {"split-on-trailing-comma": False} if not complies_with_subset(settings, minimal_settings): @@ -450,7 +450,7 @@ def __update_isort_settings(pyproject: PyprojectTOML) -> None: pyproject.modifications.append(msg) -def __update_pydocstyle_settings(pyproject: PyprojectTOML) -> None: +def __update_pydocstyle_settings(pyproject: Pyproject) -> None: settings = pyproject.get_table("tool.ruff.lint.pydocstyle", create=True) minimal_settings = { "convention": "google", @@ -461,13 +461,13 @@ def __update_pydocstyle_settings(pyproject: PyprojectTOML) -> None: pyproject.modifications.append(msg) -def __remove_nbqa(pyproject: PyprojectTOML) -> None: +def __remove_nbqa(pyproject: Pyproject) -> None: with Executor() as do: do(___remove_nbqa_settings, pyproject) do(remove_precommit_hook, "nbqa-ruff") -def ___remove_nbqa_settings(pyproject: PyprojectTOML) -> None: +def ___remove_nbqa_settings(pyproject: Pyproject) -> None: nbqa_addopts = pyproject.get_table("tool.nbqa.addopts", create=True) if "ruff" in nbqa_addopts: del nbqa_addopts["ruff"] @@ -497,7 +497,7 @@ def _update_precommit_hook(has_notebooks: bool) -> None: update_single_hook_precommit_repo(expected_repo) -def _update_lint_dependencies(pyproject: PyprojectTOML) -> None: +def _update_lint_dependencies(pyproject: Pyproject) -> None: if get_build_system() is None: return python_versions = pyproject.get_supported_python_versions() diff --git a/src/compwa_policy/check_dev_files/toml.py b/src/compwa_policy/check_dev_files/toml.py index 2366d4fb..11c94282 100644 --- a/src/compwa_policy/check_dev_files/toml.py +++ b/src/compwa_policy/check_dev_files/toml.py @@ -17,7 +17,7 @@ Repo, update_single_hook_precommit_repo, ) -from compwa_policy.utilities.pyproject import PyprojectTOML +from compwa_policy.utilities.pyproject import Pyproject from compwa_policy.utilities.toml import to_toml_array __INCORRECT_TAPLO_CONFIG_PATHS = [ @@ -43,7 +43,7 @@ def main() -> None: def _update_tomlsort_config() -> None: - pyproject = PyprojectTOML.load() + pyproject = Pyproject.load() sort_first = [ "build-system", "project", diff --git a/src/compwa_policy/set_nb_cells.py b/src/compwa_policy/set_nb_cells.py index d6536555..a5119af4 100644 --- a/src/compwa_policy/set_nb_cells.py +++ b/src/compwa_policy/set_nb_cells.py @@ -29,7 +29,7 @@ import nbformat from compwa_policy.utilities.notebook import load_notebook -from compwa_policy.utilities.pyproject import PyprojectTOML +from compwa_policy.utilities.pyproject import Pyproject __CONFIG_CELL_CONTENT = """ import os @@ -119,7 +119,7 @@ def main(argv: Sequence[str] | None = None) -> int: @lru_cache(maxsize=1) def __get_install_cell() -> str: - package_name = PyprojectTOML.load().get_package_name(raise_on_missing=True) + package_name = Pyproject.load().get_package_name(raise_on_missing=True) msg = f""" # WARNING: advised to install a specific version, e.g. {package_name}==0.1.2 %pip install -q {package_name} diff --git a/src/compwa_policy/utilities/pyproject/__init__.py b/src/compwa_policy/utilities/pyproject/__init__.py index 98bf7f0b..7978a422 100644 --- a/src/compwa_policy/utilities/pyproject/__init__.py +++ b/src/compwa_policy/utilities/pyproject/__init__.py @@ -41,7 +41,7 @@ @frozen -class PyprojectTOML(AbstractContextManager): +class Pyproject(AbstractContextManager): """Stateful representation of a :code:`pyproject.toml` file. Use this class to apply multiple modifications to a :code:`pyproject.toml` file in @@ -53,7 +53,7 @@ class PyprojectTOML(AbstractContextManager): source: IO | Path | None = field(default=None) modifications: list[str] = field(factory=list, init=False) - def __enter__(self) -> PyprojectTOML: + def __enter__(self) -> Pyproject: return self def __exit__( @@ -65,7 +65,7 @@ def __exit__( self.finalize() @classmethod - def load(cls, source: IO | Path | str = CONFIG_PATH.pyproject) -> PyprojectTOML: + def load(cls, source: IO | Path | str = CONFIG_PATH.pyproject) -> Pyproject: """Load a :code:`pyproject.toml` file from a file, I/O stream, or `str`.""" if isinstance(source, io.IOBase): current_position = source.tell() @@ -141,7 +141,7 @@ def get_package_name(self, *, raise_on_missing: bool = False): # type:ignore[no def get_repo_url(self) -> str: """Extract the source URL from the project table in pyproject.toml. - >>> PyprojectTOML.load().get_repo_url() + >>> Pyproject.load().get_repo_url() 'https://github.com/ComPWA/policy' """ return get_source_url(self.document) @@ -149,7 +149,7 @@ def get_repo_url(self) -> str: def get_supported_python_versions(self) -> list[PythonVersion]: """Extract sorted, supported Python versions from package classifiers. - >>> PyprojectTOML.load().get_supported_python_versions() + >>> Pyproject.load().get_supported_python_versions() ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12'] """ return get_supported_python_versions(self.document) @@ -180,7 +180,7 @@ def get_build_system() -> Literal["pyproject", "setup.cfg"] | None: return "setup.cfg" if not CONFIG_PATH.pyproject.exists(): return None - pyproject = PyprojectTOML.load() + pyproject = Pyproject.load() if pyproject.get_package_name() is None: return None return "pyproject" diff --git a/src/compwa_policy/utilities/pyproject/getters.py b/src/compwa_policy/utilities/pyproject/getters.py index 10a9bcf3..4232e8ba 100644 --- a/src/compwa_policy/utilities/pyproject/getters.py +++ b/src/compwa_policy/utilities/pyproject/getters.py @@ -1,7 +1,7 @@ -"""Getter implementations for :class:`.PyprojectTOML`. +"""Getter implementations for :class:`.Pyproject`. As opposed to :mod:`.setters`, :mod:`.getters` leave the state of the -`~.PyprojectTOML.document` unaffected. +`~.Pyproject.document` unaffected. """ from __future__ import annotations diff --git a/tests/utilities/test_pyproject.py b/tests/utilities/test_pyproject.py index f27d8766..89b2b9e0 100644 --- a/tests/utilities/test_pyproject.py +++ b/tests/utilities/test_pyproject.py @@ -6,7 +6,7 @@ import pytest from tomlkit.items import Table -from compwa_policy.utilities.pyproject import PyprojectTOML +from compwa_policy.utilities.pyproject import Pyproject from compwa_policy.utilities.toml import to_toml_array POLICY_REPO_DIR = Path(__file__).absolute().parent.parent.parent @@ -16,14 +16,14 @@ class TestPyprojectToml: @pytest.mark.parametrize("path", [None, POLICY_REPO_DIR / "pyproject.toml"]) def test_load_from_path(self, path: Path | None): if path is None: - pyproject = PyprojectTOML.load() + pyproject = Pyproject.load() else: - pyproject = PyprojectTOML.load(path) + pyproject = Pyproject.load(path) assert "build-system" in pyproject.document assert "tool" in pyproject.document def test_load_from_str(self): - pyproject = PyprojectTOML.load(""" + pyproject = Pyproject.load(""" [build-system] build-backend = "setuptools.build_meta" requires = [ @@ -47,7 +47,7 @@ def test_load_from_str(self): def test_load_type_error(self): with pytest.raises(TypeError, match="Source of type int is not supported"): - _ = PyprojectTOML.load(1) # type: ignore[arg-type] + _ = Pyproject.load(1) # type: ignore[arg-type] def test_edit_and_dump(): @@ -59,7 +59,7 @@ def test_edit_and_dump(): city = "Wonderland" street = "123 Main St" """) - pyproject = PyprojectTOML.load(src) + pyproject = Pyproject.load(src) address = pyproject.get_table("owner.address") address["city"] = "New York" From 14d5750aaca58a7c342cfaa9e398b06fde5205d6 Mon Sep 17 00:00:00 2001 From: Remco de Boer <29308176+redeboer@users.noreply.github.com> Date: Sun, 10 Mar 2024 18:20:01 +0100 Subject: [PATCH 4/4] ENH: modify `pyproject.toml` with context manager --- pyproject.toml | 2 +- src/compwa_policy/check_dev_files/black.py | 14 +- src/compwa_policy/check_dev_files/jupyter.py | 14 +- src/compwa_policy/check_dev_files/mypy.py | 11 +- src/compwa_policy/check_dev_files/pyright.py | 12 +- src/compwa_policy/check_dev_files/pytest.py | 19 +- src/compwa_policy/check_dev_files/ruff.py | 67 +++---- src/compwa_policy/check_dev_files/toml.py | 15 +- .../utilities/pyproject/__init__.py | 171 ++++++++++-------- .../utilities/pyproject/getters.py | 6 +- tests/utilities/test_pyproject.py | 25 ++- 11 files changed, 186 insertions(+), 170 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c8c5bb41..7256257e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ dependencies = [ "pre-commit", "ruamel.yaml", # better YAML dumping "tomlkit", - 'typing-extensions; python_version <"3.11.0"', + 'typing-extensions; python_version <"3.12.0"', # override ] description = "Pre-commit hooks that ensure that ComPWA repositories have a similar developer set-up" dynamic = ["version"] diff --git a/src/compwa_policy/check_dev_files/black.py b/src/compwa_policy/check_dev_files/black.py index c1f76808..d1e3e073 100644 --- a/src/compwa_policy/check_dev_files/black.py +++ b/src/compwa_policy/check_dev_files/black.py @@ -10,15 +10,14 @@ remove_precommit_hook, update_single_hook_precommit_repo, ) -from compwa_policy.utilities.pyproject import Pyproject, complies_with_subset +from compwa_policy.utilities.pyproject import ModifiablePyproject, complies_with_subset from compwa_policy.utilities.toml import to_toml_array def main(has_notebooks: bool) -> None: if not CONFIG_PATH.pyproject.exists(): return - pyproject = Pyproject.load() - with Executor() as do: + with Executor() as do, ModifiablePyproject.load() as pyproject: do(_remove_outdated_settings, pyproject) do(_update_black_settings, pyproject) do( @@ -47,10 +46,9 @@ def main(has_notebooks: bool) -> None: }, ) do(remove_precommit_hook, "nbqa-black") - do(pyproject.finalize) -def _remove_outdated_settings(pyproject: Pyproject) -> None: +def _remove_outdated_settings(pyproject: ModifiablePyproject) -> None: settings = pyproject.get_table("tool.black", create=True) forbidden_options = ("line-length",) removed_options = set() @@ -63,10 +61,10 @@ def _remove_outdated_settings(pyproject: Pyproject) -> None: f"Removed {', '.join(sorted(removed_options))} option from black" f" configuration in {CONFIG_PATH.pyproject}" ) - pyproject.modifications.append(msg) + pyproject.append_to_changelog(msg) -def _update_black_settings(pyproject: Pyproject) -> None: +def _update_black_settings(pyproject: ModifiablePyproject) -> None: settings = pyproject.get_table("tool.black", create=True) versions = pyproject.get_supported_python_versions() target_version = to_toml_array(sorted("py" + v.replace(".", "") for v in versions)) @@ -77,7 +75,7 @@ def _update_black_settings(pyproject: Pyproject) -> None: if not complies_with_subset(settings, minimal_settings): settings.update(minimal_settings) msg = f"Updated black configuration in {CONFIG_PATH.pyproject}" - pyproject.modifications.append(msg) + pyproject.append_to_changelog(msg) def _update_precommit_repo(has_notebooks: bool) -> None: diff --git a/src/compwa_policy/check_dev_files/jupyter.py b/src/compwa_policy/check_dev_files/jupyter.py index 4a573659..44863fbf 100644 --- a/src/compwa_policy/check_dev_files/jupyter.py +++ b/src/compwa_policy/check_dev_files/jupyter.py @@ -1,7 +1,6 @@ """Update the developer setup when using Jupyter notebooks.""" -from compwa_policy.utilities.executor import Executor -from compwa_policy.utilities.pyproject import Pyproject, get_build_system +from compwa_policy.utilities.pyproject import ModifiablePyproject, get_build_system def main() -> None: @@ -11,11 +10,10 @@ def main() -> None: def _update_dev_requirements() -> None: if get_build_system() is None: return - pyproject = Pyproject.load() - supported_python_versions = pyproject.get_supported_python_versions() - if "3.6" in supported_python_versions: - return - with Executor() as do: + with ModifiablePyproject.load() as pyproject: + supported_python_versions = pyproject.get_supported_python_versions() + if "3.6" in supported_python_versions: + return for package in [ "black", "isort", @@ -27,4 +25,4 @@ def _update_dev_requirements() -> None: "python-lsp-ruff", "python-lsp-server[rope]", ]: - do(pyproject.add_dependency, package, optional_key=["jupyter", "dev"]) + pyproject.add_dependency(package, optional_key=["jupyter", "dev"]) diff --git a/src/compwa_policy/check_dev_files/mypy.py b/src/compwa_policy/check_dev_files/mypy.py index 0c9fdadd..2f78819b 100644 --- a/src/compwa_policy/check_dev_files/mypy.py +++ b/src/compwa_policy/check_dev_files/mypy.py @@ -5,18 +5,15 @@ import tomlkit from ini2toml.api import Translator -from compwa_policy.errors import PrecommitError from compwa_policy.utilities import CONFIG_PATH, vscode from compwa_policy.utilities.executor import Executor -from compwa_policy.utilities.pyproject import Pyproject +from compwa_policy.utilities.pyproject import ModifiablePyproject, Pyproject def main() -> None: - pyproject = Pyproject.load() - with Executor() as do: + with Executor() as do, ModifiablePyproject.load() as pyproject: do(_merge_mypy_into_pyproject, pyproject) do(_update_vscode_settings, pyproject) - do(pyproject.finalize) def _update_vscode_settings(pyproject: Pyproject) -> None: @@ -40,7 +37,7 @@ def _update_vscode_settings(pyproject: Pyproject) -> None: do(vscode.update_settings, settings) -def _merge_mypy_into_pyproject(pyproject: Pyproject) -> None: +def _merge_mypy_into_pyproject(pyproject: ModifiablePyproject) -> None: config_path = ".mypy.ini" if not os.path.exists(config_path): return @@ -52,4 +49,4 @@ def _merge_mypy_into_pyproject(pyproject: Pyproject) -> None: tool_table.update(mypy_config) os.remove(config_path) msg = f"Moved mypy configuration to {CONFIG_PATH.pyproject}" - raise PrecommitError(msg) + pyproject.append_to_changelog(msg) diff --git a/src/compwa_policy/check_dev_files/pyright.py b/src/compwa_policy/check_dev_files/pyright.py index 2db35062..300d9b5d 100644 --- a/src/compwa_policy/check_dev_files/pyright.py +++ b/src/compwa_policy/check_dev_files/pyright.py @@ -6,17 +6,17 @@ import os from compwa_policy.utilities import CONFIG_PATH -from compwa_policy.utilities.pyproject import Pyproject, complies_with_subset +from compwa_policy.utilities.pyproject import ModifiablePyproject, complies_with_subset from compwa_policy.utilities.toml import to_toml_array def main() -> None: - with Pyproject.load() as pyproject: + with ModifiablePyproject.load() as pyproject: _merge_config_into_pyproject(pyproject) _update_settings(pyproject) -def _merge_config_into_pyproject(pyproject: Pyproject) -> None: +def _merge_config_into_pyproject(pyproject: ModifiablePyproject) -> None: config_path = "pyrightconfig.json" # cspell:ignore pyrightconfig if not os.path.exists(config_path): return @@ -29,10 +29,10 @@ def _merge_config_into_pyproject(pyproject: Pyproject) -> None: tool_table.update(existing_config) os.remove(config_path) msg = f"Moved pyright configuration to {CONFIG_PATH.pyproject}" - pyproject.modifications.append(msg) + pyproject.append_to_changelog(msg) -def _update_settings(pyproject: Pyproject) -> None: +def _update_settings(pyproject: ModifiablePyproject) -> None: table_key = "tool.pyright" if not pyproject.has_table(table_key): return @@ -43,4 +43,4 @@ def _update_settings(pyproject: Pyproject) -> None: if not complies_with_subset(pyright_settings, minimal_settings): pyright_settings.update(minimal_settings) msg = f"Updated pyright configuration in {CONFIG_PATH.pyproject}" - pyproject.modifications.append(msg) + pyproject.append_to_changelog(msg) diff --git a/src/compwa_policy/check_dev_files/pytest.py b/src/compwa_policy/check_dev_files/pytest.py index 579a7af5..1be8dee0 100644 --- a/src/compwa_policy/check_dev_files/pytest.py +++ b/src/compwa_policy/check_dev_files/pytest.py @@ -7,11 +7,10 @@ import tomlkit from ini2toml.api import Translator -from compwa_policy.errors import PrecommitError from compwa_policy.utilities import CONFIG_PATH from compwa_policy.utilities.cfg import open_config from compwa_policy.utilities.executor import Executor -from compwa_policy.utilities.pyproject import Pyproject +from compwa_policy.utilities.pyproject import ModifiablePyproject from compwa_policy.utilities.toml import to_toml_array if TYPE_CHECKING: @@ -19,15 +18,13 @@ def main() -> None: - pyproject = Pyproject.load() - with Executor() as do: + with Executor() as do, ModifiablePyproject.load() as pyproject: do(_merge_coverage_into_pyproject, pyproject) do(_merge_pytest_into_pyproject, pyproject) do(_update_settings, pyproject) - do(pyproject.finalize) -def _merge_coverage_into_pyproject(pyproject: Pyproject) -> None: +def _merge_coverage_into_pyproject(pyproject: ModifiablePyproject) -> None: if not CONFIG_PATH.pytest_ini.exists(): return pytest_ini = open_config(CONFIG_PATH.pytest_ini) @@ -43,10 +40,10 @@ def _merge_coverage_into_pyproject(pyproject: Pyproject) -> None: tool_table = pyproject.get_table("tool.coverage.run", create=True) tool_table.update(coverage_config) msg = f"Merged Coverage.py configuration into {CONFIG_PATH.pyproject}" - pyproject.modifications.append(msg) + pyproject.append_to_changelog(msg) -def _merge_pytest_into_pyproject(pyproject: Pyproject) -> None: +def _merge_pytest_into_pyproject(pyproject: ModifiablePyproject) -> None: if not CONFIG_PATH.pytest_ini.exists(): return with open(CONFIG_PATH.pytest_ini) as stream: @@ -58,10 +55,10 @@ def _merge_pytest_into_pyproject(pyproject: Pyproject) -> None: tool_table.update(config) CONFIG_PATH.pytest_ini.unlink() msg = f"Moved pytest configuration to {CONFIG_PATH.pyproject}" - raise PrecommitError(msg) + pyproject.append_to_changelog(msg) -def _update_settings(pyproject: Pyproject) -> None: +def _update_settings(pyproject: ModifiablePyproject) -> None: if not pyproject.has_table("tool.pytest.ini_options"): return config = pyproject.get_table("tool.pytest.ini_options") @@ -70,7 +67,7 @@ def _update_settings(pyproject: Pyproject) -> None: if isinstance(existing, str) or sorted(existing) != sorted(expected): config["addopts"] = expected msg = f"Updated tool.pytest.ini_options.addopts under {CONFIG_PATH.pyproject}" - pyproject.modifications.append(msg) + pyproject.append_to_changelog(msg) def __get_expected_addopts(existing: str | Array) -> Array: diff --git a/src/compwa_policy/check_dev_files/ruff.py b/src/compwa_policy/check_dev_files/ruff.py index f15ab0e4..bd80515a 100644 --- a/src/compwa_policy/check_dev_files/ruff.py +++ b/src/compwa_policy/check_dev_files/ruff.py @@ -17,6 +17,7 @@ update_single_hook_precommit_repo, ) from compwa_policy.utilities.pyproject import ( + ModifiablePyproject, Pyproject, complies_with_subset, get_build_system, @@ -26,8 +27,7 @@ def main(has_notebooks: bool) -> None: - pyproject = Pyproject.load() - with Executor() as do: + with Executor() as do, ModifiablePyproject.load() as pyproject: do( add_badge, "[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/charliermarsh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)", @@ -43,10 +43,9 @@ def main(has_notebooks: bool) -> None: do(_update_precommit_hook, has_notebooks) do(_update_lint_dependencies, pyproject) do(_update_vscode_settings) - do(pyproject.finalize) -def _remove_black(pyproject: Pyproject) -> None: +def _remove_black(pyproject: ModifiablePyproject) -> None: with Executor() as do: do( vscode.remove_extension_recommendation, @@ -66,7 +65,7 @@ def _remove_black(pyproject: Pyproject) -> None: do(vscode.remove_settings, ["black-formatter.importStrategy"]) -def _remove_flake8(pyproject: Pyproject) -> None: +def _remove_flake8(pyproject: ModifiablePyproject) -> None: with Executor() as do: do(remove_configs, [".flake8"]) do(__remove_nbqa_option, pyproject, "flake8") @@ -79,7 +78,7 @@ def _remove_flake8(pyproject: Pyproject) -> None: do(vscode.remove_settings, ["flake8.importStrategy"]) -def _remove_isort(pyproject: Pyproject) -> None: +def _remove_isort(pyproject: ModifiablePyproject) -> None: with Executor() as do: do(__remove_nbqa_option, pyproject, "black") do(__remove_nbqa_option, pyproject, "isort") @@ -91,7 +90,7 @@ def _remove_isort(pyproject: Pyproject) -> None: do(remove_badge, r".*https://img\.shields\.io/badge/%20imports\-isort") -def __remove_nbqa_option(pyproject: Pyproject, option: str) -> None: +def __remove_nbqa_option(pyproject: ModifiablePyproject, option: str) -> None: # cspell:ignore addopts table_key = "tool.nbqa.addopts" if not pyproject.has_table(table_key): @@ -101,19 +100,19 @@ def __remove_nbqa_option(pyproject: Pyproject, option: str) -> None: return nbqa_table.remove(option) msg = f"Removed {option!r} nbQA options from {CONFIG_PATH.pyproject}" - pyproject.modifications.append(msg) + pyproject.append_to_changelog(msg) -def __remove_tool_table(pyproject: Pyproject, tool_table: str) -> None: +def __remove_tool_table(pyproject: ModifiablePyproject, tool_table: str) -> None: table_key = f"tool.{tool_table}" if not pyproject.has_table(table_key): return - pyproject.document["tool"].remove(tool_table) # type: ignore[union-attr] + pyproject._document["tool"].remove(tool_table) # type: ignore[union-attr] msg = f"Removed [tool.{tool_table}] section from {CONFIG_PATH.pyproject}" - pyproject.modifications.append(msg) + pyproject.append_to_changelog(msg) -def _remove_pydocstyle(pyproject: Pyproject) -> None: +def _remove_pydocstyle(pyproject: ModifiablePyproject) -> None: with Executor() as do: do( remove_configs, @@ -127,7 +126,7 @@ def _remove_pydocstyle(pyproject: Pyproject) -> None: do(remove_precommit_hook, "pydocstyle") -def _remove_pylint(pyproject: Pyproject) -> None: +def _remove_pylint(pyproject: ModifiablePyproject) -> None: with Executor() as do: do(remove_configs, [".pylintrc"]) # cspell:ignore pylintrc do(pyproject.remove_dependency, "pylint") @@ -137,7 +136,7 @@ def _remove_pylint(pyproject: Pyproject) -> None: do(vscode.remove_settings, ["pylint.importStrategy"]) -def _move_ruff_lint_config(pyproject: Pyproject) -> None: +def _move_ruff_lint_config(pyproject: ModifiablePyproject) -> None: """Migrate linting configuration to :code:`tool.ruff.lint`. See `this blog `_ for details. @@ -165,12 +164,12 @@ def _move_ruff_lint_config(pyproject: Pyproject) -> None: for key in lint_settings: del global_settings[key] if lint_arrays or lint_tables: - pyproject.modifications.append( + pyproject.append_to_changelog( f"Moved linting configuration to [tool.ruff.lint] in {CONFIG_PATH.pyproject}" ) -def _update_ruff_config(pyproject: Pyproject, has_notebooks: bool) -> None: +def _update_ruff_config(pyproject: ModifiablePyproject, has_notebooks: bool) -> None: with Executor() as do: do(__update_global_settings, pyproject, has_notebooks) do(__update_ruff_format_settings, pyproject) @@ -181,7 +180,9 @@ def _update_ruff_config(pyproject: Pyproject, has_notebooks: bool) -> None: do(__remove_nbqa, pyproject) -def __update_global_settings(pyproject: Pyproject, has_notebooks: bool) -> None: +def __update_global_settings( + pyproject: ModifiablePyproject, has_notebooks: bool +) -> None: settings = pyproject.get_table("tool.ruff", create=True) minimal_settings = { "preview": True, @@ -203,7 +204,7 @@ def __update_global_settings(pyproject: Pyproject, has_notebooks: bool) -> None: if not complies_with_subset(settings, minimal_settings): settings.update(minimal_settings) msg = f"Updated Ruff configuration in {CONFIG_PATH.pyproject}" - pyproject.modifications.append(msg) + pyproject.append_to_changelog(msg) def ___get_target_version(pyproject: Pyproject) -> str: @@ -241,7 +242,7 @@ def ___get_src_directories() -> list[str]: return to_toml_array(sorted(directories)) -def __update_ruff_format_settings(pyproject: Pyproject) -> None: +def __update_ruff_format_settings(pyproject: ModifiablePyproject) -> None: settings = pyproject.get_table("tool.ruff.format", create=True) minimal_settings = { "docstring-code-format": True, @@ -250,10 +251,10 @@ def __update_ruff_format_settings(pyproject: Pyproject) -> None: if not complies_with_subset(settings, minimal_settings): settings.update(minimal_settings) msg = f"Updated Ruff formatter configuration in {CONFIG_PATH.pyproject}" - pyproject.modifications.append(msg) + pyproject.append_to_changelog(msg) -def __update_ruff_lint_settings(pyproject: Pyproject) -> None: +def __update_ruff_lint_settings(pyproject: ModifiablePyproject) -> None: settings = pyproject.get_table("tool.ruff.lint", create=True) ignored_rules = [ "D101", # class docstring @@ -281,7 +282,7 @@ def __update_ruff_lint_settings(pyproject: Pyproject) -> None: if not complies_with_subset(settings, minimal_settings): settings.update(minimal_settings) msg = f"Updated Ruff linting configuration in {CONFIG_PATH.pyproject}" - pyproject.modifications.append(msg) + pyproject.append_to_changelog(msg) def ___get_selected_ruff_rules(pyproject: Pyproject) -> Array: @@ -329,7 +330,9 @@ def ___get_task_tags(ruff_settings: Table) -> Array: return to_toml_array(sorted(existing | expected)) -def __update_per_file_ignores(pyproject: Pyproject, has_notebooks: bool) -> None: +def __update_per_file_ignores( + pyproject: ModifiablePyproject, has_notebooks: bool +) -> None: settings = pyproject.get_table("tool.ruff.lint.per-file-ignores", create=True) minimal_settings = {} if has_notebooks: @@ -396,7 +399,7 @@ def __update_per_file_ignores(pyproject: Pyproject, has_notebooks: bool) -> None if not complies_with_subset(settings, minimal_settings): settings.update(minimal_settings) msg = f"Updated Ruff configuration in {CONFIG_PATH.pyproject}" - pyproject.modifications.append(msg) + pyproject.append_to_changelog(msg) def ___merge_rules(*rule_sets: Iterable[str], enforce_multiline: bool = False) -> Array: @@ -441,16 +444,16 @@ def ___ban( return to_toml_array(sorted(filtered), enforce_multiline) -def __update_isort_settings(pyproject: Pyproject) -> None: +def __update_isort_settings(pyproject: ModifiablePyproject) -> None: settings = pyproject.get_table("tool.ruff.lint.isort", create=True) minimal_settings = {"split-on-trailing-comma": False} if not complies_with_subset(settings, minimal_settings): settings.update(minimal_settings) msg = f"Updated Ruff isort settings in {CONFIG_PATH.pyproject}" - pyproject.modifications.append(msg) + pyproject.append_to_changelog(msg) -def __update_pydocstyle_settings(pyproject: Pyproject) -> None: +def __update_pydocstyle_settings(pyproject: ModifiablePyproject) -> None: settings = pyproject.get_table("tool.ruff.lint.pydocstyle", create=True) minimal_settings = { "convention": "google", @@ -458,16 +461,16 @@ def __update_pydocstyle_settings(pyproject: Pyproject) -> None: if not complies_with_subset(settings, minimal_settings): settings.update(minimal_settings) msg = f"Updated Ruff configuration in {CONFIG_PATH.pyproject}" - pyproject.modifications.append(msg) + pyproject.append_to_changelog(msg) -def __remove_nbqa(pyproject: Pyproject) -> None: +def __remove_nbqa(pyproject: ModifiablePyproject) -> None: with Executor() as do: do(___remove_nbqa_settings, pyproject) do(remove_precommit_hook, "nbqa-ruff") -def ___remove_nbqa_settings(pyproject: Pyproject) -> None: +def ___remove_nbqa_settings(pyproject: ModifiablePyproject) -> None: nbqa_addopts = pyproject.get_table("tool.nbqa.addopts", create=True) if "ruff" in nbqa_addopts: del nbqa_addopts["ruff"] @@ -476,7 +479,7 @@ def ___remove_nbqa_settings(pyproject: Pyproject) -> None: del tool_table["nbqa"] if nbqa_addopts: msg = f"Removed Ruff configuration for nbQA from {CONFIG_PATH.pyproject}" - pyproject.modifications.append(msg) + pyproject.append_to_changelog(msg) def _update_precommit_hook(has_notebooks: bool) -> None: @@ -497,7 +500,7 @@ def _update_precommit_hook(has_notebooks: bool) -> None: update_single_hook_precommit_repo(expected_repo) -def _update_lint_dependencies(pyproject: Pyproject) -> None: +def _update_lint_dependencies(pyproject: ModifiablePyproject) -> None: if get_build_system() is None: return python_versions = pyproject.get_supported_python_versions() diff --git a/src/compwa_policy/check_dev_files/toml.py b/src/compwa_policy/check_dev_files/toml.py index 11c94282..caf8489b 100644 --- a/src/compwa_policy/check_dev_files/toml.py +++ b/src/compwa_policy/check_dev_files/toml.py @@ -17,7 +17,7 @@ Repo, update_single_hook_precommit_repo, ) -from compwa_policy.utilities.pyproject import Pyproject +from compwa_policy.utilities.pyproject import ModifiablePyproject from compwa_policy.utilities.toml import to_toml_array __INCORRECT_TAPLO_CONFIG_PATHS = [ @@ -43,7 +43,6 @@ def main() -> None: def _update_tomlsort_config() -> None: - pyproject = Pyproject.load() sort_first = [ "build-system", "project", @@ -59,12 +58,12 @@ def _update_tomlsort_config() -> None: spaces_indent_inline_array=4, trailing_comma_inline_array=True, ) - tool_table = pyproject.get_table("tool", create=True) - if tool_table.get("tomlsort") == expected_config: - return - tool_table["tomlsort"] = expected_config - pyproject.modifications.append("Updated toml-sort configuration") - pyproject.finalize() + with ModifiablePyproject.load() as pyproject: + tool_table = pyproject.get_table("tool", create=True) + if tool_table.get("tomlsort") == expected_config: + return + tool_table["tomlsort"] = expected_config + pyproject.append_to_changelog("Updated toml-sort configuration") def _update_tomlsort_hook() -> None: diff --git a/src/compwa_policy/utilities/pyproject/__init__.py b/src/compwa_policy/utilities/pyproject/__init__.py index 7978a422..a49ad1ce 100644 --- a/src/compwa_policy/utilities/pyproject/__init__.py +++ b/src/compwa_policy/utilities/pyproject/__init__.py @@ -7,7 +7,7 @@ from contextlib import AbstractContextManager from pathlib import Path from textwrap import indent -from typing import IO, TYPE_CHECKING, Iterable, Sequence, overload +from typing import IO, TYPE_CHECKING, Iterable, Sequence, TypeVar, overload import tomlkit from attrs import field, frozen @@ -30,42 +30,32 @@ ) if sys.version_info < (3, 8): - from typing_extensions import Literal + from typing_extensions import Literal, final else: - from typing import Literal + from typing import Literal, final +if sys.version_info < (3, 12): + from typing_extensions import override +else: + from typing import override if TYPE_CHECKING: from types import TracebackType from tomlkit.items import Table from tomlkit.toml_document import TOMLDocument +T = TypeVar("T", bound="Pyproject") -@frozen -class Pyproject(AbstractContextManager): - """Stateful representation of a :code:`pyproject.toml` file. - - Use this class to apply multiple modifications to a :code:`pyproject.toml` file in - separate sub-hooks. The :meth:`.finalize` method should be called after all the - modifications have been applied. - """ - - document: TOMLDocument - source: IO | Path | None = field(default=None) - modifications: list[str] = field(factory=list, init=False) - def __enter__(self) -> Pyproject: - return self +@frozen +class Pyproject: + """Read-only representation of a :code:`pyproject.toml` file.""" - def __exit__( - self, - exc_type: type[BaseException] | None, - exc_value: BaseException | None, - tb: TracebackType | None, - ) -> None: - self.finalize() + _document: TOMLDocument + _source: IO | Path | None = field(default=None) + @final @classmethod - def load(cls, source: IO | Path | str = CONFIG_PATH.pyproject) -> Pyproject: + def load(cls: type[T], source: IO | Path | str = CONFIG_PATH.pyproject) -> T: """Load a :code:`pyproject.toml` file from a file, I/O stream, or `str`.""" if isinstance(source, io.IOBase): current_position = source.tell() @@ -82,52 +72,20 @@ def load(cls, source: IO | Path | str = CONFIG_PATH.pyproject) -> Pyproject: msg = f"Source of type {type(source).__name__} is not supported" raise TypeError(msg) - def dump(self, target: IO | Path | str | None = None) -> None: - if target is None and self.source is None: - msg = "Target required when source is not a file or I/O stream" - raise ValueError(msg) - if isinstance(target, io.IOBase): - current_position = target.tell() - target.seek(0) - tomlkit.dump(self.document, target, sort_keys=True) - target.seek(current_position) - elif isinstance(target, (Path, str)): - src = self.dumps() - with open(target, "w") as stream: - stream.write(src) - else: - msg = f"Target of type {type(target).__name__} is not supported" - raise TypeError(msg) - + @final def dumps(self) -> str: - src = tomlkit.dumps(self.document, sort_keys=True) + src = tomlkit.dumps(self._document, sort_keys=True) return f"{src.strip()}\n" - def finalize(self) -> None: - """If `modifications` were made, :meth:`dump` and raise `.PrecommitError`.""" - if not self.modifications: - return - self.dump(self.source) - msg = "Following modifications were made" - if isinstance(self.source, (Path, str)): - msg = f" to {self.source}" - msg += ":\n\n" - modifications = indent("\n".join(self.modifications), prefix=" - ") - self.modifications.clear() - raise PrecommitError(modifications) - - def __del__(self) -> None: - if self.modifications: - msg = "Modifications were made, but finalize was not called" - raise RuntimeError(msg) - def get_table(self, dotted_header: str, create: bool = False) -> Table: if create: - create_sub_table(self.document, dotted_header) - return get_sub_table(self.document, dotted_header) + msg = "Cannot create sub-tables in a read-only pyproject.toml" + raise TypeError(msg) + return get_sub_table(self._document, dotted_header) + @final def has_table(self, dotted_header: str) -> bool: - return has_sub_table(self.document, dotted_header) + return has_sub_table(self._document, dotted_header) @overload def get_package_name(self) -> str | None: ... @@ -135,40 +93,111 @@ def get_package_name(self) -> str | None: ... def get_package_name(self, *, raise_on_missing: Literal[False]) -> str | None: ... @overload def get_package_name(self, *, raise_on_missing: Literal[True]) -> str: ... + @final def get_package_name(self, *, raise_on_missing: bool = False): # type:ignore[no-untyped-def] - return get_package_name(self.document, raise_on_missing) # type:ignore[call-overload,reportCallIssue] + return get_package_name(self._document, raise_on_missing) # type:ignore[call-overload,reportCallIssue] + @final def get_repo_url(self) -> str: """Extract the source URL from the project table in pyproject.toml. >>> Pyproject.load().get_repo_url() 'https://github.com/ComPWA/policy' """ - return get_source_url(self.document) + return get_source_url(self._document) + @final def get_supported_python_versions(self) -> list[PythonVersion]: """Extract sorted, supported Python versions from package classifiers. >>> Pyproject.load().get_supported_python_versions() ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12'] """ - return get_supported_python_versions(self.document) + return get_supported_python_versions(self._document) + + +@frozen +class ModifiablePyproject(Pyproject, AbstractContextManager): + """Stateful representation of a :code:`pyproject.toml` file. + + Use this class to apply multiple modifications to a :code:`pyproject.toml` file in + separate sub-hooks. The modifications are dumped once the context is exited. + """ + + _is_in_context = False + _changelog: list[str] = field(factory=list) + + def __enter__(self) -> ModifiablePyproject: + object.__setattr__(self, "_is_in_context", True) + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + tb: TracebackType | None, + ) -> None: + if not self._changelog: + return + if self._source is None: + self.dump(self._source) + msg = "Following modifications were made" + if isinstance(self._source, (Path, str)): + msg = f" to {self._source}" + msg += ":\n\n" + modifications = indent("\n".join(self._changelog), prefix=" - ") + raise PrecommitError(modifications) + + def dump(self, target: IO | Path | str | None = None) -> None: + if target is None and self._source is None: + msg = "Target required when source is not a file or I/O stream" + raise ValueError(msg) + if isinstance(target, io.IOBase): + current_position = target.tell() + target.seek(0) + tomlkit.dump(self._document, target, sort_keys=True) + target.seek(current_position) + elif isinstance(target, (Path, str)): + src = self.dumps() + with open(target, "w") as stream: + stream.write(src) + else: + msg = f"Target of type {type(target).__name__} is not supported" + raise TypeError(msg) + + @override + def get_table(self, dotted_header: str, create: bool = False) -> Table: + self.__assert_is_in_context() + if create: + create_sub_table(self._document, dotted_header) + return super().get_table(dotted_header) def add_dependency( self, package: str, optional_key: str | Sequence[str] | None = None ) -> None: - updated = add_dependency(self.document, package, optional_key) + self.__assert_is_in_context() + updated = add_dependency(self._document, package, optional_key) if updated: msg = f"Listed {package} as a dependency" - self.modifications.append(msg) + self._changelog.append(msg) def remove_dependency( self, package: str, ignored_sections: Iterable[str] | None = None ) -> None: - updated = remove_dependency(self.document, package, ignored_sections) + self.__assert_is_in_context() + updated = remove_dependency(self._document, package, ignored_sections) if updated: msg = f"Removed {package} from dependencies" - self.modifications.append(msg) + self._changelog.append(msg) + + def __assert_is_in_context(self) -> None: + if not self._is_in_context: + msg = "Modifications can only be made within a context" + raise RuntimeError(msg) + + def append_to_changelog(self, message: str) -> None: + self.__assert_is_in_context() + self._changelog.append(message) def complies_with_subset(settings: dict, minimal_settings: dict) -> bool: diff --git a/src/compwa_policy/utilities/pyproject/getters.py b/src/compwa_policy/utilities/pyproject/getters.py index 4232e8ba..ad4463c1 100644 --- a/src/compwa_policy/utilities/pyproject/getters.py +++ b/src/compwa_policy/utilities/pyproject/getters.py @@ -1,8 +1,4 @@ -"""Getter implementations for :class:`.Pyproject`. - -As opposed to :mod:`.setters`, :mod:`.getters` leave the state of the -`~.Pyproject.document` unaffected. -""" +"""Getter implementations for :class:`.PyprojectTOML`.""" from __future__ import annotations diff --git a/tests/utilities/test_pyproject.py b/tests/utilities/test_pyproject.py index 89b2b9e0..3e851b1e 100644 --- a/tests/utilities/test_pyproject.py +++ b/tests/utilities/test_pyproject.py @@ -6,7 +6,7 @@ import pytest from tomlkit.items import Table -from compwa_policy.utilities.pyproject import Pyproject +from compwa_policy.utilities.pyproject import ModifiablePyproject, Pyproject from compwa_policy.utilities.toml import to_toml_array POLICY_REPO_DIR = Path(__file__).absolute().parent.parent.parent @@ -19,8 +19,8 @@ def test_load_from_path(self, path: Path | None): pyproject = Pyproject.load() else: pyproject = Pyproject.load(path) - assert "build-system" in pyproject.document - assert "tool" in pyproject.document + assert "build-system" in pyproject._document + assert "tool" in pyproject._document def test_load_from_str(self): pyproject = Pyproject.load(""" @@ -39,8 +39,8 @@ def test_load_from_str(self): name = "my-package" requires-python = ">=3.7" """) - assert isinstance(pyproject.document["build-system"], Table) - assert pyproject.document["project"]["dependencies"] == [ # type: ignore[index] + assert isinstance(pyproject._document["build-system"], Table) + assert pyproject._document["project"]["dependencies"] == [ # type: ignore[index] "attrs", "sympy >=1.10", ] @@ -59,14 +59,13 @@ def test_edit_and_dump(): city = "Wonderland" street = "123 Main St" """) - pyproject = Pyproject.load(src) - - address = pyproject.get_table("owner.address") - address["city"] = "New York" - work = pyproject.get_table("owner.work", create=True) - work["type"] = "scientist" - tools = pyproject.get_table("tool", create=True) - tools["black"] = to_toml_array(["--line-length=79"], enforce_multiline=True) + with ModifiablePyproject.load(src) as pyproject: + address = pyproject.get_table("owner.address") + address["city"] = "New York" + work = pyproject.get_table("owner.work", create=True) + work["type"] = "scientist" + tools = pyproject.get_table("tool", create=True) + tools["black"] = to_toml_array(["--line-length=79"], enforce_multiline=True) new_content = pyproject.dumps() expected = dedent("""