diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2958c902..aa6cb7ad 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,7 +3,6 @@ ci: autoupdate_schedule: quarterly skip: - check-dev-files - - format-setup-cfg - mypy - pyright - repoma-self-check @@ -49,12 +48,6 @@ repos: - --repo-name=repo-maintenance - --repo-title=ComPWA Repository Maintenance - - id: format-setup-cfg - name: Format setup.cfg - entry: format-setup-cfg - language: python - files: ^setup.cfg$ - - id: repoma-self-check name: repoma-self-check entry: repoma-self-check @@ -125,3 +118,11 @@ repos: rev: v0.8.0 hooks: - id: taplo + + - repo: https://github.com/pappasam/toml-sort + rev: v0.23.1 + hooks: + - id: toml-sort + args: + - --in-place + exclude: (?x)^(labels.toml|labels-physics.toml)$ diff --git a/.taplo.toml b/.taplo.toml index f5caabee..424b7469 100644 --- a/.taplo.toml +++ b/.taplo.toml @@ -6,11 +6,14 @@ exclude = [ ] [formatting] +align_comments = false align_entries = false +allowed_blank_lines = 1 array_auto_collapse = false array_auto_expand = true array_trailing_comma = true column_width = 88 +compact_inline_tables = true indent_string = " " reorder_arrays = true reorder_keys = true diff --git a/environment.yml b/environment.yml index 45db2944..3061e189 100644 --- a/environment.yml +++ b/environment.yml @@ -1,4 +1,4 @@ -name: repoma +name: repo-maintenance channels: - defaults dependencies: diff --git a/pyproject.toml b/pyproject.toml index d10a2071..09a5aac4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,7 +67,6 @@ module = ["nbformat.*"] ignore_missing_imports = true module = ["pre_commit.commands.autoupdate.*"] - [tool.pyright] exclude = [ "**/.git", @@ -101,7 +100,6 @@ reportUnusedImport = true reportUnusedVariable = true typeCheckingMode = "strict" - [tool.pytest.ini_options] addopts = """ --color=yes @@ -153,20 +151,21 @@ extend-select = [ "YTT", ] ignore = [ - "D101", # class docstring - "D102", # method docstring - "D103", # function docstring - "D105", # magic method docstring - "D107", # init docstring - "D203", # conflicts with D211 - "D213", # multi-line docstring should start at the second line - "D407", # missing dashed underline after section - "D416", # section name does not have to end with a colon - "E501", # handled by black formatting + "C408", # Unnecessary dict call - rewrite as a literal + "D101", # class docstring + "D102", # method docstring + "D103", # function docstring + "D105", # magic method docstring + "D107", # init docstring + "D203", # conflicts with D211 + "D213", # multi-line docstring should start at the second line + "D407", # missing dashed underline after section + "D416", # section name does not have to end with a colon + "E501", # handled by black formatting "PLR0913", # sympy functions "PLW2901", # often used for xreplace - "S301", # allow pickle - "SIM108", # if-else-block-instead-of-if-exp + "S301", # allow pickle + "SIM108", # if-else-block-instead-of-if-exp ] show-fixes = true src = [ @@ -192,3 +191,17 @@ known-first-party = ["repoma"] [tool.ruff.pydocstyle] convention = "google" + +[tool.tomlsort] +all = false +ignore_case = true +in_place = true +sort_first = [ + "build-system", + "project", + "tool.setuptools", + "tool.setuptools_scm", +] +sort_table_keys = true +spaces_indent_inline_array = 4 +trailing_comma_inline_array = true diff --git a/setup.cfg b/setup.cfg index 39be1cf1..3ec67a1c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -32,6 +32,7 @@ setup_requires = setuptools_scm install_requires = attrs >=20.1.0 # https://www.attrs.org/en/stable/changelog.html#id82 + dataclasses; python_version<="3.7.0" html2text ini2toml nbformat diff --git a/src/repoma/check_dev_files/black.py b/src/repoma/check_dev_files/black.py index 8cefe245..02c57683 100644 --- a/src/repoma/check_dev_files/black.py +++ b/src/repoma/check_dev_files/black.py @@ -9,6 +9,7 @@ remove_precommit_hook, update_single_hook_precommit_repo, ) +from repoma.utilities.project_info import get_supported_python_versions from repoma.utilities.pyproject import ( complies_with_subset, get_sub_table, @@ -16,7 +17,6 @@ to_toml_array, write_pyproject, ) -from repoma.utilities.setup_cfg import get_supported_python_versions from repoma.utilities.vscode import ( add_extension_recommendation, set_setting, diff --git a/src/repoma/check_dev_files/deprecated.py b/src/repoma/check_dev_files/deprecated.py index de89c2a3..ce809bff 100644 --- a/src/repoma/check_dev_files/deprecated.py +++ b/src/repoma/check_dev_files/deprecated.py @@ -7,9 +7,9 @@ from repoma.utilities import CONFIG_PATH from repoma.utilities.executor import Executor from repoma.utilities.precommit import remove_precommit_hook +from repoma.utilities.project_info import open_setup_cfg from repoma.utilities.pyproject import load_pyproject, write_pyproject from repoma.utilities.readme import remove_badge -from repoma.utilities.setup_cfg import open_setup_cfg from repoma.utilities.vscode import ( remove_extension_recommendation, remove_settings, @@ -35,8 +35,8 @@ def _remove_flake8() -> None: executor = Executor() executor(__remove_configs, [".flake8"]) executor(__remove_nbqa_option, "flake8") - executor(__uninstall, "flake8", check_options=["lint", "sty"]) - executor(__uninstall, "pep8-naming", check_options=["lint", "sty"]) + executor(__uninstall, "flake8") + executor(__uninstall, "pep8-naming") executor(remove_extension_recommendation, "ms-python.flake8", unwanted=True) executor(remove_precommit_hook, "autoflake") # cspell:ignore autoflake executor(remove_precommit_hook, "flake8") @@ -115,7 +115,7 @@ def _remove_pydocstyle() -> None: "tests/.pydocstyle", ], ) - executor(__uninstall, "pydocstyle", check_options=["lint", "sty"]) + executor(__uninstall, "pydocstyle") executor(remove_precommit_hook, "pydocstyle") executor.finalize() @@ -123,7 +123,7 @@ def _remove_pydocstyle() -> None: def _remove_pylint() -> None: executor = Executor() executor(__remove_configs, [".pylintrc"]) # cspell:ignore pylintrc - executor(__uninstall, "pylint", check_options=["lint", "sty"]) + executor(__uninstall, "pylint") executor(remove_extension_recommendation, "ms-python.pylint", unwanted=True) executor(remove_precommit_hook, "pylint") executor(remove_precommit_hook, "nbqa-pylint") @@ -149,14 +149,19 @@ def __remove_file(path: str) -> None: raise PrecommitError(msg) -def __uninstall(package: str, check_options: List[str]) -> None: +def __uninstall(package: str) -> None: + __uninstall_from_setup_cfg(package) + __uninstall_from_pyproject_toml(package) + + +def __uninstall_from_setup_cfg(package: str) -> None: if not os.path.exists(CONFIG_PATH.setup_cfg): return cfg = open_setup_cfg() section = "options.extras_require" if not cfg.has_section(section): return - for option in check_options: + for option in cfg[section]: if not cfg.has_option(section, option): continue if package not in cfg.get(section, option): @@ -165,6 +170,30 @@ def __uninstall(package: str, check_options: List[str]) -> None: raise PrecommitError(msg) +def __uninstall_from_pyproject_toml(package: str) -> None: + if not os.path.exists(CONFIG_PATH.pyproject): + return + pyproject = load_pyproject() + project = pyproject.get("project") + if project is None: + return + updated = False + dependencies = project.get("dependencies") + if dependencies is not None and package in dependencies: + dependencies.remove(package) + updated = True + optional_dependencies = project.get("optional-dependencies") + if optional_dependencies is not None: + for values in optional_dependencies.values(): + if package in values: + values.remove(package) + updated = True + if updated: + write_pyproject(pyproject) + msg = f"Removed {package} from {CONFIG_PATH.pyproject}" + raise PrecommitError(msg) + + def __remove_from_gitignore(pattern: str) -> None: gitignore_path = ".gitignore" if not os.path.exists(gitignore_path): diff --git a/src/repoma/check_dev_files/github_workflows.py b/src/repoma/check_dev_files/github_workflows.py index 4462d22f..85486c8b 100644 --- a/src/repoma/check_dev_files/github_workflows.py +++ b/src/repoma/check_dev_files/github_workflows.py @@ -12,7 +12,7 @@ from repoma.utilities import CONFIG_PATH, REPOMA_DIR, write from repoma.utilities.executor import Executor from repoma.utilities.precommit import PrecommitConfig -from repoma.utilities.setup_cfg import get_pypi_name +from repoma.utilities.project_info import get_pypi_name from repoma.utilities.vscode import ( add_extension_recommendation, remove_extension_recommendation, diff --git a/src/repoma/check_dev_files/gitpod.py b/src/repoma/check_dev_files/gitpod.py index 3253ca7e..a50006c5 100644 --- a/src/repoma/check_dev_files/gitpod.py +++ b/src/repoma/check_dev_files/gitpod.py @@ -7,8 +7,8 @@ from repoma.errors import PrecommitError from repoma.utilities import CONFIG_PATH, REPOMA_DIR +from repoma.utilities.project_info import get_repo_url from repoma.utilities.readme import add_badge -from repoma.utilities.setup_cfg import get_repo_url from repoma.utilities.yaml import write_yaml __CONSTRAINTS_FILE = ".constraints/py3.8.txt" diff --git a/src/repoma/check_dev_files/pyright.py b/src/repoma/check_dev_files/pyright.py index dc9d6045..cbe0f077 100644 --- a/src/repoma/check_dev_files/pyright.py +++ b/src/repoma/check_dev_files/pyright.py @@ -48,5 +48,5 @@ def _update_settings() -> None: if not complies_with_subset(settings, minimal_settings): settings.update(minimal_settings) write_pyproject(pyproject) - msg = f"Updated black configuration in {CONFIG_PATH.pyproject}" + msg = f"Updated pyright configuration in {CONFIG_PATH.pyproject}" raise PrecommitError(msg) diff --git a/src/repoma/check_dev_files/pyupgrade.py b/src/repoma/check_dev_files/pyupgrade.py index c87ced67..0499d269 100644 --- a/src/repoma/check_dev_files/pyupgrade.py +++ b/src/repoma/check_dev_files/pyupgrade.py @@ -8,7 +8,7 @@ update_precommit_hook, update_single_hook_precommit_repo, ) -from repoma.utilities.setup_cfg import get_supported_python_versions +from repoma.utilities.project_info import get_supported_python_versions def main() -> None: diff --git a/src/repoma/check_dev_files/ruff.py b/src/repoma/check_dev_files/ruff.py index 5198e864..5cf5648e 100644 --- a/src/repoma/check_dev_files/ruff.py +++ b/src/repoma/check_dev_files/ruff.py @@ -1,12 +1,17 @@ """Check `Ruff `_ configuration.""" import os +from copy import deepcopy from textwrap import dedent from typing import List, Set from ruamel.yaml.comments import CommentedMap from tomlkit.items import Array, Table +from repoma.check_dev_files.setup_cfg import ( + has_pyproject_build_system, + has_setup_cfg_build_system, +) from repoma.errors import PrecommitError from repoma.utilities import CONFIG_PATH, natural_sorting from repoma.utilities.executor import Executor @@ -14,6 +19,11 @@ update_precommit_hook, update_single_hook_precommit_repo, ) +from repoma.utilities.project_info import ( + get_project_info, + get_supported_python_versions, + open_setup_cfg, +) from repoma.utilities.pyproject import ( complies_with_subset, get_sub_table, @@ -23,7 +33,6 @@ write_pyproject, ) from repoma.utilities.readme import add_badge -from repoma.utilities.setup_cfg import get_supported_python_versions, open_setup_cfg from repoma.utilities.vscode import add_extension_recommendation, set_setting @@ -40,11 +49,14 @@ def main() -> None: executor(_update_ruff_pydocstyle_settings) executor(_update_precommit_hook) executor(_update_precommit_nbqa_hook) + executor(_update_pyproject) executor(_update_vscode_settings) executor.finalize() def _check_setup_cfg() -> None: + if not has_setup_cfg_build_system(): + return cfg = open_setup_cfg() extras_require = "options.extras_require" if not cfg.has_section(extras_require): @@ -77,6 +89,53 @@ def _check_setup_cfg() -> None: raise PrecommitError(msg) +def _update_pyproject() -> None: + if not has_pyproject_build_system(): + return + pyproject = load_pyproject() + project_info = get_project_info(pyproject) + package = project_info.name + if package is None: + msg = ( + "Please specify a [project.name] for the package in" + f" [{CONFIG_PATH.pyproject}]" + ) + raise PrecommitError(msg) + project = get_sub_table(pyproject, "project", create=True) + old_dependencies = project.get("optional-dependencies") + new_dependencies = deepcopy(old_dependencies) + python_versions = project_info.supported_python_versions + if python_versions is not None and "3.6" in python_versions: + ruff = 'ruff; python_version >="3.7.0"' + else: + ruff = "ruff" + if new_dependencies is None: + new_dependencies = dict( + dev=[f"{package}[sty]"], + lint=[ruff], + sty=[f"{package}[lint]"], + ) + else: + __add_package(new_dependencies, "dev", f"{package}[sty]") + __add_package(new_dependencies, "lint", ruff) + __add_package(new_dependencies, "sty", f"{package}[lint]") + if old_dependencies != new_dependencies: + project["optional-dependencies"] = new_dependencies + write_pyproject(pyproject) + msg = f"Updated [project.optional-dependencies] in {CONFIG_PATH.pyproject}" + raise PrecommitError(msg) + + +def __add_package(optional_dependencies: Table, key: str, package: str) -> None: + section = optional_dependencies.get(key) + if section is None: + optional_dependencies[key] = [package] + elif package not in section: + optional_dependencies[key] = to_toml_array( + sorted({package, *section}, key=lambda s: ('"' in s, s)) # Taplo sorting + ) + + def _update_nbqa_settings() -> None: # cspell:ignore addopts ruff_rules = [ diff --git a/src/repoma/check_dev_files/setup_cfg.py b/src/repoma/check_dev_files/setup_cfg.py index 733a93f5..5510f5b8 100644 --- a/src/repoma/check_dev_files/setup_cfg.py +++ b/src/repoma/check_dev_files/setup_cfg.py @@ -1,19 +1,43 @@ """Apply a certain set of standards to the :file:`setup.cfg`.""" - +# pyright: reportUnknownLambdaType=false +import dataclasses import os +import re import textwrap from collections import defaultdict +from configparser import RawConfigParser +from typing import Dict, List, Tuple, Union + +import tomlkit +from ini2toml.api import Translator +from tomlkit import TOMLDocument +from tomlkit.container import Container +from tomlkit.items import Array, Table from repoma.errors import PrecommitError from repoma.format_setup_cfg import write_formatted_setup_cfg from repoma.utilities import CONFIG_PATH from repoma.utilities.cfg import copy_config from repoma.utilities.executor import Executor -from repoma.utilities.setup_cfg import open_setup_cfg +from repoma.utilities.precommit import remove_precommit_hook +from repoma.utilities.project_info import ( + ProjectInfo, + get_project_info, + get_pypi_name, + get_supported_python_versions, + open_setup_cfg, +) +from repoma.utilities.pyproject import ( + get_sub_table, + load_pyproject, + write_pyproject, +) def main(ignore_author: bool) -> None: - if not CONFIG_PATH.setup_cfg.exists(): + if CONFIG_PATH.setup_cfg.exists(): + _convert_to_pyproject() + if not CONFIG_PATH.pyproject.exists(): return executor = Executor() executor(_check_required_options) @@ -23,21 +47,111 @@ def main(ignore_author: bool) -> None: executor.finalize() +def _convert_to_pyproject() -> None: + if "3.6" in get_supported_python_versions(): + return + setup_cfg = CONFIG_PATH.setup_cfg + with open(setup_cfg) as stream: + original_contents = stream.read() + toml_str = Translator().translate(original_contents, profile_name=str(setup_cfg)) + converted_cfg = tomlkit.parse(toml_str) + pyproject = load_pyproject() + _update_container(pyproject, converted_cfg) + extras_require = _get_recursive_optional_dependencies() + if extras_require: + _update_optional_dependencies(pyproject, extras_require) + write_pyproject(pyproject) + os.remove(setup_cfg) + if os.path.exists("setup.py"): + os.remove("setup.py") + remove_precommit_hook("format-setup-cfg") + msg = f"Converted {setup_cfg} configuration to {CONFIG_PATH.pyproject}" + raise PrecommitError(msg) + + +def _get_recursive_optional_dependencies() -> Dict[str, List[Tuple[str, str]]]: + if not CONFIG_PATH.setup_cfg.exists(): + return {} + cfg = RawConfigParser() + cfg.read(CONFIG_PATH.setup_cfg) + section_name = "options.extras_require" + if section_name not in cfg.sections(): + return {} + return { + option: __extract_package_list(cfg.get(section_name, option, raw=True)) + for option in cfg.options(section_name) + } + + +def _update_optional_dependencies( + pyproject: TOMLDocument, extras_require: Dict[str, List[Tuple[str, str]]] +) -> None: + package_name = get_pypi_name(pyproject) + optional_dependencies = tomlkit.table() + for key, packages in extras_require.items(): + package_array = tomlkit.array() + for package, comment in packages: + package = re.sub(r"%\(([^\)]+)\)s", rf"{package_name}[\1]", package) + toml_str = tomlkit.string(package, escape=False, literal='"' in package) + package_array.append(toml_str) + if comment: + __add_comment(package_array, -1, comment) + package_array.multiline(True) + optional_dependencies[key] = package_array + project = get_sub_table(pyproject, "project", create=True) + project["optional-dependencies"] = optional_dependencies + + +def __extract_package_list(raw_content: str) -> List[Tuple[str, str]]: + def split_comment(line: str) -> Tuple[str, str]: + if "#" in line: + return tuple(s.strip() for s in line.split("#", maxsplit=1)) # type: ignore[return-value] + return line.strip(), "" + + raw_content = raw_content.strip() + return [split_comment(s) for s in raw_content.split("\n")] + + +def __add_comment(array: Array, idx: int, comment: str) -> None: # disgusting hack + toml_comment = tomlkit.comment(comment) + toml_comment.indent(1) + toml_comment._trivia = dataclasses.replace(toml_comment._trivia, trail="") + array._value[idx].comment = toml_comment + + +def _update_container( + old: Union[Container, Table], new: Union[Container, Table] +) -> None: + for key, value in new.items(): + if isinstance(value, (Container, Table)): + if key in old: + _update_container(old[key], value) # type: ignore[arg-type] + else: + old[key] = value + else: + old[key] = value + + def _check_required_options() -> None: - cfg = open_setup_cfg() + if not has_pyproject_build_system(): + return + pyproject = load_pyproject() + project_info = get_project_info() + if project_info.is_empty(): + return required_options = { - "metadata": [ + "project": [ "name", "description", "license", "classifiers", + "requires-python", ], - "options": ["python_requires"], } missing_options = defaultdict(list) for section, options in required_options.items(): for option in options: - if cfg.has_option(section, option): + if option in get_sub_table(pyproject, section, create=True): continue missing_options[section].append(option) if missing_options: @@ -48,12 +162,39 @@ def _check_required_options() -> None: summary += f"{option} = ...\n" summary += "...\n" raise PrecommitError( - f"./{CONFIG_PATH.setup_cfg} is missing the following options:\n" + f"{CONFIG_PATH.pyproject} is missing the following options:\n" + textwrap.indent(summary, prefix=" ") ) def _update_author_data() -> None: + __update_author_data_in_pyproject() + __update_author_data_in_setup_cfg() + + +def __update_author_data_in_pyproject() -> None: + if not CONFIG_PATH.pyproject.exists(): + return + if not has_pyproject_build_system(): + return + pyproject = load_pyproject() + author_info = dict( + name="Common Partial Wave Analysis", + email="compwa-admin@ep1.rub.de", + ) + authors = tomlkit.array().multiline(True) + authors.append(author_info) + project = get_sub_table(pyproject, "project", create=True) + if project.get("authors") != authors: + pyproject["project"]["authors"] = authors # type: ignore[index] + write_pyproject(pyproject) + msg = f"Updated author info in {CONFIG_PATH.pyproject}" + raise PrecommitError(msg) + + +def __update_author_data_in_setup_cfg() -> None: + if not CONFIG_PATH.setup_cfg.exists(): + return old_cfg = open_setup_cfg() new_cfg = copy_config(old_cfg) new_cfg.set("metadata", "author", "Common Partial Wave Analysis") @@ -66,12 +207,53 @@ def _update_author_data() -> None: def _fix_long_description() -> None: - if os.path.exists("README.md"): - old_cfg = open_setup_cfg() - new_cfg = copy_config(old_cfg) - new_cfg.set("metadata", "long_description", "file: README.md") - new_cfg.set("metadata", "long_description_content_type", "text/markdown") - if new_cfg != old_cfg: - write_formatted_setup_cfg(new_cfg) - msg = f"Updated long_description in ./{CONFIG_PATH.setup_cfg}" - raise PrecommitError(msg) + if not os.path.exists("README.md"): + return + __fix_long_description_in_pyproject() + __fix_long_description_in_setup_cfg() + + +def __fix_long_description_in_pyproject() -> None: + if not has_pyproject_build_system(): + return + cfg = load_pyproject() + project = get_sub_table(cfg, "project", create=True) + existing_readme = project.get("readme") + expected_readme = { + "content-type": "text/markdown", + "file": "README.md", + } + if existing_readme == expected_readme: + return + project["readme "] = expected_readme + write_pyproject(cfg) + msg = f"Updated long_description in ./{CONFIG_PATH.setup_cfg}" + raise PrecommitError(msg) + + +def __fix_long_description_in_setup_cfg() -> None: + if not has_setup_cfg_build_system(): + return + old_cfg = open_setup_cfg() + new_cfg = copy_config(old_cfg) + new_cfg.set("metadata", "long_description", "file: README.md") + new_cfg.set("metadata", "long_description_content_type", "text/markdown") + if new_cfg != old_cfg: + write_formatted_setup_cfg(new_cfg) + msg = f"Updated long_description in ./{CONFIG_PATH.setup_cfg}" + raise PrecommitError(msg) + + +def has_pyproject_build_system() -> bool: + if not CONFIG_PATH.pyproject.exists(): + return False + pyproject = load_pyproject() + project_info = ProjectInfo.from_pyproject_toml(pyproject) + return not project_info.is_empty() + + +def has_setup_cfg_build_system() -> bool: + if not CONFIG_PATH.setup_cfg.exists(): + return False + cfg = open_setup_cfg() + return cfg.has_section("metadata") diff --git a/src/repoma/check_dev_files/toml.py b/src/repoma/check_dev_files/toml.py index f39924df..eae33258 100644 --- a/src/repoma/check_dev_files/toml.py +++ b/src/repoma/check_dev_files/toml.py @@ -11,6 +11,12 @@ from repoma.utilities import CONFIG_PATH, REPOMA_DIR, vscode from repoma.utilities.executor import Executor from repoma.utilities.precommit import update_single_hook_precommit_repo +from repoma.utilities.pyproject import ( + get_sub_table, + load_pyproject, + to_toml_array, + write_pyproject, +) __INCORRECT_TAPLO_CONFIG_PATHS = [ "taplo.toml", @@ -29,10 +35,51 @@ def main() -> None: executor(_rename_taplo_config) executor(_update_taplo_config) executor(_update_precommit_repo) + executor(_update_tomlsort_config) + executor(_update_tomlsort_hook) executor(_update_vscode_extensions) executor.finalize() +def _update_tomlsort_config() -> None: + # cspell:ignore tomlsort + pyproject = load_pyproject() + sort_first = [ + "build-system", + "project", + "tool.setuptools", + "tool.setuptools_scm", + ] + expected_config = dict( + all=False, + ignore_case=True, + in_place=True, + sort_first=to_toml_array(sort_first), + sort_table_keys=True, + spaces_indent_inline_array=4, + trailing_comma_inline_array=True, + ) + tool_table = get_sub_table(pyproject, "tool", create=True) + if tool_table.get("tomlsort") == expected_config: + return + tool_table["tomlsort"] = expected_config + write_pyproject(pyproject) + msg = "Updated toml-sort configuration" + raise PrecommitError(msg) + + +def _update_tomlsort_hook() -> None: + expected_hook = CommentedMap( + repo="https://github.com/pappasam/toml-sort", + hooks=[CommentedMap(id="toml-sort", args=["--in-place"])], + ) + excludes = ["labels.toml", "labels-physics.toml"] + excludes = [f for f in excludes if os.path.exists(f)] + if excludes: + expected_hook["hooks"][0]["exclude"] = "(?x)^(" + "|".join(excludes) + ")$" + update_single_hook_precommit_repo(expected_hook) + + def _rename_taplo_config() -> None: for path in __INCORRECT_TAPLO_CONFIG_PATHS: if not os.path.exists(path): diff --git a/src/repoma/format_setup_cfg.py b/src/repoma/format_setup_cfg.py index 10fd2a41..84625f98 100644 --- a/src/repoma/format_setup_cfg.py +++ b/src/repoma/format_setup_cfg.py @@ -10,7 +10,7 @@ from repoma.utilities import CONFIG_PATH from repoma.utilities.cfg import format_config -from repoma.utilities.setup_cfg import open_setup_cfg +from repoma.utilities.project_info import open_setup_cfg def format_setup_cfg() -> None: diff --git a/src/repoma/set_nb_cells.py b/src/repoma/set_nb_cells.py index 83013b42..bb91982a 100644 --- a/src/repoma/set_nb_cells.py +++ b/src/repoma/set_nb_cells.py @@ -20,15 +20,13 @@ import argparse import sys +from functools import lru_cache from textwrap import dedent from typing import Optional, Sequence import nbformat -from repoma.utilities.setup_cfg import open_setup_cfg - -__SETUP_CFG = open_setup_cfg() -__PACKAGE_NAME = __SETUP_CFG["metadata"]["name"] +from repoma.utilities.project_info import get_pypi_name __CONFIG_CELL_CONTENT = """ %config InlineBackend.figure_formats = ['svg'] @@ -45,10 +43,6 @@ "tags": ["remove-cell"], } -__INSTALL_CELL_CONTENT = f""" -# WARNING: advised to install a specific version, e.g. {__PACKAGE_NAME}==0.1.2 -%pip install -q {__PACKAGE_NAME} -""" __INSTALL_CELL_METADATA: dict = { **__CONFIG_CELL_METADATA, "tags": ["remove-cell", "skip-execution"], @@ -95,7 +89,7 @@ def main(argv: Optional[Sequence[str]] = None) -> int: for filename in args.filenames: cell_id = 0 if args.add_install_cell: - cell_content = __INSTALL_CELL_CONTENT.strip("\n") + cell_content = __get_install_cell().strip("\n") if args.extras_require: extras = args.extras_require.strip() cell_content += f"[{extras}]" @@ -121,6 +115,15 @@ def main(argv: Optional[Sequence[str]] = None) -> int: return 0 +@lru_cache(maxsize=1) +def __get_install_cell() -> str: + package_name = get_pypi_name() + return dedent(f""" + # WARNING: advised to install a specific version, e.g. {package_name}==0.1.2 + %pip install -q {package_name} + """) + + def _update_cell( filename: str, new_content: str, diff --git a/src/repoma/utilities/project_info.py b/src/repoma/utilities/project_info.py new file mode 100644 index 00000000..3f10e980 --- /dev/null +++ b/src/repoma/utilities/project_info.py @@ -0,0 +1,147 @@ +"""Helper functions for reading from and writing to :file:`setup.cfg`.""" + +import os +from configparser import ConfigParser +from textwrap import dedent +from typing import Dict, List, Optional + +from attrs import field, frozen +from tomlkit import TOMLDocument + +from repoma.errors import PrecommitError +from repoma.utilities.pyproject import get_sub_table, load_pyproject + +from . import CONFIG_PATH +from .cfg import open_config + + +@frozen +class ProjectInfo: + name: Optional[str] = None + supported_python_versions: Optional[List[str]] = None + urls: Dict[str, str] = field(factory=dict) + + def is_empty(self) -> bool: + return ( + self.name is None + and self.supported_python_versions is None + and not self.urls + ) + + @staticmethod + def from_pyproject_toml(pyproject: TOMLDocument) -> "ProjectInfo": + if "project" not in pyproject: + return ProjectInfo() + project = get_sub_table(pyproject, "project") + return ProjectInfo( + name=project.get("name"), + supported_python_versions=_extract_python_versions( + project.get("classifiers", []) + ), + urls=project.get("urls", {}), + ) + + @staticmethod + def from_setup_cfg(cfg: ConfigParser) -> "ProjectInfo": + if not cfg.has_section("metadata"): + return ProjectInfo() + metadata = dict(cfg.items("metadata")) + project_urls_raw: str = metadata.get("project_urls", "\n") + project_url_lines = project_urls_raw.split("\n") + project_url_lines = list(filter(lambda line: line.strip(), project_url_lines)) + project_urls = {} + for line in project_url_lines: + url_type, url, *_ = tuple(line.split("=")) + url_type = url_type.strip() + url = url.strip() + project_urls[url_type] = url + return ProjectInfo( + name=metadata.get("name"), + supported_python_versions=_extract_python_versions( + metadata.get("classifiers", "").split("\n") + ), + urls=project_urls, + ) + + +def get_project_info(pyproject: Optional[TOMLDocument] = None) -> ProjectInfo: + if pyproject is not None or os.path.exists(CONFIG_PATH.pyproject): + if pyproject is None: + pyproject = load_pyproject() + project_info = ProjectInfo.from_pyproject_toml(pyproject) + if not project_info.is_empty(): + return project_info + if os.path.exists(CONFIG_PATH.setup_cfg): + cfg = open_config(CONFIG_PATH.setup_cfg) + project_info = ProjectInfo.from_setup_cfg(cfg) + if not project_info.is_empty(): + return project_info + msg = f"No valid {CONFIG_PATH.setup_cfg} or {CONFIG_PATH.pyproject} found" + raise PrecommitError(msg) + + +def _extract_python_versions(classifiers: List[str]) -> Optional[List[str]]: + identifier = "Programming Language :: Python :: 3." + version_classifiers = [s for s in classifiers if s.startswith(identifier)] + if not version_classifiers: + return None + prefix = identifier[:-2] + return [s.replace(prefix, "") for s in version_classifiers] + + +def get_pypi_name(pyproject: Optional[TOMLDocument] = None) -> str: + """Extract package name for PyPI from `setup.cfg`. + + >>> get_pypi_name() + 'repo-maintenance' + """ + project_info = get_project_info(pyproject) + if project_info.name is None: + msg = ( + f"No package name defined in {CONFIG_PATH.setup_cfg} or" + f" {CONFIG_PATH.pyproject}" + ) + raise PrecommitError(msg) + return project_info.name + + +def get_supported_python_versions( + pyproject: Optional[TOMLDocument] = None, +) -> List[str]: + """Extract supported Python versions from package classifiers. + + >>> get_supported_python_versions() + ['3.6', '3.7', '3.8', '3.9', '3.10', '3.11'] + """ + project_info = get_project_info(pyproject) + if project_info.supported_python_versions is None: + msg = "Could not determine Python version classifiers of this package" + raise PrecommitError(msg) + return project_info.supported_python_versions + + +def get_repo_url(pyproject: Optional[TOMLDocument] = None) -> str: + project_info = get_project_info(pyproject) + if not project_info.urls: + msg = dedent(""" + pyproject.toml or setup.cfg does not contain project URLSs. Should be + something like: + + [project.urls]" + Documentation = "https://ampform.rtfd.io" + Source = "https://github.com/ComPWA/ampform" + Tracker = "https://github.com/ComPWA/ampform/issues" + """) + raise PrecommitError(msg) + source_url = project_info.urls.get("Source") + if source_url is None: + msg = '[project.urls] in pyproject.toml does not contain a "Source" URL' + raise PrecommitError(msg) + return source_url + + +def open_setup_cfg() -> ConfigParser: + if not CONFIG_PATH.setup_cfg.exists(): + msg = "This repository contains no setup.cfg file" + raise PrecommitError(msg) + return open_config(CONFIG_PATH.setup_cfg) diff --git a/src/repoma/utilities/setup_cfg.py b/src/repoma/utilities/setup_cfg.py deleted file mode 100644 index 3b5ebdf7..00000000 --- a/src/repoma/utilities/setup_cfg.py +++ /dev/null @@ -1,86 +0,0 @@ -"""Helper functions for reading from and writing to :file:`setup.cfg`.""" - -from configparser import ConfigParser -from typing import List - -from repoma.errors import PrecommitError - -from . import CONFIG_PATH -from .cfg import open_config - - -def get_pypi_name() -> str: - """Extract package name for PyPI from `setup.cfg`. - - >>> get_pypi_name() - 'repo-maintenance' - """ - cfg = open_setup_cfg() - if not cfg.has_option("metadata", "name"): - msg = f"No package name defined in {CONFIG_PATH.setup_cfg}" - raise PrecommitError(msg) - return cfg.get("metadata", "name") - - -def get_supported_python_versions() -> List[str]: - """Extract supported Python versions from package classifiers. - - >>> get_supported_python_versions() - ['3.6', '3.7', '3.8', '3.9', '3.10', '3.11'] - """ - cfg = open_setup_cfg() - if not cfg.has_option("metadata", "classifiers"): - msg = ( - "This package does not have Python version classifiers. See" - " https://pypi.org/classifiers." - ) - raise PrecommitError(msg) - raw = cfg.get("metadata", "classifiers") - lines = [s.strip() for s in raw.split("\n")] - identifier = "Programming Language :: Python :: 3." - classifiers = list(filter(lambda s: s.startswith(identifier), lines)) - if not classifiers: - msg = f'setup.cfg does not have any classifiers of the form "{identifier}*"' - raise PrecommitError(msg) - prefix = identifier[:-2] - return [s.replace(prefix, "") for s in classifiers] - - -def get_repo_url() -> str: - cfg = open_setup_cfg() - if not cfg.has_section("metadata"): - msg = "setup.cfg does not contain a metadata section" - raise PrecommitError(msg) - project_urls_def = cfg["metadata"].get("project_urls", None) - if project_urls_def is None: - error_message = ( - "Section metadata in setup.cfg does not contain project_urls." - " Should be something like:\n\n" - "[metadata]\n" - "...\n" - "project_urls =\n" - " Tracker = https://github.com/ComPWA/ampform/issues\n" - " Source = https://github.com/ComPWA/ampform\n" - " ...\n" - ) - raise PrecommitError(error_message) - project_url_lines = project_urls_def.split("\n") - project_url_lines = list(filter(lambda line: line.strip(), project_url_lines)) - project_urls = {} - for line in project_url_lines: - url_type, url, *_ = tuple(line.split("=")) - url_type = url_type.strip() - url = url.strip() - project_urls[url_type] = url - source_url = project_urls.get("Source") - if source_url is None: - msg = 'metadata.project_urls in setup.cfg does not contain "Source" URL' - raise PrecommitError(msg) - return source_url - - -def open_setup_cfg() -> ConfigParser: - if not CONFIG_PATH.setup_cfg.exists(): - msg = "This repository contains no setup.cfg file" - raise PrecommitError(msg) - return open_config(CONFIG_PATH.setup_cfg) diff --git a/tests/utilities/test_cfg.py b/tests/utilities/test_cfg.py index c8d6c775..a25fb812 100644 --- a/tests/utilities/test_cfg.py +++ b/tests/utilities/test_cfg.py @@ -6,7 +6,7 @@ from repoma.errors import PrecommitError from repoma.utilities.cfg import copy_config, format_config, open_config -from repoma.utilities.setup_cfg import get_repo_url, open_setup_cfg +from repoma.utilities.project_info import get_repo_url, open_setup_cfg def test_copy_config(): diff --git a/tests/utilities/test_setup_cfg.py b/tests/utilities/test_setup_cfg.py index 1e05a47f..d0267aec 100644 --- a/tests/utilities/test_setup_cfg.py +++ b/tests/utilities/test_setup_cfg.py @@ -1,4 +1,4 @@ -from repoma.utilities.setup_cfg import open_setup_cfg +from repoma.utilities.project_info import open_setup_cfg def test_open_setup_cfg():