From 05212de45befd37f5fceb93bc97d8b617444884b Mon Sep 17 00:00:00 2001 From: Niklas Rosenstein Date: Sat, 27 Jan 2024 02:34:48 +0100 Subject: [PATCH 01/22] add test install with pipx in CI --- .github/workflows/{python.yml => python.yaml} | 0 .github/workflows/selftest.yaml | 26 +++++++++++++++++++ 2 files changed, 26 insertions(+) rename .github/workflows/{python.yml => python.yaml} (100%) create mode 100644 .github/workflows/selftest.yaml diff --git a/.github/workflows/python.yml b/.github/workflows/python.yaml similarity index 100% rename from .github/workflows/python.yml rename to .github/workflows/python.yaml diff --git a/.github/workflows/selftest.yaml b/.github/workflows/selftest.yaml new file mode 100644 index 00000000..e0eadc12 --- /dev/null +++ b/.github/workflows/selftest.yaml @@ -0,0 +1,26 @@ +name: "Selftest" + +on: + push: + branches: [ "develop" ] + tags: [ '*' ] + pull_request: + branches: [ "develop" ] + +jobs: + test: + runs-on: ubuntu-20.04 + strategy: + matrix: + python-version: ["3.10", "3.11"] + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-python@v2 + with: { python-version: "${{ matrix.python-version }}" } + - run: pipx install . && which slap + - run: slap info + - run: slap test + - run: slap publish --dry + - uses: actions/setup-python@v2 + with: { python-version: "3.9" } + - run: slap info From 186fe83724820bf67deb034b644c9bea0d2487f1 Mon Sep 17 00:00:00 2001 From: Niklas Rosenstein Date: Sat, 27 Jan 2024 02:38:33 +0100 Subject: [PATCH 02/22] install pipx --- .github/workflows/selftest.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/selftest.yaml b/.github/workflows/selftest.yaml index e0eadc12..d8b9b748 100644 --- a/.github/workflows/selftest.yaml +++ b/.github/workflows/selftest.yaml @@ -17,7 +17,7 @@ jobs: - uses: actions/checkout@v2 - uses: actions/setup-python@v2 with: { python-version: "${{ matrix.python-version }}" } - - run: pipx install . && which slap + - run: pip install pipx && pipx install . && which slap - run: slap info - run: slap test - run: slap publish --dry From f3e320686f759b11d27071d9b6f93dedd779d4bc Mon Sep 17 00:00:00 2001 From: Niklas Rosenstein Date: Sat, 27 Jan 2024 02:41:08 +0100 Subject: [PATCH 03/22] pipx pipargs --- .github/workflows/selftest.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/selftest.yaml b/.github/workflows/selftest.yaml index d8b9b748..af98e312 100644 --- a/.github/workflows/selftest.yaml +++ b/.github/workflows/selftest.yaml @@ -17,7 +17,7 @@ jobs: - uses: actions/checkout@v2 - uses: actions/setup-python@v2 with: { python-version: "${{ matrix.python-version }}" } - - run: pip install pipx && pipx install . && which slap + - run: pip install pipx && pipx install . --pip-args -vvv && which slap - run: slap info - run: slap test - run: slap publish --dry From 6b5ac4ddb98d2b12406c4149f5e1e455794f3a77 Mon Sep 17 00:00:00 2001 From: Niklas Rosenstein Date: Sat, 27 Jan 2024 02:43:35 +0100 Subject: [PATCH 04/22] betta pipargs --- .github/workflows/selftest.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/selftest.yaml b/.github/workflows/selftest.yaml index af98e312..03468a55 100644 --- a/.github/workflows/selftest.yaml +++ b/.github/workflows/selftest.yaml @@ -17,7 +17,7 @@ jobs: - uses: actions/checkout@v2 - uses: actions/setup-python@v2 with: { python-version: "${{ matrix.python-version }}" } - - run: pip install pipx && pipx install . --pip-args -vvv && which slap + - run: pip install pipx && pipx install . --pip-args=-vvv && which slap - run: slap info - run: slap test - run: slap publish --dry From 3c0690ac5d76e128ea44c18fcb284ade1a3fe536 Mon Sep 17 00:00:00 2001 From: Niklas Rosenstein Date: Sat, 27 Jan 2024 02:45:04 +0100 Subject: [PATCH 05/22] pipx talk too little --- .github/workflows/selftest.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/selftest.yaml b/.github/workflows/selftest.yaml index 03468a55..a6fff227 100644 --- a/.github/workflows/selftest.yaml +++ b/.github/workflows/selftest.yaml @@ -17,7 +17,7 @@ jobs: - uses: actions/checkout@v2 - uses: actions/setup-python@v2 with: { python-version: "${{ matrix.python-version }}" } - - run: pip install pipx && pipx install . --pip-args=-vvv && which slap + - run: pip install pipx && pipx install . --pip-args=-vvv --verbose && which slap - run: slap info - run: slap test - run: slap publish --dry From ff5bfadcf8bb2da950594387bcc34222c73f6c3c Mon Sep 17 00:00:00 2001 From: Niklas Rosenstein Date: Sat, 27 Jan 2024 02:48:26 +0100 Subject: [PATCH 06/22] echomoa --- .github/workflows/selftest.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/selftest.yaml b/.github/workflows/selftest.yaml index a6fff227..6ee76152 100644 --- a/.github/workflows/selftest.yaml +++ b/.github/workflows/selftest.yaml @@ -18,6 +18,7 @@ jobs: - uses: actions/setup-python@v2 with: { python-version: "${{ matrix.python-version }}" } - run: pip install pipx && pipx install . --pip-args=-vvv --verbose && which slap + - run: echo $PIPX_HOME // $PIPX_BIN_DIR && ls $PIPX_HOME - run: slap info - run: slap test - run: slap publish --dry From a90b19227ce258393e96fc732054d20add90bd29 Mon Sep 17 00:00:00 2001 From: Niklas Rosenstein Date: Sat, 27 Jan 2024 02:49:59 +0100 Subject: [PATCH 07/22] speak --- .github/workflows/selftest.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/selftest.yaml b/.github/workflows/selftest.yaml index 6ee76152..09f67774 100644 --- a/.github/workflows/selftest.yaml +++ b/.github/workflows/selftest.yaml @@ -19,6 +19,8 @@ jobs: with: { python-version: "${{ matrix.python-version }}" } - run: pip install pipx && pipx install . --pip-args=-vvv --verbose && which slap - run: echo $PIPX_HOME // $PIPX_BIN_DIR && ls $PIPX_HOME + - run: ls $PIPX_HOME/venvs + - run: $PIPX_HOME/venvs/slap-cli/bin/python -m pip freeze - run: slap info - run: slap test - run: slap publish --dry From 561dfc1c49b117caf3e617e8470680c72a52fd7b Mon Sep 17 00:00:00 2001 From: Niklas Rosenstein Date: Sat, 27 Jan 2024 03:19:06 +0100 Subject: [PATCH 08/22] improvement: Get rid of deprecated `nr.util` dependency by placing all the required bits of code into Slap itself --- .changelog/_unreleased.toml | 5 + .github/workflows/{python.yaml => python.yml} | 0 pyproject.toml | 2 +- src/slap/application.py | 10 +- src/slap/changelog.py | 3 +- src/slap/configuration.py | 4 +- src/slap/ext/application/add.py | 2 +- src/slap/ext/application/check.py | 3 +- src/slap/ext/application/init.py | 2 +- src/slap/ext/application/install.py | 2 +- src/slap/ext/application/link.py | 2 +- src/slap/ext/application/release.py | 14 +- src/slap/ext/application/test.py | 3 +- src/slap/ext/checks/poetry.py | 4 +- src/slap/ext/project_handlers/base.py | 4 +- src/slap/ext/project_handlers/setuptools.py | 2 +- src/slap/ext/repository_handlers/default.py | 4 +- src/slap/ext/repository_hosts/github.py | 2 +- src/slap/install/installer.py | 3 +- src/slap/plugins.py | 4 +- src/slap/project.py | 9 +- src/slap/python/environment.py | 2 +- src/slap/release.py | 5 +- src/slap/repository.py | 13 +- src/slap/util/digraph.py | 256 +++++++++++ src/slap/util/fs.py | 137 ++++++ src/slap/util/git.py | 411 ++++++++++++++++++ src/slap/util/logging.py | 50 +++ src/slap/util/notset.py | 7 + src/slap/util/once.py | 29 ++ src/slap/util/orderedset.py | 67 +++ src/slap/util/plugins.py | 95 ++++ src/slap/util/supplier.py | 7 + src/slap/util/terminal.py | 298 +++++++++++++ src/slap/util/text.py | 56 +++ src/slap/util/toml_file.py | 2 +- src/slap/util/url.py | 74 ++++ src/slap/util/vcs.py | 7 +- src/slap/util/weak_property.py | 60 +++ 39 files changed, 1603 insertions(+), 57 deletions(-) create mode 100644 .changelog/_unreleased.toml rename .github/workflows/{python.yaml => python.yml} (100%) create mode 100644 src/slap/util/digraph.py create mode 100644 src/slap/util/fs.py create mode 100644 src/slap/util/git.py create mode 100644 src/slap/util/logging.py create mode 100644 src/slap/util/notset.py create mode 100644 src/slap/util/once.py create mode 100644 src/slap/util/orderedset.py create mode 100644 src/slap/util/plugins.py create mode 100644 src/slap/util/supplier.py create mode 100644 src/slap/util/terminal.py create mode 100644 src/slap/util/text.py create mode 100644 src/slap/util/url.py create mode 100644 src/slap/util/weak_property.py diff --git a/.changelog/_unreleased.toml b/.changelog/_unreleased.toml new file mode 100644 index 00000000..fd9641d2 --- /dev/null +++ b/.changelog/_unreleased.toml @@ -0,0 +1,5 @@ +[[entries]] +id = "69eb006e-0206-4aea-87fa-69bf2000a3f3" +type = "improvement" +description = "Get rid of deprecated `nr.util` dependency by placing all the required bits of code into Slap itself" +author = "@NiklasRosenstein" diff --git a/.github/workflows/python.yaml b/.github/workflows/python.yml similarity index 100% rename from .github/workflows/python.yaml rename to .github/workflows/python.yml diff --git a/pyproject.toml b/pyproject.toml index 9844fc66..2803e8b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,6 @@ beautifulsoup4 = "^4.10.0" cleo = ">=1.0.0a4" "databind" = "^4.4.0" flit = "^3.6.0" -"nr.util" = ">=0.8.12,<1.0.0" poetry-core = ">=1.7,<1.8" ptyprocess = "^0.7.0" pygments = "^2.11.2" @@ -40,6 +39,7 @@ tqdm = "^4.64.0" build = "^0.10.0" "nr.python.environment" = "^0.1.4" gitpython = "^3.1.31" +"nr.stream" = "^1.1.5" [tool.poetry.dev-dependencies] black = "^22.3.0" diff --git a/src/slap/application.py b/src/slap/application.py index 1124d205..fac64909 100644 --- a/src/slap/application.py +++ b/src/slap/application.py @@ -20,11 +20,10 @@ from slap.util.strings import split_by_commata if t.TYPE_CHECKING: - from nr.util.functional import Once - from slap.configuration import Configuration from slap.project import Project from slap.repository import Repository + from slap.util.once import Once __all__ = ["Command", "argument", "option", "IO", "Application"] logger = logging.getLogger(__name__) @@ -116,7 +115,7 @@ def render_error(self, error: Exception, io: IO) -> None: def _configure_io(self, io: IO) -> None: import logging - from nr.util.logging.formatters.terminal_colors import TerminalColorFormatter + from slap.util.logging import TerminalColorFormatter fmt = "%(message)s" if io.input.has_parameter_option("-vvv"): @@ -172,7 +171,7 @@ class Application: cleo: CleoApplication def __init__(self, directory: Path | None = None, name: str = "slap", version: str = __version__) -> None: - from nr.util.functional import Once + from slap.util.once import Once self._directory = directory or Path.cwd() self._repository: t.Optional[Repository] = None @@ -230,9 +229,8 @@ def load_plugins(self) -> None: plugins delivered immediately with Slap are enabled by default unless disabled explicitly with the `disable` option.""" - from nr.util.plugins import iter_entrypoints - from slap.plugins import ApplicationPlugin + from slap.util.plugins import iter_entrypoints assert not self._plugins_loaded self._plugins_loaded = True diff --git a/src/slap/changelog.py b/src/slap/changelog.py index 8f26e13b..95aa135f 100644 --- a/src/slap/changelog.py +++ b/src/slap/changelog.py @@ -9,7 +9,8 @@ from pathlib import Path from databind.core.settings import Alias -from nr.util.weak import weak_property + +from slap.util.weak_property import weak_property if t.TYPE_CHECKING: from poetry.core.constraints.version import Version # type: ignore[import] diff --git a/src/slap/configuration.py b/src/slap/configuration.py index 450a5705..612fe4c0 100644 --- a/src/slap/configuration.py +++ b/src/slap/configuration.py @@ -7,7 +7,7 @@ from slap.util.toml_file import TomlFile if t.TYPE_CHECKING: - from nr.util.functional import Once + from slap.util.once import Once logger = logging.getLogger(__name__) @@ -35,7 +35,7 @@ class Configuration: raw_config: Once[dict[str, t.Any]] def __init__(self, directory: Path) -> None: - from nr.util.functional import Once + from slap.util.once import Once self.directory = directory self.pyproject_toml = TomlFile(directory / "pyproject.toml") diff --git a/src/slap/ext/application/add.py b/src/slap/ext/application/add.py index 1de7a126..8652f11d 100644 --- a/src/slap/ext/application/add.py +++ b/src/slap/ext/application/add.py @@ -62,7 +62,7 @@ def activate(self, app: Application, config: None) -> None: app.cleo.add(self) def handle(self) -> int: - from nr.util.stream import Stream + from nr.stream import Stream from slap.install.installer import InstallOptions, PipInstaller, get_indexes_for_projects from slap.python.environment import PythonEnvironment diff --git a/src/slap/ext/application/check.py b/src/slap/ext/application/check.py index bdff617a..29b554af 100644 --- a/src/slap/ext/application/check.py +++ b/src/slap/ext/application/check.py @@ -3,12 +3,11 @@ import logging import typing as t -from nr.util.plugins import load_entrypoint - from slap.application import Application, Command, option from slap.check import Check, CheckResult from slap.plugins import ApplicationPlugin, CheckPlugin from slap.project import Project +from slap.util.plugins import load_entrypoint logger = logging.getLogger(__name__) DEFAULT_PLUGINS = ["changelog", "general", "poetry", "release"] diff --git a/src/slap/ext/application/init.py b/src/slap/ext/application/init.py index 9c4c2ad8..4bfb04e2 100644 --- a/src/slap/ext/application/init.py +++ b/src/slap/ext/application/init.py @@ -93,7 +93,7 @@ def activate(self, app: Application, config: None) -> None: app.cleo.add(self) def handle(self) -> int: - from nr.util.optional import Optional + from nr.stream import Optional template = self.option("template") if template not in TEMPLATES: diff --git a/src/slap/ext/application/install.py b/src/slap/ext/application/install.py index 8e5b1244..779aff06 100644 --- a/src/slap/ext/application/install.py +++ b/src/slap/ext/application/install.py @@ -166,7 +166,7 @@ def handle(self) -> int: Installs the requirements of the package using Pip. """ - from nr.util.stream import Stream + from nr.stream import Stream from slap.install.installer import InstallOptions, PipInstaller, get_indexes_for_projects from slap.python.dependency import PathDependency, PypiDependency, parse_dependencies diff --git a/src/slap/ext/application/link.py b/src/slap/ext/application/link.py index b361903a..54d9b63e 100644 --- a/src/slap/ext/application/link.py +++ b/src/slap/ext/application/link.py @@ -102,8 +102,8 @@ def handle(self) -> int: def link_repository(io: IO, projects: list[Project], dump_pyproject: bool = False, python: str | None = None) -> None: from flit.install import Installer # type: ignore[import] - from nr.util.fs import atomic_swap + from slap.util.fs import atomic_swap from slap.util.pygments import toml_highlight # We need to pass an absolute path to Python to make sure the scripts have an absolute shebang. diff --git a/src/slap/ext/application/release.py b/src/slap/ext/application/release.py index 5177e01e..27e6eb49 100644 --- a/src/slap/ext/application/release.py +++ b/src/slap/ext/application/release.py @@ -197,7 +197,7 @@ def _validate_options(self) -> int: def _load_plugins(self, configuration: Configuration) -> list[ReleasePlugin]: """Internal. Loads the plugins for the given configuration.""" - from nr.util.plugins import load_entrypoint + from slap.util.plugins import load_entrypoint plugins = [] for plugin_name in self.config[configuration].plugins: @@ -252,7 +252,7 @@ def _validate_version_refs(self, version_refs: list[VersionRef], version: str | def _check_on_release_branch(self) -> bool: """Internal. Checks if the current Git branch matches the configured release branch.""" - from nr.util.git import NoCurrentBranchError + from slap.util.git import NoCurrentBranchError if not self.is_git_repository or self.option("no-branch-check"): return True @@ -320,9 +320,10 @@ def _get_new_version(self, version_refs: list[VersionRef], rule: str) -> "Versio """Return the new version, based on *rule*. If *rule* is a version string, it is used as the new version. Otherwise, it is considered a rule and the applicable rule plugin is invoked to construct the new version.""" - from nr.util.plugins import NoSuchEntrypointError, load_entrypoint from poetry.core.constraints.version import Version + from slap.util.plugins import NoSuchEntrypointError, load_entrypoint + try: return Version.parse(rule) except ValueError: @@ -336,8 +337,9 @@ def _get_new_version(self, version_refs: list[VersionRef], rule: str) -> "Versio def _bump_version(self, version_refs: list[VersionRef], target_version: Version, dry: bool) -> list[Path]: """Internal. Replaces the version reference in all files with the specified *version*.""" - from nr.util import Stream - from nr.util.text import substitute_ranges + from nr.stream import Stream + + from slap.util.text import substitute_ranges self.line( f'bumping {len(version_refs)} version reference{"" if len(version_refs) == 1 else "s"} to ' @@ -456,7 +458,7 @@ def _get_version_refs(self) -> list[VersionRef]: def handle(self) -> int: """Entrypoint for the command.""" - from nr.util.git import Git + from slap.util.git import Git self.git = Git() self.is_git_repository = self.git.get_toplevel() is not None diff --git a/src/slap/ext/application/test.py b/src/slap/ext/application/test.py index 0652216c..dc2dc178 100644 --- a/src/slap/ext/application/test.py +++ b/src/slap/ext/application/test.py @@ -3,12 +3,11 @@ import typing as t from pathlib import Path -from nr.util.singleton import NotSet - from slap.application import IO, Application, argument, option from slap.ext.application.venv import VenvAwareCommand from slap.plugins import ApplicationPlugin from slap.project import Project +from slap.util.notset import NotSet logger = logging.getLogger(__name__) diff --git a/src/slap/ext/checks/poetry.py b/src/slap/ext/checks/poetry.py index 2849c974..7148b99e 100644 --- a/src/slap/ext/checks/poetry.py +++ b/src/slap/ext/checks/poetry.py @@ -2,14 +2,14 @@ from pathlib import Path import requests -from nr.util import Optional -from nr.util.fs import get_file_in_directory +from nr.stream import Optional from slap.check import Check, CheckResult, check, get_checks from slap.ext.project_handlers.poetry import PoetryProjectHandler from slap.plugins import CheckPlugin from slap.project import Project from slap.util.external.pypi_classifiers import get_classifiers +from slap.util.fs import get_file_in_directory def get_readme_path(project: Project) -> Path | None: diff --git a/src/slap/ext/project_handlers/base.py b/src/slap/ext/project_handlers/base.py index 18497e6e..5dc63634 100644 --- a/src/slap/ext/project_handlers/base.py +++ b/src/slap/ext/project_handlers/base.py @@ -7,13 +7,13 @@ import typing as t from pathlib import Path -from nr.util.algorithm.longest_common_substring import longest_common_substring -from nr.util.fs import get_file_in_directory from setuptools import find_namespace_packages, find_packages from slap.plugins import ProjectHandlerPlugin from slap.project import Package, Project from slap.release import VersionRef, match_version_ref_pattern, match_version_ref_pattern_on_lines +from slap.util.fs import get_file_in_directory +from slap.util.text import longest_common_substring if t.TYPE_CHECKING: from slap.python.dependency import Dependency diff --git a/src/slap/ext/project_handlers/setuptools.py b/src/slap/ext/project_handlers/setuptools.py index 54183372..30dded8f 100644 --- a/src/slap/ext/project_handlers/setuptools.py +++ b/src/slap/ext/project_handlers/setuptools.py @@ -106,7 +106,7 @@ def parse_list_semi(val: str) -> list[str]: [1]: https://setuptools.pypa.io/en/latest/userguide/declarative_config.html#specifying-values""" - from nr.util.stream import Stream + from nr.stream import Stream return Stream(val.splitlines()).map(lambda v: v.split(";")).concat().map(str.strip).filter(bool).collect() diff --git a/src/slap/ext/repository_handlers/default.py b/src/slap/ext/repository_handlers/default.py index 2d2b3442..be91d956 100644 --- a/src/slap/ext/repository_handlers/default.py +++ b/src/slap/ext/repository_handlers/default.py @@ -2,11 +2,11 @@ import typing as t from databind.core.settings import Alias -from nr.util.fs import get_file_in_directory from slap.plugins import RepositoryHandlerPlugin from slap.project import Project from slap.repository import Repository, RepositoryHost +from slap.util.fs import get_file_in_directory from slap.util.vcs import Vcs, detect_vcs @@ -64,7 +64,7 @@ def get_vcs(self, repository: Repository) -> Vcs | None: return detect_vcs(repository.directory) def get_repository_host(self, repository: Repository) -> RepositoryHost | None: - from nr.util.plugins import iter_entrypoints + from slap.util.plugins import iter_entrypoints config = self._get_config(repository) if config.repository_host: diff --git a/src/slap/ext/repository_hosts/github.py b/src/slap/ext/repository_hosts/github.py index fa693464..94c4fdd1 100644 --- a/src/slap/ext/repository_hosts/github.py +++ b/src/slap/ext/repository_hosts/github.py @@ -95,7 +95,7 @@ def get_pull_request_by_reference(self, pr_reference: str) -> PullRequest: @staticmethod def detect_repository_host(repository: Repository) -> RepositoryHost | None: - from nr.util.git import Git + from slap.util.git import Git git = Git(repository.directory) if not git.get_toplevel(): diff --git a/src/slap/install/installer.py b/src/slap/install/installer.py index 8945b791..d5ba1ea3 100644 --- a/src/slap/install/installer.py +++ b/src/slap/install/installer.py @@ -11,10 +11,9 @@ from pathlib import Path from urllib.parse import unquote -from nr.util.url import Url - from slap.python.dependency import MultiDependency from slap.python.pep508 import filter_dependencies, test_dependency +from slap.util.url import Url if t.TYPE_CHECKING: from slap.project import Project diff --git a/src/slap/plugins.py b/src/slap/plugins.py index 6fd6904b..5756ff39 100644 --- a/src/slap/plugins.py +++ b/src/slap/plugins.py @@ -4,7 +4,7 @@ import typing as t from functools import partial -from nr.util.generic import T +T = t.TypeVar("T") if t.TYPE_CHECKING: from pathlib import Path @@ -202,7 +202,7 @@ def all() -> dict[str, t.Callable[[], RepositoryCIPlugin]]: """Iterates over all registered automation plugins and returns a dictionary that maps the plugin name to a factory function.""" - from nr.util.plugins import iter_entrypoints + from slap.util.plugins import iter_entrypoints result: dict[str, t.Callable[[], RepositoryCIPlugin]] = {} for ep in iter_entrypoints(RepositoryCIPlugin.ENTRYPOINT): diff --git a/src/slap/project.py b/src/slap/project.py index 869411da..12542bab 100644 --- a/src/slap/project.py +++ b/src/slap/project.py @@ -10,13 +10,12 @@ from slap.configuration import Configuration if t.TYPE_CHECKING: - from nr.util.functional import Once - from slap.install.installer import Indexes from slap.plugins import ProjectHandlerPlugin from slap.python.dependency import Dependency, VersionSpec from slap.release import VersionRef from slap.repository import Repository + from slap.util.once import Once logger = logging.getLogger(__name__) @@ -87,8 +86,7 @@ class Project(Configuration): def __init__(self, repository: Repository, directory: Path) -> None: super().__init__(directory) - from nr.util.functional import Once - + from slap.util.once import Once from slap.util.toml_file import TomlFile self.repository = repository @@ -112,9 +110,8 @@ def _get_project_configuration(self) -> ProjectConfig: def _get_project_handler(self) -> ProjectHandlerPlugin: """Returns the handler for this project.""" - from nr.util.plugins import iter_entrypoints, load_entrypoint - from slap.plugins import ProjectHandlerPlugin + from slap.util.plugins import iter_entrypoints, load_entrypoint handler_name = self.config().handler if handler_name is None: diff --git a/src/slap/python/environment.py b/src/slap/python/environment.py index 5c275234..795dcade 100644 --- a/src/slap/python/environment.py +++ b/src/slap/python/environment.py @@ -170,7 +170,7 @@ class DistributionGraph: missing: set[str] def sort(self) -> None: - from nr.util.orderedset import OrderedSet + from slap.util.orderedset import OrderedSet for dist_name, dependencies in self.dependencies.items(): self.dependencies[dist_name] = OrderedSet(sorted(dependencies)) diff --git a/src/slap/release.py b/src/slap/release.py index c8075c14..2f4086da 100644 --- a/src/slap/release.py +++ b/src/slap/release.py @@ -5,8 +5,9 @@ import typing as t from pathlib import Path -from nr.util.generic import T -from nr.util.singleton import NotSet +from slap.util.notset import NotSet + +T = t.TypeVar("T") @t.overload diff --git a/src/slap/repository.py b/src/slap/repository.py index 01cf0649..0c7d286d 100644 --- a/src/slap/repository.py +++ b/src/slap/repository.py @@ -6,9 +6,9 @@ from pathlib import Path from databind.core.settings import Union -from nr.util.functional import Once from slap.configuration import Configuration +from slap.util.once import Once if t.TYPE_CHECKING: from slap.plugins import RepositoryHandlerPlugin @@ -107,10 +107,9 @@ def use_shared_venv(self) -> bool: def _get_repository_handler(self) -> RepositoryHandlerPlugin | None: """Returns the handler for this repository.""" - from nr.util.plugins import load_entrypoint - from slap.ext.repository_handlers.default import DefaultRepositoryHandler from slap.plugins import RepositoryHandlerPlugin + from slap.util.plugins import load_entrypoint handler: RepositoryHandlerPlugin handler_name = self.raw_config().get("repository", {}).get("handler") @@ -136,10 +135,8 @@ def _get_projects(self) -> list[Project]: def get_projects_ordered(self) -> list[Project]: """Return a topological ordering of the projects.""" - from nr.util.digraph import DiGraph - from nr.util.digraph.algorithm.topological_sort import topological_sort - from slap.project import Project + from slap.util.digraph import DiGraph, topological_sort graph: DiGraph[Project, None, None] = DiGraph() projects = self.projects() @@ -153,12 +150,12 @@ def get_projects_ordered(self) -> list[Project]: return list(topological_sort(graph, sorting_key=lambda p: p.id)) def _get_vcs(self) -> Vcs | None: - from nr.util.optional import Optional + from nr.stream import Optional return Optional(self._handler()).map(lambda h: h.get_vcs(self)).or_else(None) def _get_repository_host(self) -> RepositoryHost | None: - from nr.util.optional import Optional + from nr.stream import Optional return Optional(self._handler()).map(lambda h: h.get_repository_host(self)).or_else(None) diff --git a/src/slap/util/digraph.py b/src/slap/util/digraph.py new file mode 100644 index 00000000..72e6688f --- /dev/null +++ b/src/slap/util/digraph.py @@ -0,0 +1,256 @@ +from __future__ import annotations + +import dataclasses +import typing as t +import weakref + +from typing_extensions import Protocol + +from slap.util.notset import NotSet + +K = t.TypeVar("K", bound=t.Hashable) +N = t.TypeVar("N") +E = t.TypeVar("E") + + +class DiGraph(t.Generic[K, N, E]): + """ + Represents a directed graph. + + @generic N: The type of value stored for each node in the graph. All nodes in a graph are unique. + @generic E: The type of value stored for each edge in the graph. Edge values may not be unique. + """ + + def __init__(self): + """ + Create a new empty directed graph. + """ + + self._nodes: dict[K, "_Node[K, N]"] = {} + self._roots: dict[K, None] = {} + self._leafs: dict[K, None] = {} + self._edges: dict[tuple[K, K], E] = {} + self._nodesview = NodesView(self) + self._edgesview = EdgesView(self) + + def add_node(self, node_id: K, value: N) -> None: + """ + Add a node to the graph. Overwrites the existing node value if the *node_id* already exists in the graph, + but keeps its edges intact. + """ + + existing_node = self._nodes.get(node_id, NotSet.Value) + if existing_node is NotSet.Value: + predecessors, successors = {}, {} + self._roots[node_id] = None + self._leafs[node_id] = None + else: + predecessors, successors = existing_node.predecessors, existing_node.successors + self._nodes[node_id] = _Node(value, predecessors, successors) + + def add_edge(self, node_id1: K, node_id2: K, value: E) -> None: + """ + Adds a directed edge from *node_id1* to *node_id2* to the graph, storing the given value along the edge. + Overwrites the value if the edge already exists. The edge's nodes must be present in the graph. + + @raises UnknownNodeError: If one of the nodes don't exist in the graph. + """ + + node1, node2 = self._get_node(node_id1), self._get_node(node_id2) + self._edges[(node_id1, node_id2)] = value + node1.successors[node_id2] = None + node2.predecessors[node_id1] = None + self._leafs.pop(node_id1, None) + self._roots.pop(node_id2, None) + + @property + def nodes(self) -> "NodesView[K, N]": + """ + Returns a view on the nodes in the graph. + """ + + return self._nodesview + + @property + def edges(self) -> "EdgesView[K, E]": + """ + Returns a view on the edges in the graph. + """ + + return self._edgesview + + @property + def roots(self) -> t.KeysView[K]: + """ + Return the nodes of the graph that have no predecessors. + """ + + return self._roots.keys() + + @property + def leafs(self) -> t.KeysView[K]: + """ + Return the nodes of the graph that have no successors. + """ + + return self._leafs.keys() + + def predecessors(self, node_id: K) -> t.KeysView[K]: + """ + Returns a sequence of the given node's predecessor node IDs. + + @raises UnknownNodeError: If the node does not exist. + """ + + return self._get_node(node_id).predecessors.keys() + + def successors(self, node_id: K) -> t.KeysView[K]: + """ + Returns a sequence of the given node's successor node IDs. + + @raises UnknownNodeError: If the node does not exist. + """ + + return self._get_node(node_id).successors.keys() + + def copy(self) -> DiGraph[K, N, E]: + """Return a copy of the graph. Note that the data is still the same, which may be undesirable if + it is intended to be mutable.""" + + new = type(self)() + new._nodes.update(self._nodes) + new._roots.update(self._roots) + new._leafs.update(self._leafs) + new._edges.update(self._edges) + return new + + # Internal + + def _get_node(self, node_id: K) -> "_Node[K, N]": + try: + return self._nodes[node_id] + except KeyError: + raise UnknownNodeError(node_id) + + +@dataclasses.dataclass +class _Node(t.Generic[K, N]): + value: N + predecessors: dict[K, None] + successors: dict[K, None] + + +class NodesView(t.Mapping[K, N]): + def __init__(self, g: DiGraph[K, N, t.Any]) -> None: + self._g = weakref.ref(g) + self._nodes = g._nodes + + def __repr__(self) -> str: + return f"" + + def __contains__(self, node_id: object) -> bool: + return node_id in self._nodes + + def __len__(self) -> int: + return len(self._nodes) + + def __iter__(self) -> t.Iterator[K]: + return iter(self._nodes) + + def __getitem__(self, key: K) -> N: + try: + return self._nodes[key].value + except KeyError: + raise UnknownNodeError(key) + + def __setitem__(self, key: K, value: N) -> None: + g = self._g() + assert g is not None + g.add_node(key, value) + + def __delitem__(self, key: K) -> None: + g = self._g() + assert g is not None + node = g._nodes.pop(key) + for pred in node.predecessors: + g._nodes[pred].successors.pop(key) + del g._edges[(pred, key)] + for succ in node.successors: + g._nodes[succ].predecessors.pop(key) + del g._edges[(key, succ)] + g._roots.pop(key, None) + g._leafs.pop(key, None) + + +class EdgesView(t.Mapping["tuple[K, K]", E]): + def __init__(self, g: DiGraph[K, t.Any, E]) -> None: + self._g = weakref.ref(g) + self._edges = g._edges + + def __repr__(self) -> str: + return f"" + + def __contains__(self, edge: object) -> bool: + return edge in self._edges + + def __len__(self) -> int: + return len(self._edges) + + def __iter__(self) -> t.Iterator[tuple[K, K]]: + return iter(self._edges) + + def __getitem__(self, key: tuple[K, K]) -> E: + try: + return self._edges[key] + except KeyError: + raise UnknownEdgeError(key) + + def __setitem__(self, key: tuple[K, K], value: E) -> None: + g = self._g() + assert g is not None + g.add_edge(key[0], key[1], value) + + def __delitem__(self, key: tuple[K, K]) -> None: + del self._edges[key] + + +class UnknownNodeError(KeyError): + pass + + +class UnknownEdgeError(KeyError): + pass + + +T_Comparable = t.TypeVar("T_Comparable", bound="Comparable") + + +class Comparable(Protocol): + def __lt__(self, other: t.Any) -> bool: + ... + + +def topological_sort( + graph: DiGraph[K, N, E], sorting_key: t.Optional[t.Callable[[K], Comparable]] = None +) -> t.Iterator[K]: + """Calculate the topological order for elements in the *graph*. + + @raises RuntimeError: If there is a cycle in the graph.""" + + seen: set[K] = set() + roots = graph.roots + + while roots: + if seen & roots: + raise RuntimeError(f"encountered a cycle in the graph at {seen & roots}") + seen.update(roots) + yield from roots + roots = { + k: None + for n in roots + for k in sorted(graph.successors(n), key=sorting_key) # type: ignore + if not graph.predecessors(k) - seen + }.keys() + + if len(seen) != len(graph.nodes): + raise RuntimeError(f"encountered a cycle in the graph (unreached nodes {set(graph.nodes) - seen})") diff --git a/src/slap/util/fs.py b/src/slap/util/fs.py new file mode 100644 index 00000000..820559fa --- /dev/null +++ b/src/slap/util/fs.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +import contextlib +import os +import tempfile +import typing as t +from pathlib import Path + +import typing_extensions as te + +StrPath: te.TypeAlias = "str | Path" + + +@t.overload +def atomic_write( + path: StrPath, + mode: te.Literal["w"], + rename_mode: te.Literal["posix", "windows"] | None, +) -> t.ContextManager[t.TextIO]: + ... + + +@t.overload +def atomic_write( + path: StrPath, + mode: te.Literal["wb"], + rename_mode: te.Literal["posix", "windows"] | None, +) -> t.ContextManager[t.BinaryIO]: + ... + + +@contextlib.contextmanager # type: ignore +def atomic_write( + path: StrPath, + mode: te.Literal["w", "wb"], + rename_mode: te.Literal["posix", "windows"] | None = None, +) -> t.Iterator[t.IO]: + """Write to a temporarily file, then rename on file closure. If an error occurs while the context manager is active, + the temporary file will be deleted instead and if an original file existed before it will not be modified. On + Windows systems, the file cannot be replaced in an atomic operation, so it will need to be deleted first.""" + + if rename_mode is None: + if os.name == "nt": + rename_mode = "windows" + else: + rename_mode = "posix" + + with tempfile.NamedTemporaryFile(mode, delete=False) as fp: + try: + yield fp + except: # noqa: E722 + os.remove(fp.name) + raise + else: + fp.flush() + os.fsync(fp.fileno()) + if rename_mode == "windows" and os.path.isfile(path): + os.remove(path) + os.rename(fp.name, path) + + +@t.overload +def atomic_swap( + path: StrPath, + mode: te.Literal["w"], + always_revert: bool, +) -> t.ContextManager[t.TextIO]: + ... + + +@t.overload +def atomic_swap( + path: StrPath, + mode: te.Literal["wb"], + always_revert: bool, +) -> t.ContextManager[t.BinaryIO]: + ... + + +@contextlib.contextmanager # type: ignore +def atomic_swap( + path: StrPath, + mode: te.Literal["w", "wb"], + always_revert: bool = False, +) -> t.Iterator[t.IO]: + """Similar to #atomic_write(), only that this function writes to the *path* directlty instead of a temporary file, + and save the original version of the file next to it in the same directory by temporarily renaming it. If the + context exits without error, the old file will be removed. Otherwise, the new file will be deleted and the old file + will be renamed back to *path*. If *always_revert* is enabled, the original file will be restored even if the + context exits without errors.""" + + path = Path(path) + + with tempfile.NamedTemporaryFile(mode, prefix=path.stem + "~", suffix="~" + path.suffix, dir=path.parent) as old: + old.close() + os.rename(path, old.name) + + def _revert(): + if path.is_file(): + path.unlink() + os.rename(old.name, path) + + try: + with path.open(mode) as new: + yield new + except: # noqa: E722 + _revert() + raise + else: + if always_revert: + _revert() + else: + os.remove(old.name) + + +def get_file_in_directory( + directory: Path, + prefix: str, + preferred: list[str], + case_sensitive: bool = True, +) -> Path | None: + """Returns a file in *directory* that is either in the *preferred* list or starts with specified *prefix*.""" + + if not case_sensitive: + preferred = [x.lower() for x in preferred] + + choices = [] + for path in sorted(directory.iterdir()): + if (case_sensitive and path.name in preferred) or (not case_sensitive and path.name.lower() in preferred): + return path + if path.name.startswith(prefix): + choices.append(path) + else: + if choices: + return choices[0] + + return None diff --git a/src/slap/util/git.py b/src/slap/util/git.py new file mode 100644 index 00000000..695af6b0 --- /dev/null +++ b/src/slap/util/git.py @@ -0,0 +1,411 @@ +from __future__ import annotations + +import os +import subprocess as sp +import typing as t +from pathlib import Path + + +class GitError(Exception): + pass + + +class NoCurrentBranchError(GitError): + pass + + +class Branch(t.NamedTuple): + name: str + current: bool + + +class FileStatus(t.NamedTuple): + mode: str + filename: str + + +class RefWithSha(t.NamedTuple): + ref: str + sha: str + + +class Remote(t.NamedTuple): + name: str + fetch: str + push: str + + +class Git: + """ + Utility class to interface with the Git commandline. + """ + + def __init__(self, path: Path | str | None = None): + self.path = Path(path) if path else Path.cwd() + + def check_call(self, command: t.List[str], stdout: t.Optional[int] = None) -> None: + sp.check_call(command, cwd=self.path, stdout=stdout) + + def check_output(self, command: t.List[str], stderr: t.Optional[int] = None) -> bytes: + return sp.check_output(command, cwd=self.path, stderr=stderr) + + def init(self) -> None: + self.check_call(["git", "init", "."]) + + def clone( + self, + clone_url: str, + branch: str = None, + depth: int = None, + recursive: bool = False, + username: str = None, + password: str = None, + quiet: bool = False, + ) -> None: + """ + Clone a Git repository to the *to_directory* from *clone_url*. If a relative path is + specified in *to_directory*, it will be treated relative to the #Git.cwd path. + """ + + if password or username: + if not clone_url.startswith("https://"): + raise ValueError("cannot specify username/password for non-HTTPS clone URL.") + schema, remainder = clone_url.partition("://")[::2] + auth = ":".join(t.cast(t.List[str], filter(bool, [username, password]))) + clone_url = schema + "://" + auth + "@" + remainder + + command = ["git", "clone", clone_url, str(self.path)] + if branch: + command += ["-b", branch] + if depth: + command += ["--depth", str(depth)] + if recursive: + command += ["--recursive"] + if quiet: + command += ["-q"] + + # NOTE (NiklasRosenstein): We don't use #Git.check_call() as that would try to + # change directory to the clone target directory, which might not yet exist. + sp.check_call(command) + + def add(self, files: t.List[str]) -> None: + """ + Add files to the index. + """ + + assert isinstance(files, list), f"expected list, got {type(files).__name__}" + command = ["git", "add", "--"] + files + self.check_call(command) + + def get_branches(self) -> t.List[Branch]: + """ + Get the branches of the repository. Returns a list of #Branch objects. + """ + + command = ["git", "branch"] + results = [] + for line in self.check_output(command).decode().splitlines(): + current = False + if line.startswith("*"): + line = line[1:] + current = True + line = line.strip() + if line.startswith("(HEAD"): + continue + results.append(Branch(line, current)) + + return results + + def get_branch_names(self) -> t.List[str]: + """ + Get the branch names. + """ + + return [x.name for x in self.get_branches()] + + def get_current_branch_name(self) -> str: + """ + Return the name of the current branch. + """ + + for branch in self.get_branches(): + if branch.current: + return branch.name + + raise NoCurrentBranchError(self.path) + + def get_remote_refs(self, remote: str) -> t.List[RefWithSha]: + result = [] + command = ["git", "ls-remote", "--heads", "origin"] + for line in self.check_output(command).decode().splitlines(): + sha, ref = line.split() + result.append(RefWithSha(ref, sha)) + return result + + def get_remote_branch_names(self, remote: str) -> t.List[str]: + refs = self.get_remote_refs(remote) + return [x.ref[11:] for x in refs if x.ref.startswith("refs/heads/")] + + def rename_branch(self, current: str, new: str) -> None: + self.check_call(["git", "branch", "-m", current, new]) + + def push(self, remote: str, *refs, force: bool = False) -> None: + """ + Push the specified *refs* to the Git remote. + """ + + command = ["git", "push", remote] + list(refs) + if force: + command.insert(2, "-f") + self.check_call(command) + + def pull(self, remote: str = None, branch: str = None, quiet: bool = False): + """ + Pull from the specified Git remote. + """ + + command = ["git", "pull"] + if remote and branch: + command += [remote, branch] + elif remote or branch: + raise ValueError("remote and branch arguments can only be specified together") + if quiet: + command += ["-q"] + + self.check_call(command) + + def fetch( + self, + remote: str = None, + all: bool = False, + tags: bool = False, + prune: bool = False, + prune_tags: bool = False, + argv: t.Optional[t.List[str]] = None, + ) -> None: + """ + Fetch a remote repository (or multiple). + """ + + command = ["git", "fetch"] + if remote: + command += [remote] + if all: + command += ["--all"] + if tags: + command += ["--tags"] + if prune: + command += ["--prune"] + if prune_tags: + command += ["--prune-tags"] + command += argv or [] + + self.check_call(command) + + def remotes(self) -> t.List[Remote]: + """ + List up all the remotes of the repository. + """ + + remotes: t.Dict[str, t.Dict[str, str]] = {} + for line in self.check_output(["git", "remote", "-v"]).decode().splitlines(): + remote, url, kind = line.split() + remotes.setdefault(remote, {})[kind] = url + + return [Remote(remote, urls["(fetch)"], urls["(push)"]) for remote, urls in remotes.items()] + + def add_remote(self, remote: str, url: str, argv: t.Optional[t.List[str]] = None) -> None: + """ + Add a remote with the specified name. + """ + + command = ["git", "remote", "add", remote, url] + (argv or []) + self.check_call(command) + + def get_status(self) -> t.Iterable[FileStatus]: + """ + Returns the file status for the working tree. + """ + + for line in self.check_output(["git", "status", "--porcelain"]).decode().splitlines(): + mode = line[:2] + filename = line.strip().partition(" ")[-1] + yield FileStatus(mode, filename) + + def commit(self, message: str, allow_empty: bool = False) -> None: + """ + Commit staged files to the repository. + """ + + command = ["git", "commit", "-m", message] + if allow_empty: + command.append("--allow-empty") + self.check_call(command) + + def tag(self, tag_name: str, force: bool = False) -> None: + """ + Create a tag. + """ + + command = ["git", "tag", tag_name] + (["-f"] if force else []) + self.check_call(command) + + def rev_parse(self, rev: str) -> t.Optional[str]: + """ + Parse a Git ref into a shasum. + """ + + command = ["git", "rev-parse", rev] + try: + return self.check_output(command, stderr=sp.STDOUT).decode().strip() + except sp.CalledProcessError: + return None + + def rev_list(self, rev: str, path: str = None) -> t.List[str]: + """ + Return a list of all Git revisions, optionally in the specified path. + """ + + command = ["git", "rev-list", rev] + if path: + command += ["--", path] + try: + revlist = self.check_output(command, stderr=sp.STDOUT).decode().strip().split("\n") + except sp.CalledProcessError: + return [] + if revlist == [""]: + revlist = [] + return revlist + + def has_diff(self) -> bool: + """ + Returns #True if the repository has changed files. + """ + + try: + self.check_call(["git", "diff", "--exit-code"], stdout=sp.PIPE) + return False + except sp.CalledProcessError as exc: + if exc.returncode == 1: + return True + raise + + def create_branch(self, name: str, orphan: bool = False, reset: bool = False, ref: t.Optional[str] = None) -> None: + """ + Creates a branch. + """ + + command = ["git", "checkout"] + if orphan: + if ref: + raise ValueError("cannot checkout orphan branch with ref") + command += ["--orphan", name] + else: + command += ["-B" if reset else "-b", name] + if ref: + command += [ref] + + self.check_call(command) + + def checkout(self, ref: str = None, files: t.List[str] = None, quiet: bool = False): + """ + Check out the specified ref or files. + """ + + command = ["git", "checkout"] + if ref: + command += [ref] + if quiet: + command += ["-q"] + if files: + command += ["--"] + files + self.check_call(command) + + def reset(self, ref: str = None, files: t.List[str] = None, hard: bool = False, quiet: bool = False): + """ + Reset to the specified ref or reset the files. + """ + + command = ["git", "reset"] + if ref: + command += [ref] + if quiet: + command += ["-q"] + if files: + command += ["--"] + files + self.check_call(command) + + def get_commit_message(self, rev: str) -> str: + """ + Returns the commit message of the specified *rev*. + """ + + return self.check_output(["git", "log", "-1", rev, "--pretty=%B"]).decode() + + def get_diff(self, files: t.List[str] = None, cached: bool = False): + command = ["git", "--no-pager", "diff", "--color=never"] + if cached: + command += ["--cached"] + if files is not None: + command += ["--"] + files + return self.check_output(command).decode() + + def describe( + self, + all: bool = False, + tags: bool = False, + contains: bool = False, + commitish: t.Optional[str] = None, + ) -> t.Optional[str]: + + command = ["git", "describe"] + if all: + command.append("--all") + if tags: + command.append("--tags") + if contains: + command.append("--contains") + if commitish: + command.append(commitish) + + try: + return self.check_output(command, stderr=sp.DEVNULL).decode().strip() + except FileNotFoundError: + raise + except sp.CalledProcessError: + return None + + def get_toplevel(self) -> str | None: + """Return the toplevel directory of the Git repository. Returns #None if it does not appear to be a Git repo.""" + + try: + return self.check_output(["git", "rev-parse", "--show-toplevel"], sp.PIPE).decode().strip() + except sp.CalledProcessError as exc: + if "not a git repository" in exc.stderr.decode(): + return None + raise + + def get_files(self) -> t.List[str]: + """Returns a list of all the files tracked in the Git repository.""" + + return self.check_output(["git", "ls-files"]).decode().strip().splitlines() + + def get_config(self, option: str, global_: bool = False) -> str | None: + command = ["git", "config", option] + if global_: + command.insert(2, "--global") + return self.check_output(command).decode().strip() + + def get_file_contents(self, file: str, revision: str) -> bytes: + """Returns the contents of a file at the given revision. Raises a #FileNotFoundError if the file did not + exist at the revision.""" + + file = os.path.relpath(file, str(self.path)) + + try: + return self.check_output(["git", "show", f"{revision}:{file}"], stderr=sp.PIPE) + except sp.CalledProcessError as exc: + stderr = exc.stderr.decode() + if "does not exist" in stderr or "exists on disk, but not in" in stderr: + raise FileNotFoundError(file) + raise diff --git a/src/slap/util/logging.py b/src/slap/util/logging.py new file mode 100644 index 00000000..1f00d039 --- /dev/null +++ b/src/slap/util/logging.py @@ -0,0 +1,50 @@ +""" Provides a logging formatter that understands color hints in the message and decorates it with """ + +from __future__ import annotations + +import logging + +import typing_extensions as te + +from slap.util.notset import NotSet +from slap.util.terminal import StyleManager + + +def get_default_styles() -> StyleManager: + manager = StyleManager() + manager.add_style("info", "blue") + manager.add_style("warning", "magenta") + manager.add_style("error", "red") + manager.add_style("critical", "bright red", None, "bold,underline") + return manager + + +class TerminalColorFormatter(logging.Formatter): + """A formatter that enhances text decorated with HTML-style tags with ANSI terminal colors. It can also be + configured to eliminate the HTML tags instead of converting them to terminal styles.""" + + def __init__(self, fmt: str, styles: StyleManager | None | NotSet = NotSet.Value) -> None: + super().__init__(fmt) + self.styles = get_default_styles() if styles is NotSet.Value else styles + + def format(self, record: logging.LogRecord) -> str: + message = super().format(record) + if self.styles is None: + return StyleManager.strip_tags(message) + else: + return self.styles.format(message, True) + + def install(self, target: te.Literal["tty", "notty"] | None = None) -> None: + """Install the formatter on stream handlers on all handlers of the root logger that are attached to a TTY, + or otherwise on all that are not attached to a TTY based on the *target* value. If no value is specified, it + will install into TTY-attached stream handlers if #styles is set.""" + + if target is None: + target = "notty" if self.styles is None else "tty" + + for handler in logging.root.handlers: + if isinstance(handler, logging.StreamHandler) and handler.stream.isatty(): + if target == "tty": + handler.setFormatter(self) + elif target == "notty": + handler.setFormatter(self) diff --git a/src/slap/util/notset.py b/src/slap/util/notset.py new file mode 100644 index 00000000..6052c715 --- /dev/null +++ b/src/slap/util/notset.py @@ -0,0 +1,7 @@ +import enum + + +class NotSet(enum.Enum): + "A type to include in a union where `None` is a valid value and needs to be differentiated from 'not present'." + + Value = 0 diff --git a/src/slap/util/once.py b/src/slap/util/once.py new file mode 100644 index 00000000..75ca3e60 --- /dev/null +++ b/src/slap/util/once.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +import typing as t + +from .supplier import Supplier, T_co + + +class Once(t.Generic[T_co]): + def __init__(self, supplier: Supplier[T_co]) -> None: + self._supplier = supplier + self._cached: bool = False + self._value: T_co | None = None + + def __repr__(self) -> str: + return f"Once({self._supplier!r})" + + def __bool__(self) -> bool: + return self._cached + + def __call__(self) -> T_co: + if not self._cached: + self._value = self._supplier() + self._cached = True + return t.cast(T_co, self._value) + + def get(self, resupply: bool = False) -> T_co: + if resupply: + self._cached = False + return self() diff --git a/src/slap/util/orderedset.py b/src/slap/util/orderedset.py new file mode 100644 index 00000000..39e68772 --- /dev/null +++ b/src/slap/util/orderedset.py @@ -0,0 +1,67 @@ +import collections +import functools +import typing as t + +T = t.TypeVar("T") +T_OrderedSet = t.TypeVar("T_OrderedSet", bound="OrderedSet") + + +@functools.total_ordering +class OrderedSet(t.MutableSet[T]): + def __init__(self, iterable: t.Optional[t.Iterable[T]] = None) -> None: + self._index_map: t.Dict[T, int] = {} + self._content: t.Deque[T] = collections.deque() + if iterable is not None: + self.update(iterable) + + def __repr__(self) -> str: + if not self._content: + return "%s()" % (type(self).__name__,) + return "%s(%r)" % (type(self).__name__, list(self)) + + def __iter__(self) -> t.Iterator[T]: + return iter(self._content) + + def __reversed__(self) -> "OrderedSet[T]": + return OrderedSet(reversed(self._content)) + + def __eq__(self, other: t.Any) -> bool: + if isinstance(other, OrderedSet): + return len(self) == len(other) and list(self) == list(other) + return False + + def __le__(self, other: t.Any) -> bool: + return all(e in other for e in self) + + def __len__(self) -> int: + return len(self._content) + + def __contains__(self, key: t.Any) -> bool: + return key in self._index_map + + def __getitem__(self, index: int) -> T: + return self._content[index] + + def add(self, key: T) -> None: + if key not in self._index_map: + self._index_map[key] = len(self._content) + self._content.append(key) + + def copy(self: T_OrderedSet) -> "T_OrderedSet": + return type(self)(self) + + def discard(self, key: T) -> None: + if key in self._index_map: + index = self._index_map.pop(key) + del self._content[index] + + def pop(self, last: bool = True) -> T: + if not self._content: + raise KeyError("set is empty") + key = self._content.pop() if last else self._content.popleft() + self._index_map.pop(key) + return key + + def update(self, iterable: t.Iterable[T]) -> None: + for x in iterable: + self.add(x) diff --git a/src/slap/util/plugins.py b/src/slap/util/plugins.py new file mode 100644 index 00000000..58531acf --- /dev/null +++ b/src/slap/util/plugins.py @@ -0,0 +1,95 @@ +""" Helpers to implement a plugin infrastructure in Python. """ + +from __future__ import annotations + +import logging +import typing as t + +import pkg_resources +import typing_extensions as te +from nr.util.generic import T + +logger = logging.getLogger(__name__) + +# NOTE (@NiklasRosenstein): I wished we could use a TypeVar bound to a protocol instead of T, but mypy does +# not seem to like it. + + +class NoSuchEntrypointError(RuntimeError): + pass + + +@t.overload +def load_entrypoint(group: str, name: str) -> t.Any: + ... + + +@t.overload +def load_entrypoint(group: type[T], name: str) -> type[T]: + ... + + +def load_entrypoint(group: str | type[T], name: str) -> t.Any | type[T]: + """Load a single entrypoint value. Raises a #RuntimeError if no such entrypoint exists.""" + + if isinstance(group, type): + group_name = group.ENTRYPOINT # type: ignore + else: + group_name = group + + for ep in pkg_resources.iter_entry_points(group_name, name): + value = ep.load() + break + else: + raise NoSuchEntrypointError(f'no entrypoint "{name}" in group "{group}"') + + if isinstance(group, type): + if not isinstance(value, type): + raise TypeError(f'entrypoint "{name}" in group "{group}" is not a type (found "{type(value).__name__}")') + if not issubclass(value, group): # type: ignore + raise TypeError(f'entrypoint "{name}" in group "{group}" is not a subclass of {group.__name__}') + + return value + + +_Iter_Entrypoints_1: te.TypeAlias = "t.Iterator[pkg_resources.EntryPoint]" +_Iter_Entrypoints_2: te.TypeAlias = "t.Iterator[tuple[str, t.Callable[[], type[T]]]]" + + +@t.overload +def iter_entrypoints(group: str) -> _Iter_Entrypoints_1: + ... + + +@t.overload +def iter_entrypoints(group: type[T]) -> _Iter_Entrypoints_2: + ... + + +def iter_entrypoints(group: str | type[T]) -> _Iter_Entrypoints_1 | _Iter_Entrypoints_2: + """Loads all entrypoints from the given group.""" + + if isinstance(group, type): + group_name = group.ENTRYPOINT # type: ignore + else: + group_name = group + + def _make_loader(ep: pkg_resources.EntryPoint) -> t.Callable[[], type[T]]: + def loader(): + assert isinstance(group, type) + value = ep.load() + if not isinstance(value, type): + raise TypeError( + f'entrypoint "{ep.name}" in group "{group}" is not a type (found "{type(value).__name__}")' + ) + if not issubclass(value, group): # type: ignore + raise TypeError(f'entrypoint "{ep.name}" in group "{group}" is not a subclass of {group.__name__}') + return value + + return loader + + for ep in pkg_resources.iter_entry_points(group_name): + if isinstance(group, type): + yield ep.name, _make_loader(ep) + else: + yield ep diff --git a/src/slap/util/supplier.py b/src/slap/util/supplier.py new file mode 100644 index 00000000..e6abf690 --- /dev/null +++ b/src/slap/util/supplier.py @@ -0,0 +1,7 @@ +from __future__ import annotations + +from typing import Callable, TypeVar + +T_co = TypeVar("T_co", covariant=True) +T_Supplier = TypeVar("T_Supplier", bound="Supplier") +Supplier = Callable[[], T_co] diff --git a/src/slap/util/terminal.py b/src/slap/util/terminal.py new file mode 100644 index 00000000..d1cdc8dd --- /dev/null +++ b/src/slap/util/terminal.py @@ -0,0 +1,298 @@ +from __future__ import annotations + +import abc +import dataclasses +import enum +import re +import typing as t + + +class Attribute(enum.Enum): + RESET = 0 + BOLD = 1 + FAINT = 2 + ITALIC = 3 + UNDERLINE = 4 + SLOW_BLINK = 5 + RAPID_BLINK = 6 + REVERSE_VIDEO = 7 + CONCEAL = 8 + CROSSED_OUT = 9 + FONT_0 = 10 + FONT_1 = 11 + FONT_2 = 12 + FONT_3 = 13 + FONT_4 = 14 + FONT_5 = 15 + FONT_6 = 16 + FONT_7 = 17 + FONT_8 = 18 + FONT_9 = 19 + FRAKTUR = 20 + DOUBLY_UNDERLINE = 21 + NORMAL_INTENSITY = 22 + NOT_ITALIC = 23 + UNDERLINE_OFF = 24 + BLINK_OFF = 25 + REVERSE_OFF = 27 + REVEAL = 28 + CROSSED_OUT_OFF = 29 + FRAMED = 51 + ENCIRCLED = 52 + OVERLINED = 53 + FRAMED_OFF = 54 + OVERLINED_OFF = 55 + + +class Color(abc.ABC): + @abc.abstractmethod + def as_foreground(self) -> str: + ... + + @abc.abstractmethod + def as_background(self) -> str: + ... + + +class SgrColorName(enum.Enum): + BLACK = 0 + RED = 1 + GREEN = 2 + YELLOW = 3 + BLUE = 4 + MAGENTA = 5 + CYAN = 6 + WHITE = 7 + GRAY = 8 + DEFAULT = 9 + + +@dataclasses.dataclass +class SgrColor(Color): + """Represents a color from the SGR space (see #SgrColorName).""" + + name: SgrColorName + bright: bool + + def __init__(self, name: SgrColorName, bright: bool = False) -> None: + if isinstance(name, str): + name = SgrColorName[name.upper()] + self.name = name + self.bright = bright + + def as_foreground(self) -> str: + return str((90 if self.bright else 30) + self.name.value) + + def as_background(self) -> str: + return str((100 if self.bright else 40) + self.name.value) + + +@dataclasses.dataclass +class LutColor(Color): + """Represents a LUT color, which is one of 216 colors.""" + + index: int + + def as_foreground(self) -> str: + return "38;5;" + str(self.index) + + def as_background(self) -> str: + return "48;5;" + str(self.index) + + @classmethod + def from_rgb(cls, r: int, g: int, b: int) -> "LutColor": + """ + Given RGB values in the range of [0..5], returns a #LutColor pointing + to the color index that resembles the specified color coordinates. + """ + + def _check_range(name, value): + if not (0 <= value < 6): + raise ValueError('bad value for parameter "{}": {} ∉ [0..5]'.format(name, value)) + + _check_range("r", r) + _check_range("g", g) + _check_range("b", b) + + return cls((16 + 36 * r) + (6 * g) + b) + + +class TrueColor(Color): + """Represents a true color comprised of three color components.""" + + r: int + g: int + b: int + + def as_foreground(self) -> str: + return "38;2;{};{};{}".format(self.r, self.g, self.b) + + def as_background(self) -> str: + return "48;2;{};{};{}".format(self.r, self.g, self.b) + + +def parse_color(color_string: str) -> Color: + """Parses a color string of one of the following formats and returns a corresponding #SgrColor, #LutColor or + #TrueColor. + + * ``, `BRIGHT_`: #SgrColor (case insensitive, underline optional) + * `%rgb`, `$xxx`: #LutColor + * `#cef`, `#cceeff`: #TrueColor + """ + + if color_string.startswith("%") and len(color_string) == 4: + try: + r, g, b = map(int, color_string[1:]) + except ValueError: + pass + else: + if r < 6 and g < 6 and b < 6: + return LutColor.from_rgb(r, g, b) + + elif color_string.startswith("$") and len(color_string) <= 4: + try: + index = int(color_string[1:]) + except ValueError: + pass + else: + if index >= 0 and index < 256: + return LutColor(index) + + elif color_string.startswith("#") and len(color_string) in (4, 7): + parts = re.findall("." if len(color_string) == 4 else "..", color_string[1:]) + if len(color_string) == 4: + parts = [x * 2 for x in parts] + try: + parts = [int(x, 16) for x in parts] + except ValueError: + pass + else: + return TrueColor(*parts) + + else: + color_string = color_string.upper() + bright = color_string.startswith("BRIGHT_") or color_string.startswith("BRIGHT ") + if bright: + color_string = color_string[7:] + if hasattr(SgrColorName, color_string): + return SgrColor(SgrColorName[color_string], bright) + + raise ValueError("unrecognizable color string: {!r}".format(color_string)) + + +@dataclasses.dataclass +class Style: + """A style is a combination of foreground and background color, as well as a list of attributes.""" + + RESET: t.ClassVar[Style] + + fg: Color | None = None + bg: Color | None = None + attrs: list[Attribute] | None = None + + def __init__( + self, + fg: Color | str | None = None, + bg: Color | str | None = None, + attrs: t.Sequence[Attribute | str] | str | None = None, + ) -> None: + """The constructor allows you to specify all arguments also as strings. The foreground and background are parsed + with #parse_color(). The *attrs* can be a comma-separated string.""" + + if isinstance(fg, str): + fg = parse_color(fg) + if isinstance(bg, str): + bg = parse_color(bg) + if isinstance(attrs, str): + attrs = [x.strip() for x in attrs.split(",") if x.strip()] + self.fg = fg + self.bg = bg + if attrs is None: + self.attrs = None + else: + self.attrs = [] + for attr in attrs: + if isinstance(attr, str): + self.attrs.append(Attribute[attr.upper()]) + else: + self.attrs.append(attr) + + def to_escape_sequence(self) -> str: + seq = [] + if self.fg: + seq.append(self.fg.as_foreground()) + if self.bg: + seq.append(self.bg.as_background()) + seq.extend(str(attr.value) for attr in self.attrs or ()) + return "\033[" + ";".join(seq) + "m" + + +Style.RESET = Style(attrs="reset") + + +class StyleManager: + """Allows you to register styles and format text using HTML-style tags.""" + + TAG_EXPR = r"<([^>=]+)([^>]*)>(.*?)" + + def __init__(self) -> None: + self._styles: dict[str, Style] = {} + + def add_style( + self, + name: str, + fg: Color | str | None = None, + bg: Color | str | None = None, + attrs: list[Attribute | str] | str | None = None, + ) -> None: + self._styles[name] = Style(fg, bg, attrs) + + def parse_style(self, style_string: str, safe: bool = False) -> Style: + """Parses a style string that is valid inside an opening HTML-style tag accepted in strings by #format().""" + + parts = style_string.split(";") + style: Style = Style() + for part in parts: + try: + if part.startswith("fg="): + style = Style(parse_color(part[3:]), style.bg, style.attrs) + elif part.startswith("bg="): + style = Style(style.fg, parse_color(part[3:]), style.attrs) + elif part.startswith("attr="): + style = Style(style.fg, style.bg, (style.attrs or []) + [Attribute[part[5:].upper()]]) + else: + style = self._styles[part] + except (ValueError, KeyError): + if not safe: + raise + + return style + + def format(self, text: str, safe: bool = False, repl: t.Callable[[str, str], str] | None = None) -> str: + """Formats text that contains HTML-style tags that represent styles in the style manager. In addition, special + tags ``, `` or ` can be used to manually specify the exact styling of the + text and the can be combined (such as ``). If *safe* is set to `True`, tags + referencing styles that are unknown to the manager are ignored.""" + + def _regex_sub(m: re.Match) -> str: + style_string = m.group(1) + m.group(2) + content = m.group(3) + if repl is None: + style = self.parse_style(style_string, safe) + return style.to_escape_sequence() + content + Style.RESET.to_escape_sequence() + else: + return repl(style_string, content) + + upper_limit = 15 + prev_text = text + for _ in range(upper_limit): + text = re.sub(self.TAG_EXPR, _regex_sub, text, flags=re.S | re.M) + if prev_text == text: + break + prev_text = text + + return prev_text + + @classmethod + def strip_tags(cls, text: str) -> str: + return cls().format(text, True, lambda _, s: s) diff --git a/src/slap/util/text.py b/src/slap/util/text.py new file mode 100644 index 00000000..520575af --- /dev/null +++ b/src/slap/util/text.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import io +import typing as t + +T = t.TypeVar("T") +SubstRange = t.Tuple[int, int, str] + + +def substitute_ranges(text: str, ranges: t.Iterable[SubstRange], is_sorted: bool = False) -> str: + """Replaces parts of *text* using the specified *ranges* and returns the new text. Ranges must not overlap. + *is_sorted* can be set to `True` if the input *ranges* are already sorted from lowest to highest starting index to + optimize the function. + """ + + if not is_sorted: + ranges = sorted(ranges, key=lambda x: x[0]) + + out = io.StringIO() + max_end_index = 0 + for index, (istart, iend, subst) in enumerate(ranges): + if iend < istart: + raise ValueError(f"invalid range at index {index}: (istart: {istart!r}, iend: {iend!r})") + if istart < max_end_index: + raise ValueError(f"invalid range at index {index}: overlap with previous range") + + subst = str(subst) + out.write(text[max_end_index:istart]) + out.write(subst) + max_end_index = iend + + out.write(text[max_end_index:]) + return out.getvalue() + + +def longest_common_substring( + seq1: t.Sequence[T], + seq2: t.Sequence[T], + *args: t.Sequence[T], + start_only: bool = False, +) -> t.Sequence[T]: + """Finds the longest common contiguous sequence of elements in *seq1* and *seq2* and returns it.""" + + longest: tuple[int, int] = (0, 0) + for i in (0,) if start_only else range(len(seq1)): + for j in (0,) if start_only else range(len(seq2)): + k = 0 + while (i + k < len(seq1) and (j + k) < len(seq2)) and seq1[i + k] == seq2[j + k]: + k += 1 + if k > longest[1] - longest[0]: + longest = (i, i + k) + + result = seq1[longest[0] : longest[1]] + if args: + result = longest_common_substring(result, *args, start_only=start_only) + return result diff --git a/src/slap/util/toml_file.py b/src/slap/util/toml_file.py index 2697012c..9af95464 100644 --- a/src/slap/util/toml_file.py +++ b/src/slap/util/toml_file.py @@ -3,7 +3,7 @@ import typing as t from pathlib import Path -from nr.util.generic import T +T = t.TypeVar("T") class TomlFile(t.MutableMapping[str, t.Any]): diff --git a/src/slap/util/url.py b/src/slap/util/url.py new file mode 100644 index 00000000..eb680aa5 --- /dev/null +++ b/src/slap/util/url.py @@ -0,0 +1,74 @@ +""" Tools for URL handling. """ + +from __future__ import annotations + +import dataclasses +import urllib.parse + + +@dataclasses.dataclass +class Url: + """Helper to represent the components of a URL, including first class support for username, password, host + and port.""" + + scheme: str = "" + hostname: str = "" + path: str = "" + params: str = "" + query: str = "" + fragment: str = "" + + username: str | None = None + password: str | None = None + port: int | None = None + + def __str__(self) -> str: + return urllib.parse.urlunparse((self.scheme, self.netloc, self.path, self.params, self.query, self.fragment)) + + @property + def netloc(self) -> str: + """Returns the entire network location with auth and port.""" + + auth = self.auth + if auth: + return f"{self.auth}@{self.netloc_no_auth}" + + return self.netloc_no_auth + + @property + def auth(self) -> str | None: + """Returns just the auth part of the network location.""" + + if self.username or self.password: + return f'{urllib.parse.quote(self.username or "")}:{urllib.parse.quote(self.password or "")}' + + return None + + @property + def netloc_no_auth(self) -> str: + """Returns the network location without the auth part.""" + + if self.port is None: + return self.hostname + + return f"{self.hostname}:{self.port}" + + @staticmethod + def of(url: str) -> Url: + """Parses the *url* string into its parts. + + Raises: + ValueError: If an invalid URL is passed (for example if the port number cannot be parsed to an integer). + """ + parsed = urllib.parse.urlparse(url) + return Url( + scheme=parsed.scheme, + hostname=parsed.hostname or "", + path=parsed.path, + params=parsed.params, + query=parsed.query, + fragment=parsed.fragment, + username=parsed.username, + password=parsed.password, + port=parsed.port, + ) diff --git a/src/slap/util/vcs.py b/src/slap/util/vcs.py index 3fed6464..0bf07064 100644 --- a/src/slap/util/vcs.py +++ b/src/slap/util/vcs.py @@ -5,9 +5,10 @@ import typing as t from pathlib import Path -from nr.util.functional import Consumer -from nr.util.generic import T -from nr.util.git import Git as _Git, NoCurrentBranchError +from slap.util.git import Git as _Git, NoCurrentBranchError + +T = t.TypeVar("T") +Consumer = t.Callable[[T], t.Any] class FileStatus(enum.Enum): diff --git a/src/slap/util/weak_property.py b/src/slap/util/weak_property.py new file mode 100644 index 00000000..3ed99ac9 --- /dev/null +++ b/src/slap/util/weak_property.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import weakref +from typing import Any, Generic, Literal, Optional, TypeVar, cast, overload + +T = TypeVar("T") + + +class WeakProperty(Generic[T]): + def __init__(self, attr_name: str, once: bool = False) -> None: + self._name = attr_name + self._once = once + self._value: Optional[weakref.ReferenceType[T]] = None + + def __set__(self, instance: Any, value: T) -> None: + has_value: Optional[weakref.ReferenceType[T]] = getattr(instance, self._name, None) + if self._once and has_value is not None: + raise RuntimeError("property can not be set more than once") + setattr(instance, self._name, weakref.ref(value)) + + def __get__(self, instance: Any, owner: Any) -> T: + if instance is None: + raise AttributeError() + has_value: Optional[weakref.ReferenceType[T]] = getattr(instance, self._name, None) + if has_value is None: + raise AttributeError("property value is not set") + value = has_value() + if value is None: + raise RuntimeError("lost weak reference") + return value + + +class OptionalWeakProperty(WeakProperty[Optional[T]]): + def __set__(self, instance: Any, value: Optional[T]) -> None: + has_value: Optional[weakref.ReferenceType[T]] = getattr(instance, self._name, None) + if self._once and has_value is not None: + raise RuntimeError("property can not be set more than once") + setattr(instance, self._name, weakref.ref(value) if value is not None else None) + + def __get__(self, instance: Any, owner: Any) -> Optional[T]: + if instance is None: + raise AttributeError() + try: + return super().__get__(instance, owner) + except AttributeError: + return None + + +@overload +def weak_property(attr_name: str, once: bool = False, optional: Literal[False] = False) -> T: + ... + + +@overload +def weak_property(attr_name: str, once: bool = False, optional: Literal[True] = True) -> T | None: + ... + + +def weak_property(attr_name: str, once: bool = False, optional: bool = False) -> T | None: + return cast(T, WeakProperty(attr_name, once) if optional else OptionalWeakProperty(attr_name, once)) From c65311bedbf565855c17761b9c00ce9a22f1f915 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sat, 27 Jan 2024 02:19:42 +0000 Subject: [PATCH 09/22] Updated PR references in 1 changelogs. skip-checks: true --- .changelog/_unreleased.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/.changelog/_unreleased.toml b/.changelog/_unreleased.toml index fd9641d2..27e6a514 100644 --- a/.changelog/_unreleased.toml +++ b/.changelog/_unreleased.toml @@ -3,3 +3,4 @@ id = "69eb006e-0206-4aea-87fa-69bf2000a3f3" type = "improvement" description = "Get rid of deprecated `nr.util` dependency by placing all the required bits of code into Slap itself" author = "@NiklasRosenstein" +pr = "https://github.com/NiklasRosenstein/slap/pull/108" From 23447c7ba9b9727afd84940987362471f9028732 Mon Sep 17 00:00:00 2001 From: Niklas Rosenstein Date: Sat, 27 Jan 2024 03:19:56 +0100 Subject: [PATCH 10/22] indent --- src/slap/python/environment.py | 42 +++++++++++++++++----------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/src/slap/python/environment.py b/src/slap/python/environment.py index 795dcade..cc4fee67 100644 --- a/src/slap/python/environment.py +++ b/src/slap/python/environment.py @@ -47,10 +47,10 @@ def has_importlib_metadata(self) -> bool: if self._has_pkg_resources is None: code = textwrap.dedent( """ - try: import importlib.metadata - except ImportError: print('false') - else: print('true') - """ + try: import importlib.metadata + except ImportError: print('false') + else: print('true') + """ ) self._has_pkg_resources = json.loads(sp.check_output([self.executable, "-c", code]).decode()) return self._has_pkg_resources @@ -77,23 +77,23 @@ def of(python: str | t.Sequence[str]) -> "PythonEnvironment": code = textwrap.dedent( f""" - import sys, platform, json, pickle - sys.path.append({pep508_path!r}) - import pep508 - try: import importlib.metadata as metadata - except ImportError: metadata = None - print(json.dumps({{ - "executable": sys.executable, - "version": sys.version, - "version_tuple": sys.version_info[:3], - "platform": platform.platform(), - "prefix": sys.prefix, - "base_prefix": getattr(sys, 'base_prefix', None), - "real_prefix": getattr(sys, 'real_prefix', None), - "pep508": pep508.Pep508Environment.current().as_json(), - "_has_pkg_resources": metadata is not None, - }})) - """ + import sys, platform, json, pickle + sys.path.append({pep508_path!r}) + import pep508 + try: import importlib.metadata as metadata + except ImportError: metadata = None + print(json.dumps({{ + "executable": sys.executable, + "version": sys.version, + "version_tuple": sys.version_info[:3], + "platform": platform.platform(), + "prefix": sys.prefix, + "base_prefix": getattr(sys, 'base_prefix', None), + "real_prefix": getattr(sys, 'real_prefix', None), + "pep508": pep508.Pep508Environment.current().as_json(), + "_has_pkg_resources": metadata is not None, + }})) + """ ) payload = json.loads(sp.check_output(list(python) + ["-c", code]).decode()) From dc26ff638b3dbfc754d4c5b778ee0abfca373a4d Mon Sep 17 00:00:00 2001 From: Niklas Rosenstein Date: Sat, 27 Jan 2024 03:21:41 +0100 Subject: [PATCH 11/22] improvement: Add `Once.flush()` to use that instead of `.get(resupply=True)` --- .changelog/_unreleased.toml | 6 ++++++ src/slap/project.py | 5 ++--- src/slap/util/once.py | 3 +++ src/slap/util/plugins.py | 3 ++- 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/.changelog/_unreleased.toml b/.changelog/_unreleased.toml index 27e6a514..b5b0b61c 100644 --- a/.changelog/_unreleased.toml +++ b/.changelog/_unreleased.toml @@ -4,3 +4,9 @@ type = "improvement" description = "Get rid of deprecated `nr.util` dependency by placing all the required bits of code into Slap itself" author = "@NiklasRosenstein" pr = "https://github.com/NiklasRosenstein/slap/pull/108" + +[[entries]] +id = "a685b161-8d42-435d-8edd-bdb9be130454" +type = "improvement" +description = "Add `Once.flush()` to use that instead of `.get(resupply=True)`" +author = "@NiklasRosenstein" diff --git a/src/slap/project.py b/src/slap/project.py index 12542bab..10fa82b3 100644 --- a/src/slap/project.py +++ b/src/slap/project.py @@ -196,9 +196,8 @@ def add_dependency(self, dependency: Dependency, where: str) -> None: assert isinstance(dependency, Dependency), type(dependency) self.handler().add_dependency(self, dependency, where) - # TODO(@NiklasRosenstein): Use a method to flush the cache of Once when it is available in `nr.utils`. - self.raw_config.get(True) - self.dependencies.get(True) + self.raw_config.flush() + self.dependencies.flush() @property def id(self) -> str: # type: ignore[override] diff --git a/src/slap/util/once.py b/src/slap/util/once.py index 75ca3e60..0a1e2be1 100644 --- a/src/slap/util/once.py +++ b/src/slap/util/once.py @@ -23,6 +23,9 @@ def __call__(self) -> T_co: self._cached = True return t.cast(T_co, self._value) + def flush(self) -> None: + self._cached = False + def get(self, resupply: bool = False) -> T_co: if resupply: self._cached = False diff --git a/src/slap/util/plugins.py b/src/slap/util/plugins.py index 58531acf..99be130b 100644 --- a/src/slap/util/plugins.py +++ b/src/slap/util/plugins.py @@ -7,7 +7,8 @@ import pkg_resources import typing_extensions as te -from nr.util.generic import T + +T = t.TypeVar("T") logger = logging.getLogger(__name__) From c4bc07fd025e17d3445d0c8fa75371f50d92e689 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sat, 27 Jan 2024 02:22:08 +0000 Subject: [PATCH 12/22] Updated PR references in 1 changelogs. skip-checks: true --- .changelog/_unreleased.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/.changelog/_unreleased.toml b/.changelog/_unreleased.toml index b5b0b61c..07bd05ed 100644 --- a/.changelog/_unreleased.toml +++ b/.changelog/_unreleased.toml @@ -10,3 +10,4 @@ id = "a685b161-8d42-435d-8edd-bdb9be130454" type = "improvement" description = "Add `Once.flush()` to use that instead of `.get(resupply=True)`" author = "@NiklasRosenstein" +pr = "https://github.com/NiklasRosenstein/slap/pull/108" From 800daece7676af868c37a5f1e7c32f60203eeaab Mon Sep 17 00:00:00 2001 From: Niklas Rosenstein Date: Sat, 27 Jan 2024 03:26:23 +0100 Subject: [PATCH 13/22] improvement: Use `importlib-metadata` package over `pkg_resources` --- .changelog/_unreleased.toml | 6 ++++++ src/slap/util/plugins.py | 10 +++++----- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/.changelog/_unreleased.toml b/.changelog/_unreleased.toml index 07bd05ed..fb7c6176 100644 --- a/.changelog/_unreleased.toml +++ b/.changelog/_unreleased.toml @@ -11,3 +11,9 @@ type = "improvement" description = "Add `Once.flush()` to use that instead of `.get(resupply=True)`" author = "@NiklasRosenstein" pr = "https://github.com/NiklasRosenstein/slap/pull/108" + +[[entries]] +id = "712b2efd-32e6-4ed6-b131-4669d36fc951" +type = "improvement" +description = "Use `importlib-metadata` package over `pkg_resources`" +author = "@NiklasRosenstein" diff --git a/src/slap/util/plugins.py b/src/slap/util/plugins.py index 99be130b..e9016372 100644 --- a/src/slap/util/plugins.py +++ b/src/slap/util/plugins.py @@ -5,7 +5,7 @@ import logging import typing as t -import pkg_resources +import importlib_metadata import typing_extensions as te T = t.TypeVar("T") @@ -38,7 +38,7 @@ def load_entrypoint(group: str | type[T], name: str) -> t.Any | type[T]: else: group_name = group - for ep in pkg_resources.iter_entry_points(group_name, name): + for ep in importlib_metadata.entry_points(group=group_name, name=name): value = ep.load() break else: @@ -53,7 +53,7 @@ def load_entrypoint(group: str | type[T], name: str) -> t.Any | type[T]: return value -_Iter_Entrypoints_1: te.TypeAlias = "t.Iterator[pkg_resources.EntryPoint]" +_Iter_Entrypoints_1: te.TypeAlias = "t.Iterator[importlib_metadata.EntryPoint]" _Iter_Entrypoints_2: te.TypeAlias = "t.Iterator[tuple[str, t.Callable[[], type[T]]]]" @@ -75,7 +75,7 @@ def iter_entrypoints(group: str | type[T]) -> _Iter_Entrypoints_1 | _Iter_Entryp else: group_name = group - def _make_loader(ep: pkg_resources.EntryPoint) -> t.Callable[[], type[T]]: + def _make_loader(ep: importlib_metadata.EntryPoint) -> t.Callable[[], type[T]]: def loader(): assert isinstance(group, type) value = ep.load() @@ -89,7 +89,7 @@ def loader(): return loader - for ep in pkg_resources.iter_entry_points(group_name): + for ep in importlib_metadata.entry_points(group=group_name): if isinstance(group, type): yield ep.name, _make_loader(ep) else: From 19e58e8e48670a81fa018f85ce1eef5001ce43f1 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sat, 27 Jan 2024 02:27:29 +0000 Subject: [PATCH 14/22] Updated PR references in 1 changelogs. skip-checks: true --- .changelog/_unreleased.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/.changelog/_unreleased.toml b/.changelog/_unreleased.toml index fb7c6176..06b6c1f8 100644 --- a/.changelog/_unreleased.toml +++ b/.changelog/_unreleased.toml @@ -17,3 +17,4 @@ id = "712b2efd-32e6-4ed6-b131-4669d36fc951" type = "improvement" description = "Use `importlib-metadata` package over `pkg_resources`" author = "@NiklasRosenstein" +pr = "https://github.com/NiklasRosenstein/slap/pull/108" From 86c3dcfd332d32cab11d788c07e68939ed654aea Mon Sep 17 00:00:00 2001 From: Niklas Rosenstein Date: Sat, 27 Jan 2024 03:31:36 +0100 Subject: [PATCH 15/22] remove selftest.yaml --- .github/workflows/selftest.yaml | 29 ----------------------------- 1 file changed, 29 deletions(-) delete mode 100644 .github/workflows/selftest.yaml diff --git a/.github/workflows/selftest.yaml b/.github/workflows/selftest.yaml deleted file mode 100644 index 09f67774..00000000 --- a/.github/workflows/selftest.yaml +++ /dev/null @@ -1,29 +0,0 @@ -name: "Selftest" - -on: - push: - branches: [ "develop" ] - tags: [ '*' ] - pull_request: - branches: [ "develop" ] - -jobs: - test: - runs-on: ubuntu-20.04 - strategy: - matrix: - python-version: ["3.10", "3.11"] - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 - with: { python-version: "${{ matrix.python-version }}" } - - run: pip install pipx && pipx install . --pip-args=-vvv --verbose && which slap - - run: echo $PIPX_HOME // $PIPX_BIN_DIR && ls $PIPX_HOME - - run: ls $PIPX_HOME/venvs - - run: $PIPX_HOME/venvs/slap-cli/bin/python -m pip freeze - - run: slap info - - run: slap test - - run: slap publish --dry - - uses: actions/setup-python@v2 - with: { python-version: "3.9" } - - run: slap info From daa65fa5a21aad8267dfbb4254a32179dc5ef144 Mon Sep 17 00:00:00 2001 From: Niklas Rosenstein Date: Sat, 27 Jan 2024 03:32:00 +0100 Subject: [PATCH 16/22] also test 3.12 --- .github/workflows/python.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 88f188e8..8d001566 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-20.04 strategy: matrix: - python-version: ["3.10", "3.11"] + python-version: ["3.10", "3.11", "3.12"] steps: - uses: actions/checkout@v2 - uses: actions/setup-python@v2 From 58ad5e9959ab19c7432be05362be944ec1dc95b0 Mon Sep 17 00:00:00 2001 From: Niklas Rosenstein Date: Sat, 27 Jan 2024 03:32:50 +0100 Subject: [PATCH 17/22] allow 3.12? --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 2803e8b1..e23995e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ Homepage = "https://github.com/NiklasRosenstein/slap" Repository = "https://github.com/NiklasRosenstein/slap.git" [tool.poetry.dependencies] -python = ">=3.10,<3.12" +python = ">=3.10,<3.13" beautifulsoup4 = "^4.10.0" cleo = ">=1.0.0a4" "databind" = "^4.4.0" From 75e3ac39409c755533cc677eecee4c9373ab1a33 Mon Sep 17 00:00:00 2001 From: Niklas Rosenstein Date: Sat, 27 Jan 2024 03:37:01 +0100 Subject: [PATCH 18/22] upgrade dev dependencies --- pyproject.toml | 10 +++++----- src/slap/changelog.py | 9 +++------ src/slap/ext/application/info.py | 10 ++++++---- src/slap/ext/application/install.py | 6 ++---- src/slap/ext/checks/changelog.py | 14 ++++++++------ src/slap/ext/checks/release.py | 8 +++++--- src/slap/install/installer.py | 9 +++------ src/slap/plugins.py | 15 +++++---------- src/slap/release.py | 6 ++---- src/slap/repository.py | 15 +++++---------- src/slap/util/cleo.py | 6 ++---- src/slap/util/digraph.py | 3 +-- src/slap/util/fs.py | 12 ++++-------- src/slap/util/plugins.py | 12 ++++-------- src/slap/util/terminal.py | 6 ++---- src/slap/util/toml_file.py | 6 ++---- src/slap/util/vcs.py | 6 ++---- src/slap/util/weak_property.py | 6 ++---- 18 files changed, 63 insertions(+), 96 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e23995e5..1b99fd94 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,11 +42,11 @@ gitpython = "^3.1.31" "nr.stream" = "^1.1.5" [tool.poetry.dev-dependencies] -black = "^22.3.0" -flake8 = "^4.0.1" -isort = "^5.10.1" -mypy = "^0.931" -pytest = "^7.1.1" +black = "^24.1.0" +flake8 = "^7.0.0" +isort = "^5.13.1" +mypy = "^1.8.0" +pytest = "^7.4.4" types-beautifulsoup4 = "^4.10.0" types-pygments = "^2.9.16" types-PyYAML = "^6.0.3" diff --git a/src/slap/changelog.py b/src/slap/changelog.py index 95aa135f..96cad234 100644 --- a/src/slap/changelog.py +++ b/src/slap/changelog.py @@ -56,19 +56,16 @@ def find_entry(self, entry_id: str) -> ChangelogEntry | None: class ChangelogDeser(abc.ABC): @abc.abstractmethod - def load(self, fp: t.TextIO, filename: str) -> Changelog: - ... + def load(self, fp: t.TextIO, filename: str) -> Changelog: ... def save(self, changelog: Changelog, fp: t.TextIO, filename: str) -> None: fp.write(self.dump(changelog)) @abc.abstractmethod - def dump(self, changelog: Changelog) -> str: - ... + def dump(self, changelog: Changelog) -> str: ... @abc.abstractmethod - def dump_entry(self, entry: ChangelogEntry) -> str: - ... + def dump_entry(self, entry: ChangelogEntry) -> str: ... class TomlChangelogDeser(ChangelogDeser): diff --git a/src/slap/ext/application/info.py b/src/slap/ext/application/info.py index f1c0adc2..10ffe0de 100644 --- a/src/slap/ext/application/info.py +++ b/src/slap/ext/application/info.py @@ -44,10 +44,12 @@ def handle(self) -> int: packages = ( "none" if packages_list is None - else "[]" - if len(packages_list or []) == 0 - else ", ".join( - f"{p.name} ({os.path.relpath(p.root, project.directory)})" for p in packages_list + else ( + "[]" + if len(packages_list or []) == 0 + else ", ".join( + f"{p.name} ({os.path.relpath(p.root, project.directory)})" for p in packages_list + ) ) ) self.line( diff --git a/src/slap/ext/application/install.py b/src/slap/ext/application/install.py index 779aff06..99297a25 100644 --- a/src/slap/ext/application/install.py +++ b/src/slap/ext/application/install.py @@ -32,13 +32,11 @@ @t.overload -def get_active_python_bin(cmd: Command) -> str: - ... +def get_active_python_bin(cmd: Command) -> str: ... @t.overload -def get_active_python_bin(cmd: Command, fallback: te.Literal[False]) -> str | None: - ... +def get_active_python_bin(cmd: Command, fallback: te.Literal[False]) -> str | None: ... def get_active_python_bin(cmd: Command, fallback: bool = True) -> str | None: diff --git a/src/slap/ext/checks/changelog.py b/src/slap/ext/checks/changelog.py index d0fe2d08..1b394786 100644 --- a/src/slap/ext/checks/changelog.py +++ b/src/slap/ext/checks/changelog.py @@ -45,13 +45,15 @@ def _validate_changelogs(self, project: Project) -> tuple[CheckResult, str | Non ( "\n".join(f"{fn}: {err}" for fn, err in bad_files) if bad_files - else "" - + "\n" - + "\n".join( - f'{fn}: id="{entry_id}": {err}' for fn, err, entry_id in bad_changelogs + else ( + "" + + "\n" + + "\n".join( + f'{fn}: id="{entry_id}": {err}' for fn, err, entry_id in bad_changelogs + ) + if bad_changelogs + else "" ) - if bad_changelogs - else "" ).strip() or None, ) diff --git a/src/slap/ext/checks/release.py b/src/slap/ext/checks/release.py index 8273b34d..1b56e58f 100644 --- a/src/slap/ext/checks/release.py +++ b/src/slap/ext/checks/release.py @@ -38,9 +38,11 @@ def check_packages_have_source_code_version(self, project: Project) -> tuple[Che return ( Check.ERROR if packages_without_version else Check.OK, - (f'The following packages have no __version__: {", ".join(packages_without_version)}') - if packages_without_version - else f'Found __version__ in {", ".join(x.name for x in project.packages() or [])}', + ( + (f'The following packages have no __version__: {", ".join(packages_without_version)}') + if packages_without_version + else f'Found __version__ in {", ".join(x.name for x in project.packages() or [])}' + ), ) @check("consistent-versions") diff --git a/src/slap/install/installer.py b/src/slap/install/installer.py index d5ba1ea3..07007a95 100644 --- a/src/slap/install/installer.py +++ b/src/slap/install/installer.py @@ -96,8 +96,7 @@ class Installer(abc.ABC): """An installer for dependencies into a #PythonEnvironment.""" @abc.abstractmethod - def install(self, dependencies: list[Dependency], target: PythonEnvironment, options: InstallOptions) -> int: - ... + def install(self, dependencies: list[Dependency], target: PythonEnvironment, options: InstallOptions) -> int: ... class SymlinkHelper(t.Protocol): @@ -106,11 +105,9 @@ class SymlinkHelper(t.Protocol): #PathDependency is encountered with #PathDependency.link enabled. """ - def get_dependencies_for_project(self, project: Path) -> list[Dependency]: - ... + def get_dependencies_for_project(self, project: Path) -> list[Dependency]: ... - def link_project(self, project: Path) -> None: - ... + def link_project(self, project: Path) -> None: ... class PipInstaller(Installer): diff --git a/src/slap/plugins.py b/src/slap/plugins.py index 5756ff39..f6982e4d 100644 --- a/src/slap/plugins.py +++ b/src/slap/plugins.py @@ -163,8 +163,7 @@ class VersionIncrementingRulePlugin(abc.ABC): ENTRYPOINT = "slap.plugins.version_incrementing_rule" - def increment_version(self, version: Version) -> Version: - ... + def increment_version(self, version: Version) -> Version: ... class RepositoryCIPlugin(abc.ABC): @@ -179,23 +178,19 @@ class RepositoryCIPlugin(abc.ABC): io: IO @abc.abstractmethod - def initialize(self) -> None: - ... + def initialize(self) -> None: ... @abc.abstractmethod - def get_base_ref(self) -> str: - ... + def get_base_ref(self) -> str: ... def get_head_ref(self) -> str | None: return None @abc.abstractmethod - def get_pr(self) -> str: - ... + def get_pr(self) -> str: ... @abc.abstractmethod - def publish_changes(self, changed_files: list[Path], commit_message: str) -> None: - ... + def publish_changes(self, changed_files: list[Path], commit_message: str) -> None: ... @staticmethod def all() -> dict[str, t.Callable[[], RepositoryCIPlugin]]: diff --git a/src/slap/release.py b/src/slap/release.py index 2f4086da..970961ba 100644 --- a/src/slap/release.py +++ b/src/slap/release.py @@ -11,13 +11,11 @@ @t.overload -def match_version_ref_pattern(filename: Path, pattern: str) -> VersionRef: - ... +def match_version_ref_pattern(filename: Path, pattern: str) -> VersionRef: ... @t.overload -def match_version_ref_pattern(filename: Path, pattern: str, fallback: T) -> T | VersionRef: - ... +def match_version_ref_pattern(filename: Path, pattern: str, fallback: T) -> T | VersionRef: ... def match_version_ref_pattern(filename: Path, pattern: str, fallback: NotSet | T = NotSet.Value) -> T | VersionRef: diff --git a/src/slap/repository.py b/src/slap/repository.py index 0c7d286d..ba8d012d 100644 --- a/src/slap/repository.py +++ b/src/slap/repository.py @@ -52,25 +52,20 @@ def get_username(self, repository: Repository) -> str | None: ... @abc.abstractmethod - def get_issue_by_reference(self, issue_reference: str) -> Issue: - ... + def get_issue_by_reference(self, issue_reference: str) -> Issue: ... @abc.abstractmethod - def get_pull_request_by_reference(self, pr_reference: str) -> PullRequest: - ... + def get_pull_request_by_reference(self, pr_reference: str) -> PullRequest: ... @abc.abstractmethod - def comment_on_issue(self, issue_reference: str, message: str) -> None: - ... + def comment_on_issue(self, issue_reference: str, message: str) -> None: ... @abc.abstractmethod - def create_release(self, version: str, description: str, attachments: list[Path]) -> None: - ... + def create_release(self, version: str, description: str, attachments: list[Path]) -> None: ... @staticmethod @abc.abstractmethod - def detect_repository_host(repository: Repository) -> RepositoryHost | None: - ... + def detect_repository_host(repository: Repository) -> RepositoryHost | None: ... class Repository(Configuration): diff --git a/src/slap/util/cleo.py b/src/slap/util/cleo.py index 2a906381..8fc4f043 100644 --- a/src/slap/util/cleo.py +++ b/src/slap/util/cleo.py @@ -14,8 +14,7 @@ def add_style( foreground: str | None = ..., background: str | None = ..., options: list[str] | None = ..., -) -> None: - ... +) -> None: ... @t.overload @@ -23,8 +22,7 @@ def add_style( io: IO | Formatter, name: str, style: Style, -) -> None: - ... +) -> None: ... def add_style( # type: ignore[misc] diff --git a/src/slap/util/digraph.py b/src/slap/util/digraph.py index 72e6688f..a8a6c067 100644 --- a/src/slap/util/digraph.py +++ b/src/slap/util/digraph.py @@ -226,8 +226,7 @@ class UnknownEdgeError(KeyError): class Comparable(Protocol): - def __lt__(self, other: t.Any) -> bool: - ... + def __lt__(self, other: t.Any) -> bool: ... def topological_sort( diff --git a/src/slap/util/fs.py b/src/slap/util/fs.py index 820559fa..9b0f5395 100644 --- a/src/slap/util/fs.py +++ b/src/slap/util/fs.py @@ -16,8 +16,7 @@ def atomic_write( path: StrPath, mode: te.Literal["w"], rename_mode: te.Literal["posix", "windows"] | None, -) -> t.ContextManager[t.TextIO]: - ... +) -> t.ContextManager[t.TextIO]: ... @t.overload @@ -25,8 +24,7 @@ def atomic_write( path: StrPath, mode: te.Literal["wb"], rename_mode: te.Literal["posix", "windows"] | None, -) -> t.ContextManager[t.BinaryIO]: - ... +) -> t.ContextManager[t.BinaryIO]: ... @contextlib.contextmanager # type: ignore @@ -64,8 +62,7 @@ def atomic_swap( path: StrPath, mode: te.Literal["w"], always_revert: bool, -) -> t.ContextManager[t.TextIO]: - ... +) -> t.ContextManager[t.TextIO]: ... @t.overload @@ -73,8 +70,7 @@ def atomic_swap( path: StrPath, mode: te.Literal["wb"], always_revert: bool, -) -> t.ContextManager[t.BinaryIO]: - ... +) -> t.ContextManager[t.BinaryIO]: ... @contextlib.contextmanager # type: ignore diff --git a/src/slap/util/plugins.py b/src/slap/util/plugins.py index e9016372..1511fe45 100644 --- a/src/slap/util/plugins.py +++ b/src/slap/util/plugins.py @@ -21,13 +21,11 @@ class NoSuchEntrypointError(RuntimeError): @t.overload -def load_entrypoint(group: str, name: str) -> t.Any: - ... +def load_entrypoint(group: str, name: str) -> t.Any: ... @t.overload -def load_entrypoint(group: type[T], name: str) -> type[T]: - ... +def load_entrypoint(group: type[T], name: str) -> type[T]: ... def load_entrypoint(group: str | type[T], name: str) -> t.Any | type[T]: @@ -58,13 +56,11 @@ def load_entrypoint(group: str | type[T], name: str) -> t.Any | type[T]: @t.overload -def iter_entrypoints(group: str) -> _Iter_Entrypoints_1: - ... +def iter_entrypoints(group: str) -> _Iter_Entrypoints_1: ... @t.overload -def iter_entrypoints(group: type[T]) -> _Iter_Entrypoints_2: - ... +def iter_entrypoints(group: type[T]) -> _Iter_Entrypoints_2: ... def iter_entrypoints(group: str | type[T]) -> _Iter_Entrypoints_1 | _Iter_Entrypoints_2: diff --git a/src/slap/util/terminal.py b/src/slap/util/terminal.py index d1cdc8dd..3826b4ab 100644 --- a/src/slap/util/terminal.py +++ b/src/slap/util/terminal.py @@ -46,12 +46,10 @@ class Attribute(enum.Enum): class Color(abc.ABC): @abc.abstractmethod - def as_foreground(self) -> str: - ... + def as_foreground(self) -> str: ... @abc.abstractmethod - def as_background(self) -> str: - ... + def as_background(self) -> str: ... class SgrColorName(enum.Enum): diff --git a/src/slap/util/toml_file.py b/src/slap/util/toml_file.py index 9af95464..d3dbd178 100644 --- a/src/slap/util/toml_file.py +++ b/src/slap/util/toml_file.py @@ -52,12 +52,10 @@ def save(self) -> None: tomli_w.dump(self._data, fp) @t.overload - def value(self) -> dict[str, t.Any]: - ... + def value(self) -> dict[str, t.Any]: ... @t.overload - def value(self, data: dict[str, t.Any]) -> None: - ... + def value(self, data: dict[str, t.Any]) -> None: ... def value(self, data: dict[str, t.Any] | None = None) -> dict[str, t.Any] | None: if data is None: diff --git a/src/slap/util/vcs.py b/src/slap/util/vcs.py index 0bf07064..d2d6f65a 100644 --- a/src/slap/util/vcs.py +++ b/src/slap/util/vcs.py @@ -44,8 +44,7 @@ class Vcs(abc.ABC): """Interface to perform actions on a local version control system and its remote counterpart.""" @abc.abstractmethod - def get_toplevel(self) -> Path: - ... + def get_toplevel(self) -> Path: ... @abc.abstractmethod def get_web_url(self) -> str | None: @@ -98,8 +97,7 @@ def commit_files( @classmethod @abc.abstractclassmethod - def detect(cls: type[T], path: Path) -> T | None: - ... + def detect(cls: type[T], path: Path) -> T | None: ... class Git(Vcs): diff --git a/src/slap/util/weak_property.py b/src/slap/util/weak_property.py index 3ed99ac9..b87134f7 100644 --- a/src/slap/util/weak_property.py +++ b/src/slap/util/weak_property.py @@ -47,13 +47,11 @@ def __get__(self, instance: Any, owner: Any) -> Optional[T]: @overload -def weak_property(attr_name: str, once: bool = False, optional: Literal[False] = False) -> T: - ... +def weak_property(attr_name: str, once: bool = False, optional: Literal[False] = False) -> T: ... @overload -def weak_property(attr_name: str, once: bool = False, optional: Literal[True] = True) -> T | None: - ... +def weak_property(attr_name: str, once: bool = False, optional: Literal[True] = True) -> T | None: ... def weak_property(attr_name: str, once: bool = False, optional: bool = False) -> T | None: From 7fb6fe305b81e4f641535f9937265208f94aeed2 Mon Sep 17 00:00:00 2001 From: Niklas Rosenstein Date: Sat, 27 Jan 2024 03:59:24 +0100 Subject: [PATCH 19/22] address mypy lints --- .flake8 | 2 + src/slap/application.py | 2 +- src/slap/ext/application/changelog.py | 2 +- src/slap/ext/application/release.py | 2 +- src/slap/ext/application/report.py | 3 +- src/slap/ext/repository_handlers/default.py | 2 +- src/slap/plugins.py | 1 + src/slap/project.py | 4 +- src/slap/python/environment.py | 15 +++---- src/slap/repository.py | 2 +- src/slap/util/digraph.py | 2 +- src/slap/util/git.py | 46 +++++++++++---------- src/slap/util/vcs.py | 9 ++-- src/slap/util/weak_property.py | 8 ++-- 14 files changed, 55 insertions(+), 45 deletions(-) diff --git a/.flake8 b/.flake8 index a7ea72ef..d75574de 100644 --- a/.flake8 +++ b/.flake8 @@ -8,3 +8,5 @@ ignore= W504, # whitespace before ':' E203, + # multiple statements on one line (def) + E702, diff --git a/src/slap/application.py b/src/slap/application.py index fac64909..d064c807 100644 --- a/src/slap/application.py +++ b/src/slap/application.py @@ -240,7 +240,7 @@ def load_plugins(self) -> None: logger.debug("Loading application plugins") - for plugin_name, loader in iter_entrypoints(ApplicationPlugin): # type: ignore[misc] + for plugin_name, loader in iter_entrypoints(ApplicationPlugin): # type: ignore[type-abstract] if plugin_name in disable: continue try: diff --git a/src/slap/ext/application/changelog.py b/src/slap/ext/application/changelog.py index 9dcef51e..53a76e9c 100644 --- a/src/slap/ext/application/changelog.py +++ b/src/slap/ext/application/changelog.py @@ -607,7 +607,7 @@ def handle(self) -> int: except Exception as exc: has_failures = True self.line_error(f'warn: could not convert "{filename}": {exc}', "warning") - if self.io.is_very_verbose: + if self.io.is_very_verbose(): import traceback self.line_error(traceback.format_exc()) diff --git a/src/slap/ext/application/release.py b/src/slap/ext/application/release.py index 27e6eb49..2af05341 100644 --- a/src/slap/ext/application/release.py +++ b/src/slap/ext/application/release.py @@ -328,7 +328,7 @@ def _get_new_version(self, version_refs: list[VersionRef], rule: str) -> "Versio return Version.parse(rule) except ValueError: try: - plugin = load_entrypoint(VersionIncrementingRulePlugin, rule) + plugin = load_entrypoint(VersionIncrementingRulePlugin, rule) # type: ignore[type-abstract] except NoSuchEntrypointError: self.line(f'error: "{rule}" is not a valid version incrementing rule', "error") sys.exit(1) diff --git a/src/slap/ext/application/report.py b/src/slap/ext/application/report.py index 0c8734c7..76e0dc3f 100644 --- a/src/slap/ext/application/report.py +++ b/src/slap/ext/application/report.py @@ -3,7 +3,8 @@ import json import logging import typing as t -from importlib.metadata import Distribution + +from importlib_metadata import Distribution from slap.application import Application, option from slap.ext.application.venv import VenvAwareCommand diff --git a/src/slap/ext/repository_handlers/default.py b/src/slap/ext/repository_handlers/default.py index be91d956..b2054a6b 100644 --- a/src/slap/ext/repository_handlers/default.py +++ b/src/slap/ext/repository_handlers/default.py @@ -69,7 +69,7 @@ def get_repository_host(self, repository: Repository) -> RepositoryHost | None: config = self._get_config(repository) if config.repository_host: return config.repository_host - for _plugin_name, loader in iter_entrypoints(RepositoryHost): # type: ignore[misc] + for _plugin_name, loader in iter_entrypoints(RepositoryHost): # type: ignore[type-abstract] if instance := loader().detect_repository_host(repository): return instance return None diff --git a/src/slap/plugins.py b/src/slap/plugins.py index f6982e4d..bbe38639 100644 --- a/src/slap/plugins.py +++ b/src/slap/plugins.py @@ -163,6 +163,7 @@ class VersionIncrementingRulePlugin(abc.ABC): ENTRYPOINT = "slap.plugins.version_incrementing_rule" + @abc.abstractmethod def increment_version(self, version: Version) -> Version: ... diff --git a/src/slap/project.py b/src/slap/project.py index 10fa82b3..3c955ebc 100644 --- a/src/slap/project.py +++ b/src/slap/project.py @@ -115,7 +115,7 @@ def _get_project_handler(self) -> ProjectHandlerPlugin: handler_name = self.config().handler if handler_name is None: - for handler_name, loader in iter_entrypoints(ProjectHandlerPlugin): # type: ignore[misc] + for handler_name, loader in iter_entrypoints(ProjectHandlerPlugin): # type: ignore[type-abstract] handler = loader()() if handler.matches_project(self): break @@ -123,7 +123,7 @@ def _get_project_handler(self) -> ProjectHandlerPlugin: raise RuntimeError(f"unable to identify project handler for {self!r}") else: assert isinstance(handler_name, str), repr(handler_name) - handler = load_entrypoint(ProjectHandlerPlugin, handler_name)() # type: ignore[misc] + handler = load_entrypoint(ProjectHandlerPlugin, handler_name)() # type: ignore[type-abstract] assert handler.matches_project(self), (self, handler) return handler diff --git a/src/slap/python/environment.py b/src/slap/python/environment.py index cc4fee67..e5e0b966 100644 --- a/src/slap/python/environment.py +++ b/src/slap/python/environment.py @@ -9,13 +9,14 @@ import subprocess as sp import textwrap import typing as t -from importlib.metadata import PathDistribution from pathlib import Path +from importlib_metadata import PathDistribution + from slap.python import pep508 if t.TYPE_CHECKING: - from importlib.metadata import Distribution + from importlib_metadata import Distribution from slap.python.dependency import Dependency @@ -42,12 +43,12 @@ def is_venv(self) -> bool: return bool(self.real_prefix or (self.base_prefix and self.prefix != self.base_prefix)) def has_importlib_metadata(self) -> bool: - """Checks if the Python environment has the `importlib.metadata` module available.""" + """Checks if the Python environment has the `importlib_metadata` module available.""" if self._has_pkg_resources is None: code = textwrap.dedent( """ - try: import importlib.metadata + try: import importlib_metadata except ImportError: print('false') else: print('true') """ @@ -80,7 +81,7 @@ def of(python: str | t.Sequence[str]) -> "PythonEnvironment": import sys, platform, json, pickle sys.path.append({pep508_path!r}) import pep508 - try: import importlib.metadata as metadata + try: import importlib_metadata as metadata except ImportError: metadata = None print(json.dumps({{ "executable": sys.executable, @@ -108,11 +109,11 @@ def get_distribution(self, distribution: str) -> Distribution | None: def get_distributions(self, distributions: t.Collection[str]) -> dict[str, Distribution | None]: """Query the details for the given distributions in the Python environment with - #importlib.metadata.distribution().""" + #importlib_metadata.distribution().""" code = textwrap.dedent( """ - import sys, importlib.metadata as metadata, pickle + import sys, importlib_metadata as metadata, pickle result = [] for arg in sys.argv[1:]: try: diff --git a/src/slap/repository.py b/src/slap/repository.py index ba8d012d..fd885782 100644 --- a/src/slap/repository.py +++ b/src/slap/repository.py @@ -114,7 +114,7 @@ def _get_repository_handler(self) -> RepositoryHandlerPlugin | None: return None else: assert isinstance(handler_name, str), repr(handler_name) - handler = load_entrypoint(RepositoryHandlerPlugin, handler_name)() # type: ignore[misc] + handler = load_entrypoint(RepositoryHandlerPlugin, handler_name)() # type: ignore[type-abstract] assert handler.matches_repository(self), (self, handler) return handler diff --git a/src/slap/util/digraph.py b/src/slap/util/digraph.py index a8a6c067..b004fbf5 100644 --- a/src/slap/util/digraph.py +++ b/src/slap/util/digraph.py @@ -21,7 +21,7 @@ class DiGraph(t.Generic[K, N, E]): @generic E: The type of value stored for each edge in the graph. Edge values may not be unique. """ - def __init__(self): + def __init__(self) -> None: """ Create a new empty directed graph. """ diff --git a/src/slap/util/git.py b/src/slap/util/git.py index 695af6b0..93bb7157 100644 --- a/src/slap/util/git.py +++ b/src/slap/util/git.py @@ -43,10 +43,10 @@ class Git: def __init__(self, path: Path | str | None = None): self.path = Path(path) if path else Path.cwd() - def check_call(self, command: t.List[str], stdout: t.Optional[int] = None) -> None: + def check_call(self, command: list[str], stdout: t.Optional[int] = None) -> None: sp.check_call(command, cwd=self.path, stdout=stdout) - def check_output(self, command: t.List[str], stderr: t.Optional[int] = None) -> bytes: + def check_output(self, command: list[str], stderr: t.Optional[int] = None) -> bytes: return sp.check_output(command, cwd=self.path, stderr=stderr) def init(self) -> None: @@ -55,11 +55,11 @@ def init(self) -> None: def clone( self, clone_url: str, - branch: str = None, - depth: int = None, + branch: str | None = None, + depth: int | None = None, recursive: bool = False, - username: str = None, - password: str = None, + username: str | None = None, + password: str | None = None, quiet: bool = False, ) -> None: """ @@ -71,7 +71,7 @@ def clone( if not clone_url.startswith("https://"): raise ValueError("cannot specify username/password for non-HTTPS clone URL.") schema, remainder = clone_url.partition("://")[::2] - auth = ":".join(t.cast(t.List[str], filter(bool, [username, password]))) + auth = ":".join(t.cast(list[str], filter(bool, [username, password]))) clone_url = schema + "://" + auth + "@" + remainder command = ["git", "clone", clone_url, str(self.path)] @@ -88,7 +88,7 @@ def clone( # change directory to the clone target directory, which might not yet exist. sp.check_call(command) - def add(self, files: t.List[str]) -> None: + def add(self, files: list[str]) -> None: """ Add files to the index. """ @@ -97,7 +97,7 @@ def add(self, files: t.List[str]) -> None: command = ["git", "add", "--"] + files self.check_call(command) - def get_branches(self) -> t.List[Branch]: + def get_branches(self) -> list[Branch]: """ Get the branches of the repository. Returns a list of #Branch objects. """ @@ -116,7 +116,7 @@ def get_branches(self) -> t.List[Branch]: return results - def get_branch_names(self) -> t.List[str]: + def get_branch_names(self) -> list[str]: """ Get the branch names. """ @@ -134,7 +134,7 @@ def get_current_branch_name(self) -> str: raise NoCurrentBranchError(self.path) - def get_remote_refs(self, remote: str) -> t.List[RefWithSha]: + def get_remote_refs(self, remote: str) -> list[RefWithSha]: result = [] command = ["git", "ls-remote", "--heads", "origin"] for line in self.check_output(command).decode().splitlines(): @@ -142,7 +142,7 @@ def get_remote_refs(self, remote: str) -> t.List[RefWithSha]: result.append(RefWithSha(ref, sha)) return result - def get_remote_branch_names(self, remote: str) -> t.List[str]: + def get_remote_branch_names(self, remote: str) -> list[str]: refs = self.get_remote_refs(remote) return [x.ref[11:] for x in refs if x.ref.startswith("refs/heads/")] @@ -159,7 +159,7 @@ def push(self, remote: str, *refs, force: bool = False) -> None: command.insert(2, "-f") self.check_call(command) - def pull(self, remote: str = None, branch: str = None, quiet: bool = False): + def pull(self, remote: str | None = None, branch: str | None = None, quiet: bool = False) -> None: """ Pull from the specified Git remote. """ @@ -176,12 +176,12 @@ def pull(self, remote: str = None, branch: str = None, quiet: bool = False): def fetch( self, - remote: str = None, + remote: str | None = None, all: bool = False, tags: bool = False, prune: bool = False, prune_tags: bool = False, - argv: t.Optional[t.List[str]] = None, + argv: list[str] | None = None, ) -> None: """ Fetch a remote repository (or multiple). @@ -202,7 +202,7 @@ def fetch( self.check_call(command) - def remotes(self) -> t.List[Remote]: + def remotes(self) -> list[Remote]: """ List up all the remotes of the repository. """ @@ -214,7 +214,7 @@ def remotes(self) -> t.List[Remote]: return [Remote(remote, urls["(fetch)"], urls["(push)"]) for remote, urls in remotes.items()] - def add_remote(self, remote: str, url: str, argv: t.Optional[t.List[str]] = None) -> None: + def add_remote(self, remote: str, url: str, argv: list[str] | None = None) -> None: """ Add a remote with the specified name. """ @@ -261,7 +261,7 @@ def rev_parse(self, rev: str) -> t.Optional[str]: except sp.CalledProcessError: return None - def rev_list(self, rev: str, path: str = None) -> t.List[str]: + def rev_list(self, rev: str, path: str | None = None) -> list[str]: """ Return a list of all Git revisions, optionally in the specified path. """ @@ -307,7 +307,7 @@ def create_branch(self, name: str, orphan: bool = False, reset: bool = False, re self.check_call(command) - def checkout(self, ref: str = None, files: t.List[str] = None, quiet: bool = False): + def checkout(self, ref: str | None = None, files: list[str] | None = None, quiet: bool = False) -> None: """ Check out the specified ref or files. """ @@ -321,7 +321,9 @@ def checkout(self, ref: str = None, files: t.List[str] = None, quiet: bool = Fal command += ["--"] + files self.check_call(command) - def reset(self, ref: str = None, files: t.List[str] = None, hard: bool = False, quiet: bool = False): + def reset( + self, ref: str | None = None, files: list[str] | None = None, hard: bool = False, quiet: bool = False + ) -> None: """ Reset to the specified ref or reset the files. """ @@ -342,7 +344,7 @@ def get_commit_message(self, rev: str) -> str: return self.check_output(["git", "log", "-1", rev, "--pretty=%B"]).decode() - def get_diff(self, files: t.List[str] = None, cached: bool = False): + def get_diff(self, files: list[str] | None = None, cached: bool = False): command = ["git", "--no-pager", "diff", "--color=never"] if cached: command += ["--cached"] @@ -385,7 +387,7 @@ def get_toplevel(self) -> str | None: return None raise - def get_files(self) -> t.List[str]: + def get_files(self) -> list[str]: """Returns a list of all the files tracked in the Git repository.""" return self.check_output(["git", "ls-files"]).decode().strip().splitlines() diff --git a/src/slap/util/vcs.py b/src/slap/util/vcs.py index d2d6f65a..2329809c 100644 --- a/src/slap/util/vcs.py +++ b/src/slap/util/vcs.py @@ -95,9 +95,12 @@ def commit_files( specified for the *push* argument, the commit that was just created on the current branch as well as the tag name if one was specified will be pushed to the remote.""" + # @abc.abstractclassmethod @classmethod - @abc.abstractclassmethod - def detect(cls: type[T], path: Path) -> T | None: ... + def detect(cls: t.Type[T], path: Path) -> T | None: + # TODO (@NiklasRosenstein): This should be an abstract classmethod, but mypy doesn't like that. + # See https://github.com/python/typing/issues/1611 + raise NotImplementedError() class Git(Vcs): @@ -195,7 +198,7 @@ def commit_files( self._git.push(push.name, *refs, force=force) @classmethod - def detect(cls, path: Path) -> t.Union["Git", None]: + def detect(cls, path: Path) -> "Git | None": if _Git(path).get_toplevel() is not None: return Git(path) return None diff --git a/src/slap/util/weak_property.py b/src/slap/util/weak_property.py index b87134f7..9555fc6e 100644 --- a/src/slap/util/weak_property.py +++ b/src/slap/util/weak_property.py @@ -47,12 +47,12 @@ def __get__(self, instance: Any, owner: Any) -> Optional[T]: @overload -def weak_property(attr_name: str, once: bool = False, optional: Literal[False] = False) -> T: ... +def weak_property(attr_name: str, once: bool = False, optional: Literal[False] = False) -> Any: ... @overload -def weak_property(attr_name: str, once: bool = False, optional: Literal[True] = True) -> T | None: ... +def weak_property(attr_name: str, once: bool = False, optional: Literal[True] = True) -> Any | None: ... -def weak_property(attr_name: str, once: bool = False, optional: bool = False) -> T | None: - return cast(T, WeakProperty(attr_name, once) if optional else OptionalWeakProperty(attr_name, once)) +def weak_property(attr_name: str, once: bool = False, optional: bool = False) -> Any | None: + return WeakProperty(attr_name, once) if optional else OptionalWeakProperty(attr_name, once) From 1a13f24852a1d1d2c1e6c32d2801a71ee4a382cd Mon Sep 17 00:00:00 2001 From: Niklas Rosenstein Date: Sat, 27 Jan 2024 04:01:00 +0100 Subject: [PATCH 20/22] fix flake8 ignore --- .flake8 | 2 +- src/slap/util/weak_property.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.flake8 b/.flake8 index d75574de..af5065cd 100644 --- a/.flake8 +++ b/.flake8 @@ -9,4 +9,4 @@ ignore= # whitespace before ':' E203, # multiple statements on one line (def) - E702, + E704, diff --git a/src/slap/util/weak_property.py b/src/slap/util/weak_property.py index 9555fc6e..6cf78a38 100644 --- a/src/slap/util/weak_property.py +++ b/src/slap/util/weak_property.py @@ -1,7 +1,7 @@ from __future__ import annotations import weakref -from typing import Any, Generic, Literal, Optional, TypeVar, cast, overload +from typing import Any, Generic, Literal, Optional, TypeVar, overload T = TypeVar("T") From f0c6a4bc06c93d32409c1d0ae491235f159805fd Mon Sep 17 00:00:00 2001 From: Niklas Rosenstein Date: Sat, 27 Jan 2024 04:03:08 +0100 Subject: [PATCH 21/22] improvement: Python 3.12 support --- .changelog/_unreleased.toml | 6 ++++++ src/slap/ext/project_handlers/setuptools.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.changelog/_unreleased.toml b/.changelog/_unreleased.toml index 06b6c1f8..bbb5c61b 100644 --- a/.changelog/_unreleased.toml +++ b/.changelog/_unreleased.toml @@ -18,3 +18,9 @@ type = "improvement" description = "Use `importlib-metadata` package over `pkg_resources`" author = "@NiklasRosenstein" pr = "https://github.com/NiklasRosenstein/slap/pull/108" + +[[entries]] +id = "2b4061fd-0028-4520-8e76-f7926c1c6ff7" +type = "improvement" +description = "Python 3.12 support" +author = "@NiklasRosenstein" diff --git a/src/slap/ext/project_handlers/setuptools.py b/src/slap/ext/project_handlers/setuptools.py index 30dded8f..ac5f4ff3 100644 --- a/src/slap/ext/project_handlers/setuptools.py +++ b/src/slap/ext/project_handlers/setuptools.py @@ -22,7 +22,7 @@ def _get_setup_cfg(self, project: Project) -> t.Dict[str, t.Any]: import configparser if self._project is None: - parser = configparser.SafeConfigParser() + parser = configparser.ConfigParser() parser.read(project.directory / "setup.cfg") self._setup_cfg = {s: dict(parser.items(s)) for s in parser.sections()} self._project = project From 1da19c80ac5557902d6ccacbb963b9700d7eeb7e Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sat, 27 Jan 2024 03:03:39 +0000 Subject: [PATCH 22/22] Updated PR references in 1 changelogs. skip-checks: true --- .changelog/_unreleased.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/.changelog/_unreleased.toml b/.changelog/_unreleased.toml index bbb5c61b..372f6f1f 100644 --- a/.changelog/_unreleased.toml +++ b/.changelog/_unreleased.toml @@ -24,3 +24,4 @@ id = "2b4061fd-0028-4520-8e76-f7926c1c6ff7" type = "improvement" description = "Python 3.12 support" author = "@NiklasRosenstein" +pr = "https://github.com/NiklasRosenstein/slap/pull/108"