diff --git a/.flake8 b/.flake8 deleted file mode 100644 index a7cb63ddfe726693c1dcf2d415a46de535eaff83..0000000000000000000000000000000000000000 --- a/.flake8 +++ /dev/null @@ -1,7 +0,0 @@ -# Copyright © 2022 Idiap Research Institute <contact@idiap.ch> -# -# SPDX-License-Identifier: BSD-3-Clause - -[flake8] -max-line-length = 80 -ignore = E501,W503,E302,E402,E203 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e42d7d3e246980d0c61a3c5117dd4f0b7ae1a659..e4062b2b600bd4d6c653535ba8b5181c0d16846c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,32 +5,17 @@ # See https://pre-commit.com for more information # See https://pre-commit.com/hooks.html for more hooks repos: - - repo: https://github.com/psf/black - rev: 24.3.0 + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.3.3 hooks: - - id: black - - repo: https://github.com/pycqa/docformatter - rev: v1.7.5 - hooks: - - id: docformatter - - repo: https://github.com/pycqa/isort - rev: 5.13.2 - hooks: - - id: isort - - repo: https://github.com/pycqa/flake8 - rev: 7.0.0 - hooks: - - id: flake8 + - id: ruff + args: [ --fix ] + - id: ruff-format - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.9.0 hooks: - id: mypy - args: [ - --install-types, - --non-interactive, - --no-strict-optional, - --ignore-missing-imports, - ] + args: [ --install-types, --non-interactive, --no-strict-optional, --ignore-missing-imports ] exclude: '^.*/data/second_config\.py$' - repo: https://github.com/asottile/pyupgrade rev: v3.15.1 diff --git a/doc/conf.py b/doc/conf.py index fbca3e9f87e4ddd4169a6660d60304ab373e5ecf..2a9689cb43e102cbccd59d5e86418c911e10d64a 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -2,9 +2,8 @@ # # SPDX-License-Identifier: BSD-3-Clause -import os +import pathlib import time - from importlib.metadata import distribution # -- General configuration ----------------------------------------------------- @@ -35,8 +34,9 @@ nitpicky = True nitpick_ignore = [] # Allows the user to override warnings from a separate file -if os.path.exists("nitpick-exceptions.txt"): - for line in open("nitpick-exceptions.txt"): +nitpick_path = pathlib.Path("nitpick-exceptions.txt") +if nitpick_path.exists(): + for line in nitpick_path.open(): if line.strip() == "" or line.startswith("#"): continue dtype, target = line.split(None, 1) @@ -53,9 +53,9 @@ autosummary_generate = True numfig = True # If we are on OSX, the 'dvipng' path maybe different -dvipng_osx = "/Library/TeX/texbin/dvipng" -if os.path.exists(dvipng_osx): - pngmath_dvipng = dvipng_osx +dvipng_osx = pathlib.Path("/Library/TeX/texbin/dvipng") +if dvipng_osx.exists(): + pngmath_dvipng = str(dvipng_osx) # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] @@ -70,7 +70,7 @@ master_doc = "index" project = "idiap-devtools" package = distribution(project) -copyright = "%s, Idiap Research Institute" % time.strftime("%Y") +copyright = "%s, Idiap Research Institute" % time.strftime("%Y") # noqa: A001 # The short X.Y version. version = package.version diff --git a/pyproject.toml b/pyproject.toml index cf37fee78ed47ce0a485de6cea1a20ddbda8c6df..a18dee23ca20f738f09725b39f9ac33f4af943b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -84,14 +84,59 @@ test = ["pytest", "pytest-cov", "coverage"] [project.scripts] devtool = "idiap_devtools.scripts.cli:cli" -[tool.isort] -profile = "black" -line_length = 80 -order_by_type = true -lines_between_types = 1 - -[tool.black] +[tool.ruff] line-length = 80 +target-version = "py310" + +[tool.ruff.format] +docstring-code-format = true + +[tool.ruff.lint] +select = [ + "A", # https://docs.astral.sh/ruff/rules/#flake8-builtins-a + "COM", # https://docs.astral.sh/ruff/rules/#flake8-commas-com + "D", # https://docs.astral.sh/ruff/rules/#pydocstyle-d + "E", # https://docs.astral.sh/ruff/rules/#error-e + "F", # https://docs.astral.sh/ruff/rules/#pyflakes-f + "I", # https://docs.astral.sh/ruff/rules/#isort-i + "ISC", # https://docs.astral.sh/ruff/rules/#flake8-implicit-str-concat-isc + "LOG", # https://docs.astral.sh/ruff/rules/#flake8-logging-log + "N", # https://docs.astral.sh/ruff/rules/#pep8-naming-n + "PTH", # https://docs.astral.sh/ruff/rules/#flake8-use-pathlib-pth + "Q", # https://docs.astral.sh/ruff/rules/#flake8-quotes-q + "RET", # https://docs.astral.sh/ruff/rules/#flake8-return-ret + "SLF", # https://docs.astral.sh/ruff/rules/#flake8-self-slf + "T10", # https://docs.astral.sh/ruff/rules/#flake8-debugger-t10 + "T20", # https://docs.astral.sh/ruff/rules/#flake8-print-t20 + "UP", # https://docs.astral.sh/ruff/rules/#pyupgrade-up + "W", # https://docs.astral.sh/ruff/rules/#warning-w + #"G", # https://docs.astral.sh/ruff/rules/#flake8-logging-format-g + #"ICN", # https://docs.astral.sh/ruff/rules/#flake8-import-conventions-icn +] +ignore = [ + "COM812", # https://docs.astral.sh/ruff/rules/missing-trailing-comma/ + "D100", # https://docs.astral.sh/ruff/rules/undocumented-public-module/ + "D102", # https://docs.astral.sh/ruff/rules/undocumented-public-method/ + "D104", # https://docs.astral.sh/ruff/rules/undocumented-public-package/ + "D105", # https://docs.astral.sh/ruff/rules/undocumented-magic-method/ + "D107", # https://docs.astral.sh/ruff/rules/undocumented-public-init/ + "D203", # https://docs.astral.sh/ruff/rules/one-blank-line-before-class/ + "D202", # https://docs.astral.sh/ruff/rules/no-blank-line-after-function/ + "D205", # https://docs.astral.sh/ruff/rules/blank-line-after-summary/ + "D212", # https://docs.astral.sh/ruff/rules/multi-line-summary-first-line/ + "D213", # https://docs.astral.sh/ruff/rules/multi-line-summary-second-line/ + "E302", # https://docs.astral.sh/ruff/rules/blank-lines-top-level/ + "E402", # https://docs.astral.sh/ruff/rules/module-import-not-at-top-of-file/ + "E501", # https://docs.astral.sh/ruff/rules/line-too-long/ + "ISC001", # https://docs.astral.sh/ruff/rules/single-line-implicit-string-concatenation/ +] + +[tool.ruff.lint.pydocstyle] +convention = "numpy" + +[tool.ruff.lint.per-file-ignores] +"tests/*.py" = ["D", "E501"] +"doc/conf.py" = ["D"] [tool.pytest.ini_options] addopts = [ diff --git a/src/idiap_devtools/click.py b/src/idiap_devtools/click.py index fcd9e97554a0de70f5c3f276bd8fefb7109465a3..0bc3107338d0913705624fef68a04ea6bf59134b 100644 --- a/src/idiap_devtools/click.py +++ b/src/idiap_devtools/click.py @@ -56,8 +56,8 @@ def verbosity_option( :py:func:`click.option` - Returns: - + Returns + ------- A callable, that follows the :py:mod:`click`-framework policy for option decorators. Use it accordingly. """ @@ -105,7 +105,7 @@ class AliasedGroup(click.Group): def get_command( # type: ignore self, ctx: click.core.Context, cmd_name: str ) -> click.Command | None: - """Returns the decorated command. + """Return the decorated command. Arguments: @@ -114,20 +114,24 @@ class AliasedGroup(click.Group): cmd_name: The subcommand name that was called - Returns: - + Returns + ------- The decorated command with aliasing capabilities """ rv = click.Group.get_command(self, ctx, cmd_name) if rv is not None: return rv + matches = [x for x in self.list_commands(ctx) if x.startswith(cmd_name)] + if not matches: return None - elif len(matches) == 1: + + if len(matches) == 1: return click.Group.get_command(self, ctx, matches[0]) - ctx.fail("Too many matches: %s" % ", ".join(sorted(matches))) + + ctx.fail("Too many matches: %s" % ", ".join(sorted(matches))) # noqa class PreserveIndentCommand(click.Command): @@ -136,7 +140,7 @@ class PreserveIndentCommand(click.Command): def format_epilog( self, _: click.core.Context, formatter: click.formatting.HelpFormatter ) -> None: - """Formats the command epilog during --help. + """Format the command epilog during --help. Arguments: @@ -153,7 +157,7 @@ class PreserveIndentCommand(click.Command): def format_description( self, _: click.core.Context, formatter: click.formatting.HelpFormatter ) -> None: - """Formats the command description during --help. + """Format the command description during --help. Arguments: @@ -169,7 +173,7 @@ class PreserveIndentCommand(click.Command): def validate_profile(_: click.Context, __: str, value: str) -> str: - """Callback for click doing a profile name validation. + """Call back for click doing a profile name validation. Arguments: @@ -180,8 +184,8 @@ def validate_profile(_: click.Context, __: str, value: str) -> str: value: The value set for the option - Returns: - + Returns + ------- The validated option value """ profile_path = get_profile_path(value) diff --git a/src/idiap_devtools/conda.py b/src/idiap_devtools/conda.py index ddc8ce47a1896091ffa391ef08406dfbb3e53b86..5d0d8cb1b15dcd3c9cadf17f5f956d86c158d6ab 100644 --- a/src/idiap_devtools/conda.py +++ b/src/idiap_devtools/conda.py @@ -5,7 +5,7 @@ import contextlib import copy import logging -import os +import pathlib import typing from .utils import uniq @@ -15,7 +15,7 @@ logger = logging.getLogger(__name__) @contextlib.contextmanager def root_logger_protection(): - """Protects the root logger against spurious (conda) manipulation. + """Protect the root logger against spurious (conda) manipulation. Still to verify: conda does some operations on loggers at import, so we may need to put the import inside this context manager too. @@ -33,7 +33,7 @@ def root_logger_protection(): def make_conda_config( options: dict[str, typing.Any], ) -> typing.Any: - """Creates a conda configuration for a build merging various sources. + """Create a conda configuration for a build merging various sources. This function will use the conda-build API to construct a configuration by merging different sources of information. @@ -44,8 +44,8 @@ def make_conda_config( options: A dictionary (typically read from a condarc YAML file) that contains build and channel options - Returns: - + Returns + ------- A dictionary containing the merged configuration, as produced by conda-build API's ``get_or_merge_config()`` function. """ @@ -53,7 +53,7 @@ def make_conda_config( with root_logger_protection(): import conda_build.api - from conda_build.conda_interface import url_path + from conda.utils import url_path retval = conda_build.api.get_or_merge_config(None, **options) @@ -63,11 +63,10 @@ def make_conda_config( # allow people to specify relative or absolute paths to local channels # These channels still must follow conda rules - they must have the # appropriate platform-specific subdir (e.g. win-64) - if os.path.isdir(url): - if not os.path.isabs(url): - url = os.path.normpath( - os.path.abspath(os.path.join(os.getcwd(), url)) - ) + url_ = pathlib.Path(url) + if url_.is_dir(): + if not url_.is_absolute(): + url = str(url_.resolve()) with root_logger_protection(): url = url_path(url) retval.channel_urls.append(url) @@ -76,7 +75,7 @@ def make_conda_config( def use_mambabuild(): - """Will inject mamba solver to conda build API to speed up resolves.""" + """Inject mamba solver to conda build API to speed up resolves.""" # only importing this module will do the job. with root_logger_protection(): @@ -86,7 +85,7 @@ def use_mambabuild(): def get_rendered_metadata(recipe_dir, config): - """Renders the recipe and returns the interpreted YAML file.""" + """Render the recipe and returns the interpreted YAML file.""" with root_logger_protection(): import conda_build.api @@ -96,16 +95,18 @@ def get_rendered_metadata(recipe_dir, config): def get_parsed_recipe(metadata): - """Renders the recipe and returns the interpreted YAML file.""" + """Render the recipe and returns the interpreted YAML file.""" with root_logger_protection(): return metadata[0][0].get_rendered_recipe_text() def remove_pins(deps): + """Return dependencies without their pinned versions.""" return [ll.split()[0] for ll in deps] def parse_dependencies(recipe_dir, config) -> tuple[str, list[str]]: + """Parse dependencies in a meta.yaml file.""" metadata = get_rendered_metadata(recipe_dir, config) recipe = get_parsed_recipe(metadata) requirements = [] diff --git a/src/idiap_devtools/gitlab/__init__.py b/src/idiap_devtools/gitlab/__init__.py index 4fd9f56232a9920f0483e1fae6520e9e92ab5028..977b1f0caa71c0554706547f9608741a68c9da83 100644 --- a/src/idiap_devtools/gitlab/__init__.py +++ b/src/idiap_devtools/gitlab/__init__.py @@ -9,7 +9,6 @@ import pathlib import shutil import tarfile import tempfile - from io import BytesIO import gitlab @@ -19,13 +18,15 @@ logger = logging.getLogger(__name__) def get_gitlab_instance() -> gitlab.Gitlab: - """Returns an instance of the gitlab object for remote operations.""" + """Return an instance of the gitlab object for remote operations.""" # tries to figure if we can authenticate using a global configuration - cfgs = ["~/.python-gitlab.cfg", "/etc/python-gitlab.cfg"] - cfgs = [os.path.expanduser(k) for k in cfgs] - if any([os.path.exists(k) for k in cfgs]): + cfgs = [ + pathlib.Path(k).expanduser() + for k in ["~/.python-gitlab.cfg", "/etc/python-gitlab.cfg"] + ] + if any([k.exists() for k in cfgs]): gl = gitlab.Gitlab.from_config( - "idiap", [k for k in cfgs if os.path.exists(k)] + "idiap", [str(k) for k in cfgs if k.exists()] ) else: # ask the user for a token or use one from the current runner server = os.environ.get("CI_SERVER_URL", "https://gitlab.idiap.ch") @@ -34,7 +35,7 @@ def get_gitlab_instance() -> gitlab.Gitlab: logger.debug( "Did not find any of %s nor CI_JOB_TOKEN is defined. " "Asking for user token on the command line...", - "|".join(cfgs), + "|".join([str(k) for k in cfgs]), ) token = input(f"{server} (private) token: ") gl = gitlab.Gitlab(server, private_token=token, api_version="4") @@ -48,7 +49,7 @@ def download_path( output: pathlib.Path | None = None, ref: str | None = None, ) -> None: - """Downloads paths from gitlab, with an optional recurse. + """Download paths from gitlab, with an optional recurse. This method will download an archive of the repository from chosen reference, and then it will search inside the zip blob for the path to be @@ -88,4 +89,4 @@ def download_path( # move stuff to "output" basedir = os.listdir(d)[0] - shutil.move(os.path.join(d, basedir, path), output) + shutil.move(pathlib.Path(d) / basedir / path, output) diff --git a/src/idiap_devtools/gitlab/changelog.py b/src/idiap_devtools/gitlab/changelog.py index df7e3aa8a74f9893711e28d78f5d059d7716ead7..b476ebca0b549e9810c325bbf0d084c93970636e 100644 --- a/src/idiap_devtools/gitlab/changelog.py +++ b/src/idiap_devtools/gitlab/changelog.py @@ -18,7 +18,7 @@ logger = logging.getLogger(__name__) def parse_date(s: typing.TextIO | str) -> datetime.datetime: - """Parses any date supported by :py:meth:`dateutil.parser.parse` + """Parse any date supported by :py:meth:`dateutil.parser.parse`. Automatically applies the "Europe/Zurich" timezone @@ -28,8 +28,8 @@ def parse_date(s: typing.TextIO | str) -> datetime.datetime: s: The input readable stream or string to be parsed into a date - Returns: - + Returns + ------- A :py:class:`datetime.datetime`. """ return dateutil.parser.parse(s, ignoretz=True).replace( @@ -41,7 +41,7 @@ def _sort_commits( commits: typing.Iterable[gitlab.v4.objects.commits.ProjectCommit], reverse: bool, ) -> list[typing.Any]: - """Sorts gitlab commit objects using their ``committed_date`` attribute. + """Sort gitlab commit objects using their ``committed_date`` attribute. Arguments: @@ -50,8 +50,8 @@ def _sort_commits( reverse: Indicates if the sorting should be reversed - Returns: - + Returns + ------- The input list of ``commits``, sorted """ @@ -63,7 +63,7 @@ def _sort_commits( def _sort_tags( tags: typing.Iterable[gitlab.v4.objects.tags.ProjectTag], reverse: bool ) -> list[typing.Any]: - """Sorts gitlab tags objects using their ``committed_date`` attribute. + """Sort gitlab tags objects using their ``committed_date`` attribute. Arguments: @@ -72,8 +72,8 @@ def _sort_tags( reverse: Indicates if the sorting should be reversed - Returns: - + Returns + ------- The input list of ``tags``, sorted """ @@ -87,7 +87,7 @@ def _sort_tags( def get_file_from_gitlab( gitpkg: gitlab.v4.objects.projects.Project, path: str, ref: str = "main" ) -> io.StringIO: - """Retrieves a file from a Gitlab repository. + """Retrieve a file from a Gitlab repository. Arguments: @@ -98,8 +98,8 @@ def get_file_from_gitlab( ref: Branch, commit or reference to get file from, at GitLab - Returns: - + Returns + ------- A string I/O object you can use like a file. """ @@ -109,7 +109,7 @@ def get_file_from_gitlab( def get_last_tag_date( package: gitlab.v4.objects.projects.Project, ) -> datetime.datetime: - """Returns the last release date for the given package. + """Return the last release date for the given package. Falls back to the first commit date if the package has not yet been tagged @@ -120,15 +120,15 @@ def get_last_tag_date( date information - Returns: - + Returns + ------- A :py:class:`datetime.datetime` object that refers to the last date the package was released. If the package was never released, then returns the date just before the first commit. - Raises: - + Raises + ------ RuntimeError: if the project has no commits. """ @@ -151,29 +151,27 @@ def get_last_tag_date( milliseconds=500 ) - else: - commit_list = package.commits.list(all=True) - - if commit_list: - # there are commits, use these - first = _sort_commits(commit_list, reverse=False)[0] - logger.debug( - "First commit for package %s (id=%d) is from %s", - package.name, - package.id, - first.committed_date, - ) - return parse_date(first.committed_date) - datetime.timedelta( - milliseconds=500 - ) + commit_list = package.commits.list(all=True) - else: - # there are no commits nor tags - abort - raise RuntimeError( - "package %s (id=%d) does not have commits " - "or tags so I cannot devise a good starting date" - % (package.name, package.id) - ) + if commit_list: + # there are commits, use these + first = _sort_commits(commit_list, reverse=False)[0] + logger.debug( + "First commit for package %s (id=%d) is from %s", + package.name, + package.id, + first.committed_date, + ) + return parse_date(first.committed_date) - datetime.timedelta( + milliseconds=500 + ) + + # there are no commits nor tags - abort + raise RuntimeError( + "package %s (id=%d) does not have commits " + "or tags so I cannot devise a good starting date" + % (package.name, package.id) + ) def _get_tag_changelog(tag: gitlab.v4.objects.tags.ProjectTag) -> str: @@ -186,7 +184,7 @@ def _get_tag_changelog(tag: gitlab.v4.objects.tags.ProjectTag) -> str: def _write_one_tag( f: typing.TextIO, pkg_name: str, tag: gitlab.v4.objects.tags.ProjectTag ) -> None: - """Prints commit information for a single tag of a given package. + """Print commit information for a single tag of a given package. Arguments: @@ -222,8 +220,7 @@ def _write_commits_range( pkg_name: str, commits: typing.Iterable[gitlab.v4.objects.commits.ProjectCommit], ) -> None: - """Writes all commits of a given package within a range, to the output - file. + """Write all commits of a given package within a range, to the output file. Arguments: @@ -262,8 +259,7 @@ def _write_mergerequests_range( pkg_name: str, mrs: typing.Iterable[gitlab.v4.objects.merge_requests.ProjectMergeRequest], ) -> None: - """Writes all merge-requests of a given package, with a range, to the - output file. + """Write all merge-requests of a given package, with a range, to the output file. Arguments: @@ -302,7 +298,7 @@ def get_changes_since( list[gitlab.v4.objects.tags.ProjectTag], list[gitlab.v4.objects.commits.ProjectCommit], ]: - """Gets the list of MRs, tags, and commits since the provided date. + """Get the list of MRs, tags, and commits since the provided date. Arguments: @@ -311,8 +307,8 @@ def get_changes_since( since : a date and time to start looking changes from - Returns: - + Returns + ------- A list of merge requests, tags and commits for the given package, since the determined date. """ @@ -351,7 +347,7 @@ def write_tags_with_commits( since: datetime.datetime, mode: str, ) -> None: - """Writes all tags and commits of a given package to the output file. + """Write all tags and commits of a given package to the output file. Arguments: @@ -434,7 +430,7 @@ def write_tags( gitpkg: gitlab.v4.objects.projects.Project, since: datetime.datetime, ) -> None: - """Writes all tags of a given package to the output file. + """Write all tags of a given package to the output file. Arguments: diff --git a/src/idiap_devtools/gitlab/release.py b/src/idiap_devtools/gitlab/release.py index 604c5123c0da82d43bd7edf7385c3681983464ff..ecbb498735a70e270ba68c9b32315a84c1bbf42b 100644 --- a/src/idiap_devtools/gitlab/release.py +++ b/src/idiap_devtools/gitlab/release.py @@ -7,14 +7,12 @@ import difflib import logging import re import time - from distutils.version import StrictVersion import gitlab import gitlab.v4.objects import packaging.version import tomlkit - from git import Repo from pkg_resources import Requirement @@ -28,7 +26,7 @@ def _update_readme( version: str, default_branch: str, ) -> str: - """Updates README file text to make it release/latest ready. + """Update README file text to make it release/latest ready. Inside text of the readme, replaces parts of the links to the provided version. If version is not provided, replace to `stable` or the default @@ -42,8 +40,8 @@ def _update_readme( default_branch: The name of the default project branch to use - Returns: - + Returns + ------- New text of readme with all replaces done """ @@ -58,21 +56,23 @@ def _update_readme( } # matches the graphical badge in the readme's text with the given version - DOC_IMAGE = re.compile(r"docs\-(" + "|".join(variants) + r")\-", re.VERBOSE) + doc_image_re = re.compile( + r"docs\-(" + "|".join(variants) + r")\-", re.VERBOSE + ) # matches all other occurrences we need to handle - BRANCH_RE = re.compile(r"/(" + "|".join(variants) + r")", re.VERBOSE) + branch_re = re.compile(r"/(" + "|".join(variants) + r")", re.VERBOSE) new_contents = [] for line in contents.splitlines(): - if BRANCH_RE.search(line) is not None: + if branch_re.search(line) is not None: if "gitlab" in line: # gitlab links replacement = ( "/v%s" % version if version is not None else f"/{default_branch}" ) - line = BRANCH_RE.sub(replacement, line) + line = branch_re.sub(replacement, line) if ("docs-latest" in line) or ("docs-stable" in line): # our doc server replacement = ( @@ -80,12 +80,12 @@ def _update_readme( if version is not None else f"/{default_branch}" ) - line = BRANCH_RE.sub(replacement, line) - if DOC_IMAGE.search(line) is not None: + line = branch_re.sub(replacement, line) + if doc_image_re.search(line) is not None: replacement = ( "docs-v%s-" % version if version is not None else "docs-latest-" ) - line = DOC_IMAGE.sub(replacement, line) + line = doc_image_re.sub(replacement, line) new_contents.append(line) return "\n".join(new_contents) + "\n" @@ -95,7 +95,7 @@ def _pin_versions_of_packages_list( packages_list: list[str], dependencies_versions: list[Requirement], ) -> list[str]: - """Adds its version to each package according to a dictionary of versions. + """Add its version to each package according to a dictionary of versions. Modifies ``packages_list`` in-place. @@ -116,8 +116,8 @@ def _pin_versions_of_packages_list( dependencies_versions: All the known packages with their desired version pinning. - Raises: - + Raises + ------ ``ValueError`` if a version in ``dependencies_versions`` conflicts with an already present pinning in ``packages_list``. """ @@ -250,7 +250,7 @@ def _update_pyproject( update_urls: bool, profile: Profile | None = None, ) -> str: - """Updates contents of pyproject.toml to make it release/latest ready. + """Update contents of pyproject.toml to make it release/latest ready. - Sets the project.version field to the given version. - Pins the dependencies version to the ones in the given dev-profile. @@ -270,8 +270,8 @@ def _update_pyproject( profile: Used to retrieve and note the current dev-profile commit. - Returns: - + Returns + ------- New version of ``pyproject.toml`` with all replaces done """ @@ -311,10 +311,12 @@ def _update_pyproject( # Main dependencies logger.info("Pinning versions of dependencies.") pkg_deps = data.get("project", {}).get("dependencies", []) - _pin_versions_of_packages_list( - packages_list=pkg_deps, - dependencies_versions=dependencies_pins, - ), + ( + _pin_versions_of_packages_list( + packages_list=pkg_deps, + dependencies_versions=dependencies_pins, + ), + ) # Optional dependencies opt_pkg_deps = data.get("project", {}).get("optional-dependencies", []) @@ -330,8 +332,8 @@ def _update_pyproject( # Registering dev-profile version logger.info("Annotating pyproject with current dev-profile commit.") - logger.debug("Using dev-profile at '%s'", profile._basedir) - profile_repo = Repo(profile._basedir) + logger.debug("Using dev-profile at '%s'", profile._basedir) # noqa: SLF001 + profile_repo = Repo(profile._basedir) # noqa: SLF001 if profile_repo.is_dirty(): raise RuntimeError( "dev-profile was modified and is dirty! Unable to ensure a " @@ -380,15 +382,15 @@ def _update_pyproject( return tomlkit.dumps(data) # matches all other occurrences we need to handle - BRANCH_RE = re.compile(r"/(" + "|".join(variants) + r")", re.VERBOSE) + branch_re = re.compile(r"/(" + "|".join(variants) + r")", re.VERBOSE) # sets the various URLs url = data["project"].get("urls", {}).get("documentation") - if (url is not None) and (BRANCH_RE.search(url) is not None): + if (url is not None) and (branch_re.search(url) is not None): replacement = ( "/v%s" % version if version is not None else f"/{default_branch}" ) - data["project"]["urls"]["documentation"] = BRANCH_RE.sub( + data["project"]["urls"]["documentation"] = branch_re.sub( replacement, url ) @@ -405,8 +407,8 @@ def get_latest_tag_name( gitpkg: gitlab package object - Returns: - + Returns + ------- The name of the latest tag in format '#.#.#'. ``None`` if no tags for the package were found. """ @@ -427,14 +429,13 @@ def get_latest_tag_name( # sort them correctly according to each subversion number tag_names.sort(key=StrictVersion) # take the last one, as it is the latest tag in the sorted tags - latest_tag_name = tag_names[-1] - return latest_tag_name + return tag_names[-1] def get_next_version( gitpkg: gitlab.v4.objects.projects.Project, bump: str ) -> str: - """Returns the next version of this package to be tagged. + """Return the next version of this package to be tagged. Arguments: @@ -443,13 +444,13 @@ def get_next_version( bump: what to bump (can be "major", "minor", or "patch" versions) - Returns: - + Returns + ------- The new version of the package (to be tagged) - Raises: - + Raises + ------ ValueError: if the latest tag retrieve from the package does not conform with the subset of PEP440 we use (e.g. "v1.2.3b1"). """ @@ -466,7 +467,7 @@ def get_next_version( if bump == "major": return "v1.0.0" - elif bump == "minor": + if bump == "minor": return "v0.1.0" # patch @@ -477,8 +478,7 @@ def get_next_version( m = re.match(r"(\d+\.\d+\.\d+)", latest_tag_name) if not m: raise ValueError( - "The latest tag name {} in package {} has " - "unknown format".format( + "The latest tag name {} in package {} has " "unknown format".format( "v" + latest_tag_name, gitpkg.attributes["path_with_namespace"], ) @@ -560,14 +560,14 @@ def update_files_at_default_branch( def _get_last_pipeline( gitpkg: gitlab.v4.objects.projects.Project, ) -> gitlab.v4.objects.pipelines.ProjectPipeline: - """Returns the last pipeline of the project. + """Return the last pipeline of the project. Arguments: gitpkg: gitlab package object - Returns: - + Returns + ------- The gitlab object of the pipeline """ @@ -583,7 +583,7 @@ def wait_for_pipeline_to_finish( gitpkg: gitlab.v4.objects.projects.Project, pipeline_id: int | None, ) -> None: - """Using sleep function, wait for the latest pipeline to finish building. + """Wait for the latest pipeline to finish building via ``sleep()``. This function pauses the script until pipeline completes either successfully or with error. @@ -619,8 +619,8 @@ def wait_for_pipeline_to_finish( slept_so_far += sleep_step if slept_so_far > max_sleep: raise ValueError( - "I cannot wait longer than {} seconds for " - "pipeline {} to finish running!".format(max_sleep, pipeline_id) + f"I cannot wait longer than {max_sleep} seconds for " + f"pipeline {pipeline_id} to finish running!" ) # probe gitlab to update the status of the pipeline pipeline = gitpkg.pipelines.get(pipeline_id) @@ -660,7 +660,7 @@ def _cancel_last_pipeline(gitpkg: gitlab.v4.objects.projects.Project) -> None: def _get_differences(orig: str, changed: str, fname: str) -> str: - """Calculates the unified diff between two files readout as strings. + """Calculate the unified diff between two files readout as strings. Arguments: @@ -671,8 +671,8 @@ def _get_differences(orig: str, changed: str, fname: str) -> str: fname: The name of the file - Returns: - + Returns + ------- The unified differences between the changes. """ differences = difflib.unified_diff( @@ -694,7 +694,7 @@ def release_package( dry_run: bool = False, profile: Profile | None = None, ) -> int | None: - """Releases a package. + """Release a package. The provided tag will be annotated with a given list of comments. Files such as ``README.md`` and ``pyproject.toml`` will be updated according to @@ -715,8 +715,8 @@ def release_package( retrieve the specifiers to pin the package's dependencies in ``pyproject.toml``. - Returns: - + Returns + ------- The (integer) pipeline identifier, or None, if a pipeline was not actually started (e.g. ``dry_run`` is set to ``True``) """ diff --git a/src/idiap_devtools/gitlab/runners.py b/src/idiap_devtools/gitlab/runners.py index 983faf5afcf2641a8f71c2d384c7839a74d33f69..700118c7f968dcbd4ea5f76ce44b102f4f3c3ea7 100644 --- a/src/idiap_devtools/gitlab/runners.py +++ b/src/idiap_devtools/gitlab/runners.py @@ -15,7 +15,7 @@ logger = logging.getLogger(__name__) def get_runner_from_description( gl: gitlab.Gitlab, descr: str ) -> gitlab.v4.objects.runners.Runner: - """Retrieves a runner object matching the description, or raises. + """Retrieve a runner object matching the description, or raises. Arguments: @@ -24,13 +24,13 @@ def get_runner_from_description( descr: the runner description - Returns: - + Returns + ------- The runner object, if one is found matching the description - Raises: - + Raises + ------ RuntimeError: if no runner matching the description is found. """ @@ -55,7 +55,7 @@ def get_runner_from_description( def get_project( gl: gitlab.Gitlab, name: str ) -> gitlab.v4.objects.projects.Project: - """Retrieves one single project.""" + """Retrieve one single project.""" retval = gl.projects.get(name) logger.debug( @@ -69,7 +69,7 @@ def get_project( def get_projects_from_group( gl: gitlab.Gitlab, name: str ) -> list[gitlab.v4.objects.projects.Project]: - """Returns a list with all projects in a GitLab group.""" + """Return a list with all projects in a GitLab group.""" group = gl.groups.get(name) logger.debug( @@ -95,7 +95,7 @@ def get_projects_from_group( def get_projects_from_runner( gl: gitlab.Gitlab, runner: gitlab.v4.objects.runners.Runner ) -> list[gitlab.v4.objects.projects.Project]: - """Retrieves a list of all projects that include a particular runner.""" + """Retrieve a list of all projects that include a particular runner.""" the_runner = gl.runners.get(runner.id) logger.info( @@ -117,7 +117,7 @@ def get_projects_from_runner( def get_projects_from_file( gl: gitlab.Gitlab, filename: pathlib.Path ) -> list[gitlab.v4.objects.projects.Project]: - """Retrieves a list of projects based on lines of a file.""" + """Retrieve a list of projects based on lines of a file.""" packages = [] with filename.open("rt") as f: diff --git a/src/idiap_devtools/logging.py b/src/idiap_devtools/logging.py index aa8f79a6a34bea4225d2eb8c3ed9484be1ac364f..b5d100e74aa8cf1e4bf466364522071f1b5f18dc 100644 --- a/src/idiap_devtools/logging.py +++ b/src/idiap_devtools/logging.py @@ -11,7 +11,8 @@ import typing # debug and info messages are written to sys.stdout class _InfoFilter(logging.Filter): """Filter-class to delete any log-record with level above - :any:`logging.INFO` **before** reaching the handler.""" + :any:`logging.INFO` **before** reaching the handler. + """ def __init__(self): super().__init__() @@ -22,11 +23,11 @@ class _InfoFilter(logging.Filter): def setup( logger_name: str, - format: str = "[%(levelname)s][%(name)s][%(asctime)s] %(message)s", + format_: str = "[%(levelname)s][%(name)s][%(asctime)s] %(message)s", low_level_stream: typing.TextIO = sys.stdout, high_level_stream: typing.TextIO = sys.stderr, ) -> logging.Logger: - """This function returns a logger object that is ready for console logging. + """Return a logger object that is ready for console logging. Retrieves (as with :py:func:`logging.getLogger()`) the given logger, and then attaches 2 handlers (defined on the module) to it: @@ -49,7 +50,7 @@ def setup( logger_name: The name of the module to generate logs for - format: The format of the logs, see :py:class:`logging.LogRecord` for + format_: The format of the logs, see :py:class:`logging.LogRecord` for more details. By default, the log contains the logger name, the log time, the log level and the massage. @@ -59,15 +60,15 @@ def setup( above - Returns: - + Returns + ------- The configured logger. The same logger can be retrieved using the :py:func:`logging.getLogger` function. """ logger = logging.getLogger(logger_name) - formatter = logging.Formatter(format) + formatter = logging.Formatter(format_) handlers_installed = {k.name: k for k in logger.handlers} debug_logger_name = f"debug_info+{logger_name}" diff --git a/src/idiap_devtools/profile.py b/src/idiap_devtools/profile.py index 2e49c2f46f20bc3d88156bc8fc3e50c8d0f007d6..b242241344b342f2453797c3c754ae230a78efdd 100644 --- a/src/idiap_devtools/profile.py +++ b/src/idiap_devtools/profile.py @@ -3,7 +3,6 @@ # SPDX-License-Identifier: BSD-3-Clause import io -import os import pathlib import typing @@ -23,14 +22,14 @@ USER_CONFIGURATION = xdg.xdg_config_home() / "idiap-devtools.toml" """The default location for the user configuration file.""" -def load(dir: pathlib.Path) -> dict[str, typing.Any]: - """Loads a profile TOML file, returns a dictionary with contents.""" - with (dir / "profile.toml").open("rb") as f: +def load(directory: pathlib.Path) -> dict[str, typing.Any]: + """Load a profile TOML file, returns a dictionary with contents.""" + with (directory / "profile.toml").open("rb") as f: return tomli.load(f) def get_profile_path(name: str | pathlib.Path) -> pathlib.Path | None: - """Returns the local directory of the named profile. + """Return the local directory of the named profile. If the input name corresponds to an existing directory, then that is returned. Otherwise, we lookup the said name inside the user @@ -44,19 +43,19 @@ def get_profile_path(name: str | pathlib.Path) -> pathlib.Path | None: existing path, or any name from the user configuration file. - Returns: - + Returns + ------- Either ``None``, if the profile cannot be found, or a verified path, if one is found. """ path = pathlib.Path(name) - if path.exists() and os.path.isdir(path): + if path.exists() and path.is_dir(): logger.debug(f"Returning path to profile {str(path)}...") return path # makes the user move the configuration file quickly! - if os.path.exists(OLD_USER_CONFIGURATION): + if OLD_USER_CONFIGURATION.exists(): raise RuntimeError( f"Move your configuration from " f"{str(OLD_USER_CONFIGURATION)} to {str(USER_CONFIGURATION)}, " @@ -65,11 +64,11 @@ def get_profile_path(name: str | pathlib.Path) -> pathlib.Path | None: # if you get to this point, then no local directory with that name exists # check the user configuration for a specific key - if os.path.exists(USER_CONFIGURATION): + if USER_CONFIGURATION.exists(): logger.debug( f"Loading user-configuration from {str(USER_CONFIGURATION)}..." ) - with open(USER_CONFIGURATION, "rb") as f: + with USER_CONFIGURATION.open("rb") as f: usercfg = tomli.load(f) else: usercfg = {} @@ -89,7 +88,7 @@ def get_profile_path(name: str | pathlib.Path) -> pathlib.Path | None: ) return None - return pathlib.Path(os.path.expanduser(value)) + return pathlib.Path(value).expanduser() class Profile: @@ -124,7 +123,7 @@ class Profile: ) -> typing.Any: # Using Any as type, as either flake8, mypy, or sphinx # will complain about conda otherwise. Will anyway be fixed when # resolving https://gitlab.idiap.ch/software/idiap-devtools/-/issues/3 - """Builds the conda-configuration to use based on the profile. + """Build the conda-configuration to use based on the profile. Arguments: @@ -166,9 +165,8 @@ class Profile: # incorporate constraints, if there are any constraints = self.data.get("conda", {}).get("constraints") if constraints is not None: - if not os.path.isabs(constraints): - constraints = self._basedir / pathlib.Path(constraints) - condarc_options["variant_config_files"] = str(constraints) + constraints_path = self._basedir / pathlib.Path(constraints) + condarc_options["variant_config_files"] = str(constraints_path) # detect append-file, if any copy_files = self.data.get("conda", {}).get("build-copy") @@ -177,7 +175,9 @@ class Profile: k for k in copy_files if k.endswith("recipe_append.yaml") ] if append_file: - condarc_options["append_sections_file"] = append_file[0] + condarc_options["append_sections_file"] = str( + self._basedir / append_file[0] + ) condarc_options["python"] = python @@ -190,7 +190,7 @@ class Profile: return make_conda_config(condarc_options) def python_indexes(self, public: bool, stable: bool) -> list[str]: - """Returns Python indexes to be used according to the current profile. + """Return Python indexes to be used according to the current profile. Arguments: @@ -212,7 +212,7 @@ class Profile: def get( self, key: str | typing.Iterable[str], default: typing.Any = None ) -> typing.Any: - """Reads the contents of a certain toml profile variable.""" + """Read the contents of a certain toml profile variable.""" if isinstance(key, str): return self.data.get(key, default) @@ -230,7 +230,7 @@ class Profile: key: str | typing.Iterable[str], default: None | pathlib.Path = None, ) -> pathlib.Path | None: - """Reads the contents of path from the profile and resolves it. + """Read the contents of path from the profile and resolves it. This function will search for a given profile key, consider it points to a path (relative or absolute) and will return that resolved path to @@ -245,8 +245,8 @@ class Profile: does not exist within the profile. - Returns: - + Returns + ------- The selected profile file path, or the contents of ``default`` otherwise. """ @@ -269,7 +269,7 @@ class Profile: def get_file_contents( self, key: str | typing.Iterable[str], default: None | str = None ) -> str | None: - """Reads the contents of a file from the profile. + """Read the contents of a file from the profile. This function will search for a given profile key, consider it points to a filename (relative or absolute) and will read its contents, @@ -284,8 +284,8 @@ class Profile: does not exist within the profile. - Returns: - + Returns + ------- The contents of the selected profile file, or the contents of ``default`` otherwise. """ @@ -294,7 +294,7 @@ class Profile: return path.open().read() if path is not None else default def conda_constraints(self, python: str) -> dict[str, str] | None: - """Returns a list of conda constraints given the current profile. + """Return a list of conda constraints given the current profile. Arguments: @@ -326,7 +326,7 @@ class Profile: } def python_constraints(self) -> list[pkg_resources.Requirement] | None: - """Returns a list of Python requirements given the current profile.""" + """Return a list of Python requirements given the current profile.""" content = self.get_file_contents(("python", "constraints")) if content is None: diff --git a/src/idiap_devtools/python.py b/src/idiap_devtools/python.py index 64b9e66bcd0514694041f5aac54074a5eb863d47..67aacab73683b3798fe8d4790c414acc392450c2 100644 --- a/src/idiap_devtools/python.py +++ b/src/idiap_devtools/python.py @@ -2,33 +2,35 @@ # # SPDX-License-Identifier: BSD-3-Clause -import pkg_resources +import pathlib + +import packaging.requirements import tomli def dependencies_from_pyproject_toml( - path: str, -) -> tuple[str, list[pkg_resources.Requirement]]: - """Returns a list with all ``project.optional-dependencies`` + path: pathlib.Path, +) -> tuple[str, list[packaging.requirements.Requirement]]: + """Return a list with all ``project.optional-dependencies``. Arguments: path: The path to a ``pyproject.toml`` file to load - Returns: - + Returns + ------- A list of optional dependencies (if any exist) on the provided python project. """ - data = tomli.load(open(path, "rb")) + data = tomli.load(path.open("rb")) deps = data.get("project", {}).get("dependencies", []) optional_deps = data.get("project", {}).get("optional-dependencies", {}) - retval = list(pkg_resources.parse_requirements(deps)) + retval = [packaging.requirements.Requirement(k) for k in deps] for v in optional_deps.values(): - retval += list(pkg_resources.parse_requirements(v)) + retval += [packaging.requirements.Requirement(k) for k in v] return data.get("project", {}).get("name", "UNKNOWN"), retval diff --git a/src/idiap_devtools/scripts/cli.py b/src/idiap_devtools/scripts/cli.py index 5c9a87c2e0c35b43284618f1753951b3efce5882..55c17e9eb3997c398f260fea5836607f26f65eba 100644 --- a/src/idiap_devtools/scripts/cli.py +++ b/src/idiap_devtools/scripts/cli.py @@ -16,7 +16,7 @@ from .update_pins import update_pins context_settings=dict(help_option_names=["-?", "-h", "--help"]), ) def cli(): - """Idiap development tools - see available commands below""" + """Idiap development tools - see available commands below.""" pass diff --git a/src/idiap_devtools/scripts/env.py b/src/idiap_devtools/scripts/env.py index 784633a92174d9c91073dfb7c78ab4aaf655858c..66347648a56bf1d030df4517dece2e7a8d7628f4 100644 --- a/src/idiap_devtools/scripts/env.py +++ b/src/idiap_devtools/scripts/env.py @@ -2,6 +2,7 @@ # # SPDX-License-Identifier: BSD-3-Clause +import pathlib import sys import typing @@ -15,9 +16,9 @@ logger = setup(__name__.split(".", 1)[0]) def _load_conda_packages( - meta: list[str], conda_config: typing.Any -) -> tuple[list[str], list[str], list[str]]: - """Loads dependence packages by scanning conda recipes. + meta: list[pathlib.Path], conda_config: typing.Any +) -> tuple[list[pathlib.Path], list[str], list[str]]: + """Load dependence packages by scanning conda recipes. Input entries in ``meta`` may correspond to various types of entries, out of which, we will not ignore in this function: @@ -36,8 +37,8 @@ def _load_conda_packages( determining packages to install and constraints. - Returns: - + Returns + ------- A list of non-consumed elements from the ``meta`` list, the list of parsed package names, and finally the list of dependencies from the parsed recipes. @@ -52,10 +53,10 @@ def _load_conda_packages( conda_packages = [] for m in meta: - if m.endswith("meta.yaml"): + if m.name == "meta.yaml": # user has passed the full path to the file # we can consume this from the input list - recipe_dir = os.path.dirname(m) + recipe_dir = str(m.parent) logger.info(f"Parsing conda recipe at {recipe_dir}...") pkg_name, pkg_deps = conda.parse_dependencies( recipe_dir, conda_config @@ -67,10 +68,10 @@ def _load_conda_packages( conda_packages += pkg_deps consumed.append(m) - elif os.path.exists(os.path.join(m, "conda", "meta.yaml")): + elif (m / "conda" / "meta.yaml").exists(): # it is the root of a project # may need to parse it for python packages later on - recipe_dir = os.path.join(m, "conda") + recipe_dir = str(m / "conda") logger.info(f"Parsing conda recipe at {recipe_dir}...") pkg_name, pkg_deps = conda.parse_dependencies( recipe_dir, conda_config @@ -81,11 +82,11 @@ def _load_conda_packages( parsed_packages.append(pkg_name) conda_packages += pkg_deps - elif not os.path.exists(m) and os.sep not in m: + elif not m.exists() and os.sep not in str(m): # it is a conda package name, add to list of packages to install # we can consume this from the input list logger.info(f"Adding conda package {m}...") - conda_packages.append(m) + conda_packages.append(str(m)) consumed.append(m) meta = [k for k in meta if k not in consumed] @@ -105,10 +106,10 @@ def _load_conda_packages( def _load_python_packages( the_profile: Profile, python: str, - meta: list[str], + meta: list[pathlib.Path], conda_pkgs: list[str], -) -> tuple[list[str], list[str], list[str]]: - """Loads dependence packages by scanning Python recipes. +) -> tuple[list[pathlib.Path], list[str], list[str]]: + """Load dependence packages by scanning Python recipes. Input entries in ``meta`` may correspond to various types of entries, out of which, we will not ignore in this function: @@ -132,16 +133,14 @@ def _load_python_packages( equivalents for those. - Returns: - + Returns + ------- A list of non-consumed elements from the ``meta`` list, the list of pure-Python dependencies from the parsed recipes, that are not at ``conda_pkgs`` and have no conda equivalents, and finally, an extension to the list of conda packages that can be installed that way. """ - import os - from .. import python as pyutils from .. import utils @@ -153,10 +152,10 @@ def _load_python_packages( python_packages = [] for m in meta: - if m.endswith("pyproject.toml"): + if m.name == "pyproject.toml": # user has passed the full path to the file # we can consume this from the input list - logger.info(f"Parsing Python package at {m}...") + logger.info(f"Parsing Python package at {str(m)}...") pkg_name, pkg_deps = pyutils.dependencies_from_pyproject_toml(m) logger.info( f"Added {len(pkg_deps)} Python packages from package '{pkg_name}'", @@ -165,10 +164,10 @@ def _load_python_packages( python_packages += pkg_deps consumed.append(m) - elif os.path.exists(os.path.join(m, "pyproject.toml")): + elif (m / "pyproject.toml").exists(): # it is the root of a project - proj = os.path.join(m, "pyproject.toml") - logger.info(f"Parsing Python package at {proj}...") + proj = m / "pyproject.toml" + logger.info(f"Parsing Python package at {str(proj)}...") pkg_name, pkg_deps = pyutils.dependencies_from_pyproject_toml(proj) logger.info( f"Added {len(pkg_deps)} Python packages from package '{pkg_name}'", @@ -185,24 +184,20 @@ def _load_python_packages( if conda_constraints is None: conda_constraints = {} - constrained = [k for k in python_packages if k.specs] - unconstrained = [k for k in python_packages if not k.specs] + constrained = [k for k in python_packages if k.specifier] + unconstrained = [k for k in python_packages if not k.specifier] has_conda = [ - f"{k.project_name} {conda_constraints[k.project_name]}" + f"{k.name} {conda_constraints[k.name]}" for k in unconstrained - if k.project_name in conda_constraints - ] - no_conda = [ - k for k in unconstrained if k.project_name not in conda_constraints + if k.name in conda_constraints ] + no_conda = [k for k in unconstrained if k.name not in conda_constraints] # we should install all packages that have not been parsed yet, and have no # conda equivalent via Python/pip python_packages_str = [ - str(k) - for k in constrained + no_conda - if k.project_name not in parsed_packages + str(k) for k in constrained + no_conda if k.name not in parsed_packages ] # now we sort and make it unique @@ -233,7 +228,7 @@ def _simplify_conda_plan(deps: list[str]) -> list[str]: def _add_missing_conda_pins( the_profile: Profile, python: str, deps: list[str] ) -> list[str]: - """Adds pins to unpinned packages, to respect the profile.""" + """Add pins to unpinned packages, to respect the profile.""" from .. import utils @@ -308,6 +303,7 @@ Examples: "meta", nargs=-1, required=True, + type=click.Path(path_type=pathlib.Path), ) @click.option( "-P", @@ -346,6 +342,7 @@ Examples: default="environment.yaml", show_default=True, help="The name of the environment plan file", + type=click.Path(path_type=pathlib.Path), ) @verbosity_option(logger=logger) def env( @@ -357,26 +354,21 @@ def env( output, **_, ) -> None: - """Creates a development environment for one or more projects. + """Create a development environment for one or more projects. - The environment is created by scanning conda's ``meta.yaml`` and - Python - ``pyproject.toml`` files for all input projects. All input that is - not an + The environment is created by scanning conda's ``meta.yaml`` and Python + ``pyproject.toml`` files for all input projects. All input that is not an existing file path, is considered a supplemental conda package to be installed. The environment is dumped to disk in the form of a - conda-installable YAML environment. The user may edit this file to - add + conda-installable YAML environment. The user may edit this file to add Python packages that may be of interest. - To interpret ``meta.yaml`` files found on the input directories, - this - command uses the conda render API to discover all profile- - constrained and + To interpret ``meta.yaml`` files found on the input directories, this + command uses the conda render API to discover all profile- constrained and unconstrained packages to add to the new environment. """ - import os + import shutil import yaml @@ -401,7 +393,7 @@ def env( if leftover_meta: logger.error( f"Ended parsing with unconsumed entries from the command-line: " - f"{' ,'.join(leftover_meta)}" + f"{' ,'.join(str(leftover_meta))}" ) # Adds python on the required version @@ -434,13 +426,11 @@ def env( data["dependencies"] = conda_packages # backup previous installation plan, if one exists - if os.path.exists(output): - backup = output + "~" - if os.path.exists(backup): - os.unlink(backup) - os.rename(output, backup) + if output.exists(): + backup = output.parent / (output.name + "~") + shutil.copy(output, backup) - with open(output, "w") as f: + with output.open("w") as f: import math yaml.dump(data, f, width=math.inf) diff --git a/src/idiap_devtools/scripts/fullenv.py b/src/idiap_devtools/scripts/fullenv.py index bfa83c5f9029c93c1b08c01448a609ab7aed8fee..a39bdf21d0fb7ec2f179606844e268025d5d3983 100644 --- a/src/idiap_devtools/scripts/fullenv.py +++ b/src/idiap_devtools/scripts/fullenv.py @@ -2,6 +2,7 @@ # # SPDX-License-Identifier: BSD-3-Clause +import pathlib import sys import click @@ -77,6 +78,7 @@ Examples: default="environment.yaml", show_default=True, help="The name of the environment plan file", + type=click.Path(path_type=pathlib.Path), ) @verbosity_option(logger=logger) def fullenv( @@ -86,9 +88,9 @@ def fullenv( output, **_, ) -> None: - """Creates a development environment with all constrained packages.""" + """Create a development environment with all constrained packages.""" - import os + import shutil import typing import yaml @@ -137,13 +139,11 @@ def fullenv( ) # backup previous installation plan, if one exists - if os.path.exists(output): - backup = output + "~" - if os.path.exists(backup): - os.unlink(backup) - os.rename(output, backup) + if output.exists(): + backup = output.parent / (output.name + "~") + shutil.copy(output, backup) - with open(output, "w") as f: + with output.open("w") as f: yaml.dump(data, f) click.echo( diff --git a/src/idiap_devtools/scripts/gitlab/badges.py b/src/idiap_devtools/scripts/gitlab/badges.py index 22968f59a73a6dd5c090ac8d8bbb0c03aa57ff4b..e0d01364dbc011f6f6388cda75adda6b91568a7a 100644 --- a/src/idiap_devtools/scripts/gitlab/badges.py +++ b/src/idiap_devtools/scripts/gitlab/badges.py @@ -71,7 +71,7 @@ README_BADGES = [ def _update_readme(content, info): - """Updates the README content provided, replacing badges.""" + """Update the README content provided, replacing badges.""" import re new_badges_text = [] @@ -123,7 +123,8 @@ Examples: ) @verbosity_option(logger=logger) def badges(package, update_readme, dry_run, server, **_) -> None: - """Creates stock badges for a project repository.""" + """Create stock badges for a project repository.""" + import typing import gitlab @@ -202,7 +203,7 @@ def badges(package, update_readme, dry_run, server, **_) -> None: logger.info("All done.") except gitlab.GitlabGetError: - logger.warn( + logger.warning( "Gitlab access error - package %s does not exist?", package, exc_info=True, diff --git a/src/idiap_devtools/scripts/gitlab/changelog.py b/src/idiap_devtools/scripts/gitlab/changelog.py index d286bdcd7b86327b9b4e7a448287b20c15c70c62..7da8e7ab111c6fb453860c03eaf90f7a748d9e3f 100644 --- a/src/idiap_devtools/scripts/gitlab/changelog.py +++ b/src/idiap_devtools/scripts/gitlab/changelog.py @@ -86,31 +86,24 @@ Examples: ) @verbosity_option(logger=logger) def changelog(target, output, mode, since, **_) -> None: - """Generates changelog file for package(s) from the Gitlab server. + """Generate changelog file for package(s) from the Gitlab server. - This script generates changelogs for either a single package or - multiple - packages, depending on the value of TARGET. The changelog (in - markdown + This script generates changelogs for either a single package or multiple + packages, depending on the value of TARGET. The changelog (in markdown format) is written to the output file CHANGELOG. - There are two modes of operation: you may provide the package name - in the + There are two modes of operation: you may provide the package name in the format ``<gitlab-group>/<package-name>``. Or, optionally, provide an - existing file containing a list of packages that will be iterated - on. - - For each package, we will contact the Gitlab server and create a - changelog - using merge-requests (default), tags or commits since a given date. - If a - starting date is not passed, we'll use the date of the last tagged - value or - the date of the first commit, if no tags are available in the - package. + existing file containing a list of packages that will be iterated on. + + For each package, we will contact the Gitlab server and create a changelog + using merge-requests (default), tags or commits since a given date. If a + starting date is not passed, we'll use the date of the last tagged value or + the date of the first commit, if no tags are available in the package. """ + import datetime - import os + import pathlib from ...gitlab import get_gitlab_instance from ...gitlab.changelog import ( @@ -123,9 +116,10 @@ def changelog(target, output, mode, since, **_) -> None: # reads package list or considers name to be a package name for tgt in target: - if os.path.exists(tgt) and os.path.isfile(tgt): + tgt_path = pathlib.Path(tgt) + if tgt_path.exists() and tgt_path.is_file(): logger.info(f"Reading package names from file {tgt}...") - with open(tgt) as f: + with tgt_path.open() as f: packages = [ k.strip() for k in f.readlines() diff --git a/src/idiap_devtools/scripts/gitlab/getpath.py b/src/idiap_devtools/scripts/gitlab/getpath.py index 4e0d763eefc74dd26166d89d80ee6ae4fc886d63..93b2a1ce721cb08443c384a2480ab51a31809241 100644 --- a/src/idiap_devtools/scripts/gitlab/getpath.py +++ b/src/idiap_devtools/scripts/gitlab/getpath.py @@ -43,7 +43,7 @@ Examples: ) @verbosity_option(logger=logger) def getpath(package, path, output, ref, **_) -> None: - """Downloads files and directories from gitlab. + """Download files and directories from gitlab. Files are downloaded and stored. Directories are recursed and fully downloaded to the client. diff --git a/src/idiap_devtools/scripts/gitlab/jobs.py b/src/idiap_devtools/scripts/gitlab/jobs.py index a69e3d96d89871e73d55568ca5358e7add361b3e..94ed08f9b1146f31890300f727fe0cf56ec8e86d 100644 --- a/src/idiap_devtools/scripts/gitlab/jobs.py +++ b/src/idiap_devtools/scripts/gitlab/jobs.py @@ -49,7 +49,7 @@ Examples: ) @verbosity_option(logger=logger) def jobs(status, tags, **_) -> None: - """Lists jobs on a given runner identified by description.""" + """List jobs on a given runner identified by description.""" from ...gitlab import get_gitlab_instance gl = get_gitlab_instance() diff --git a/src/idiap_devtools/scripts/gitlab/lasttag.py b/src/idiap_devtools/scripts/gitlab/lasttag.py index 936b894942d41225f1e6d553c3e9a3b83e56a6be..e9dcfa206445bd5e41d391df1fda1709b1411d6d 100644 --- a/src/idiap_devtools/scripts/gitlab/lasttag.py +++ b/src/idiap_devtools/scripts/gitlab/lasttag.py @@ -33,7 +33,7 @@ Examples: @click.argument("package") @verbosity_option(logger=logger) def lasttag(package, **_) -> None: - """Returns the last tag information on a given PACKAGE.""" + """Return the last tag information on a given PACKAGE.""" import gitlab from ...gitlab import get_gitlab_instance diff --git a/src/idiap_devtools/scripts/gitlab/runners.py b/src/idiap_devtools/scripts/gitlab/runners.py index 217229dad3c60ff16a982a76faba7185790b245f..c1786717176336471746edec40ee8338b9c8927e 100644 --- a/src/idiap_devtools/scripts/gitlab/runners.py +++ b/src/idiap_devtools/scripts/gitlab/runners.py @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -import os +import pathlib import click @@ -57,13 +57,12 @@ Examples: ) @verbosity_option(logger) def enable(name, targets, group, dry_run, **_) -> None: - """Enables runners on whole gitlab groups or single projects. + """Enable runners on whole gitlab groups or single projects. You may provide project names (like "group/project"), whole groups, and files containing list of projects to enable at certain runner at. """ - import pathlib from ...gitlab import get_gitlab_instance from ...gitlab.runners import ( @@ -80,7 +79,7 @@ def enable(name, targets, group, dry_run, **_) -> None: packages = [] for target in targets: - if os.path.exists(target): # it is a file with project names + if pathlib.Path(target).exists(): # it is a file with project names packages += get_projects_from_file(gl, pathlib.Path(target)) elif not group: # it is a specific project @@ -101,7 +100,7 @@ def enable(name, targets, group, dry_run, **_) -> None: enabled = False for ll in k.runners.list(all=True): if ll.id == the_runner.id: # it is there already - logger.warn( + logger.warning( "Runner %s (id=%d) is already enabled for project %s", ll.attributes["description"], ll.id, @@ -184,7 +183,6 @@ def disable(name, targets, dry_run, **_) -> None: files containing list of projects to load or omit the last argument, in which case all projects using this runner will be affected. """ - import pathlib from ...gitlab import get_gitlab_instance from ...gitlab.runners import ( @@ -205,7 +203,7 @@ def disable(name, targets, dry_run, **_) -> None: if "/" in target: # it is a specific project packages.append(get_project(gl, target)) - elif os.path.exists(target): # it is a file with project names + elif pathlib.Path(target).exists(): # it is a file with project names packages += get_projects_from_file(gl, pathlib.Path(target)) elif isinstance(target, str) and target: # it is a group @@ -263,6 +261,7 @@ def disable(name, targets, dry_run, **_) -> None: @runners.command( + name="list", cls=PreserveIndentCommand, epilog=""" Examples: @@ -277,8 +276,8 @@ Examples: ) @click.argument("name") @verbosity_option(logger=logger) -def list(name, **_) -> None: - """Lists projects a runner is associated to.""" +def list_(name, **_) -> None: + """List projects a runner is associated to.""" from ...gitlab import get_gitlab_instance from ...gitlab.runners import get_runner_from_description diff --git a/src/idiap_devtools/scripts/gitlab/settings.py b/src/idiap_devtools/scripts/gitlab/settings.py index ed6f8660adb8159c969c71f4f8938aba52735057..9107f2776c5659dbec33ec08a9578863407d4e08 100644 --- a/src/idiap_devtools/scripts/gitlab/settings.py +++ b/src/idiap_devtools/scripts/gitlab/settings.py @@ -2,6 +2,7 @@ # # SPDX-License-Identifier: BSD-3-Clause +import pathlib import typing import click @@ -15,7 +16,7 @@ logger = setup(__name__.split(".", 1)[0]) def _change_settings( project, info: dict[str, typing.Any], dry_run: bool ) -> None: - """Updates the project settings using ``info``""" + """Update the project settings using ``info``.""" name = f"{project.namespace['full_path']}/{project.name}" click.echo(f"Changing {name}...") @@ -41,7 +42,7 @@ def _change_settings( if info.get("avatar") is not None: click.secho(f" -> setting avatar to '{info['avatar']}'", bold=True) if not dry_run: - project.avatar = open(info["avatar"], "rb") + project.avatar = pathlib.Path(info["avatar"]).open("rb") project.save() @@ -104,9 +105,7 @@ Examples: def settings( projects, avatar, description, group, archive, dry_run, **_ ) -> None: - """Updates project settings.""" - - import os + """Update project settings.""" from ...gitlab import get_gitlab_instance from ...gitlab.runners import ( @@ -126,7 +125,7 @@ def settings( gl_projects = [] for target in projects: - if os.path.exists(target): # it is a file with project names + if pathlib.Path(target).exists(): # it is a file with project names gl_projects += get_projects_from_file(gl, target) elif not group: # it is a specific project diff --git a/src/idiap_devtools/scripts/update_pins.py b/src/idiap_devtools/scripts/update_pins.py index 362c7e305382f2169a3f053822b75912727ea5b9..94a4669df7e5561c36fa424407b491c103ec8e00 100644 --- a/src/idiap_devtools/scripts/update_pins.py +++ b/src/idiap_devtools/scripts/update_pins.py @@ -65,7 +65,7 @@ Examples: ) @verbosity_option(logger=logger) def update_pins(manual_pins, profile, python, only_pip, **_) -> None: - """Updates pip/mamba/conda package constraints (requires conda) + """Update pip/mamba/conda package constraints (requires conda). The update is done by checking-up conda-forge and trying to create an environment with all packages listed on the current conda @@ -93,7 +93,7 @@ def update_pins(manual_pins, profile, python, only_pip, **_) -> None: bold=True, fg="red", ) - return + return None # 2. loads the current conda pins packages, package_names_map = load_packages_from_conda_build_config( @@ -110,7 +110,7 @@ def update_pins(manual_pins, profile, python, only_pip, **_) -> None: bold=True, fg="red", ) - return + return None click.secho( f"Copying pins from {str(conda_config_path)} to " @@ -177,12 +177,12 @@ def update_pins(manual_pins, profile, python, only_pip, **_) -> None: with conda_config_path.open("rt") as f: content = f.read() - START = """ + start = """ # AUTOMATIC PARSING START # DO NOT MODIFY THIS COMMENT # list all packages with dashes or dots in their names, here:""" - idx1 = content.find(START) + idx1 = content.find(start) idx2 = content.find("# AUTOMATIC PARSING END") pins = "\n".join( f'{reversed_package_names_map.get(name, name)}:\n - "{version}"' @@ -192,7 +192,7 @@ def update_pins(manual_pins, profile, python, only_pip, **_) -> None: f" {k}: {v}" for k, v in package_names_map.items() ) - new_content = f"""{START} + new_content = f"""{start} package_names_map: {package_names_map_str} @@ -210,7 +210,7 @@ package_names_map: f"No pip-constraints at profile `{profile}' - not updating...", bold=True, ) - return + return None with pip_constraints_path.open("w") as f: python_packages = filter_python_packages( @@ -225,3 +225,5 @@ package_names_map: f"{name}=={version}\n" for name, version in python_packages ] f.writelines(constraints) + + return None diff --git a/src/idiap_devtools/update_pins.py b/src/idiap_devtools/update_pins.py index 62020630c03c4d7241d96f7e9fcda2e12baa3bb9..f5ea2cfb1d9bdacc4769d1a4c444d3aefc89e8ff 100644 --- a/src/idiap_devtools/update_pins.py +++ b/src/idiap_devtools/update_pins.py @@ -2,83 +2,25 @@ # # SPDX-License-Identifier: BSD-3-Clause -import contextlib -import copy -import logging -import os import pathlib import typing +import click import requests -@contextlib.contextmanager -def _root_logger_protection(): - """Protects the root logger against spurious (conda) manipulation.""" - root_logger = logging.getLogger() - level = root_logger.level - handlers = copy.copy(root_logger.handlers) - - yield - - root_logger.setLevel(level) - root_logger.handlers = handlers - - -def _make_conda_config(config, python, append_file, condarc_options): - """Creates a conda configuration for a build merging various sources. - - This function will use the conda-build API to construct a configuration by - merging different sources of information. - - Args: - - config: Path leading to the ``conda_build_config.yaml`` to use - python: The version of python to use for the build as ``x.y`` (e.g. - ``3.6``) - append_file: Path leading to the ``recipe_append.yaml`` file to use - condarc_options: A dictionary (typically read from a condarc YAML file) - that contains build and channel options - - Returns: A dictionary containing the merged configuration, as produced by - conda-build API's ``get_or_merge_config()`` function. - """ - with _root_logger_protection(): - from conda_build.api import get_or_merge_config - from conda_build.conda_interface import url_path - - retval = get_or_merge_config( - None, - variant_config_files=config, - python=python, - append_sections_file=append_file, - **condarc_options, - ) - - retval.channel_urls = [] # type: ignore - - for url in condarc_options["channels"]: - # allow people to specify relative or absolute paths to local channels - # These channels still must follow conda rules - they must have the - # appropriate platform-specific subdir (e.g. win-64) - if os.path.isdir(url): - if not os.path.isabs(url): - url = os.path.normpath( - os.path.abspath(os.path.join(os.getcwd(), url)) - ) - with _root_logger_protection(): - url = url_path(url) - retval.channel_urls.append(url) # type: ignore - - return retval - - def load_packages_from_conda_build_config( conda_build_config: pathlib.Path, condarc_options: dict[str, typing.Any], with_pins: bool = False, ) -> tuple[list[str], dict[str, str]]: - with open(conda_build_config) as f: + """Load packages listed in a conda recipe.""" + + from conda_build.metadata import ns_cfg, select_lines + + from .conda import make_conda_config + + with pathlib.Path(conda_build_config).open() as f: content = f.read() idx1 = content.find("# AUTOMATIC PARSING START") @@ -86,9 +28,8 @@ def load_packages_from_conda_build_config( content = content[idx1:idx2] # filter out using conda-build specific markers - from conda_build.metadata import ns_cfg, select_lines - - config = _make_conda_config(conda_build_config, None, None, condarc_options) + condarc_options["variant_config_files"] = [str(conda_build_config)] + config = make_conda_config(condarc_options) content = select_lines(content, ns_cfg(config), variants_in_place=False) import yaml @@ -113,7 +54,7 @@ def load_packages_from_conda_build_config( def filter_python_packages( resolved_packages, conda_to_python: dict[str, str | None] ): - """Filters the list of packages to return only Python packages available on + """Filter the list of packages to return only Python packages available on PyPI. This function will also perform name translation and de-duplication of @@ -127,14 +68,16 @@ def filter_python_packages( about. - Returns: - + Returns + ------- List of of packages and versions available on PyPI """ keep_list = [] - print(f"Filtering {len(resolved_packages)} packages for PyPI availability") + click.echo( + f"Filtering {len(resolved_packages)} packages for PyPI availability" + ) for p, v in resolved_packages: if p in conda_to_python["__ignore__"]: @@ -148,7 +91,7 @@ def filter_python_packages( if r.ok: keep_list.append((p, v)) else: - print(f"{p}@{v} NOT found - ignoring") + click.echo(f"{p}@{v} NOT found - ignoring") except requests.exceptions.RequestException: continue @@ -160,6 +103,8 @@ def update_pip_constraints_only( pip_constraints_path: pathlib.Path, conda_to_python: dict[str, typing.Any], ) -> None: + """Update pip constraints only.""" + packages, _ = load_packages_from_conda_build_config( conda_config_path, {"channels": []}, @@ -169,7 +114,7 @@ def update_pip_constraints_only( with pip_constraints_path.open("wt") as f: python_packages = filter_python_packages(package_pairs, conda_to_python) - print( + click.echo( f"Saving {len(python_packages)} entries to " f"`{pip_constraints_path}'..." ) diff --git a/src/idiap_devtools/utils.py b/src/idiap_devtools/utils.py index 4fe17b0b2458c691feb714b6b595d9fe7ca9f375..276779bf6e1ed3fe7742160531481c1ddc185516 100644 --- a/src/idiap_devtools/utils.py +++ b/src/idiap_devtools/utils.py @@ -24,10 +24,10 @@ _INTERVALS = ( def set_environment( name: str, value: str, env: dict[str, str] | os._Environ[str] = os.environ ) -> str: - """Function to setup the environment variable and print debug message. - - Parameters: + """Set up the environment variable and print debug message. + Parameters + ---------- name: The name of the environment variable to set value: The value to set the environment variable to @@ -35,8 +35,8 @@ def set_environment( env: Optional environment (dictionary) where to set the variable at - Returns: - + Returns + ------- The value just set. """ @@ -46,14 +46,14 @@ def set_environment( def human_time(seconds: int | float, granularity: int = 2) -> str: - """Returns a human readable time string like "1 day, 2 hours". + """Return a human readable time string like "1 day, 2 hours". This function will convert the provided time in seconds into weeks, days, hours, minutes and seconds. - Parameters: - + Parameters + ---------- seconds: The number of seconds to convert granularity: The granularity corresponds to how many elements will @@ -61,8 +61,8 @@ def human_time(seconds: int | float, granularity: int = 2) -> str: are output. - Returns: - + Returns + ------- A string, that contains the human readable time. """ result: list[str | None] = [] @@ -82,11 +82,11 @@ def human_time(seconds: int | float, granularity: int = 2) -> str: if not result: if seconds < 1.0: return "%.2f seconds" % seconds - else: - if seconds == 1: - return "1 second" - else: - return "%d seconds" % seconds + + if seconds == 1: + return "1 second" + + return "%d seconds" % seconds return ", ".join([x for x in result[:granularity] if x is not None]) @@ -96,10 +96,10 @@ def run_cmdline( logger: logging.Logger, **kwargs, ) -> int: - """Runs a command on a environment, logs output and reports status. - - Parameters: + """Run a command on a environment, logs output and reports status. + Parameters + ---------- cmd: The command to run, with parameters separated on a list of strings logger: A logger to log messages to console @@ -108,7 +108,9 @@ def run_cmdline( :py:class:`subprocess.Popen`. - Returns: + Returns + ------- + The exit status of the command. """ logger.info("(system) %s" % " ".join(cmd)) diff --git a/tests/test_release.py b/tests/test_release.py index 8b43933556e6783702730e8acec44ffaef7c790a..f7808b6965c8bc7e026aee1b77933e279eae1131 100644 --- a/tests/test_release.py +++ b/tests/test_release.py @@ -3,10 +3,8 @@ # SPDX-License-Identifier: BSD-3-Clause import pytest - -from pkg_resources import Requirement - from idiap_devtools.gitlab import release +from pkg_resources import Requirement def test_pinning_no_constraints(): @@ -38,7 +36,7 @@ def test_pinning_no_constraints(): ] # Actual call. Modifies pkgs in-place. - release._pin_versions_of_packages_list( + release._pin_versions_of_packages_list( # noqa: SLF001 packages_list=pkgs, dependencies_versions=constraints, ) @@ -67,7 +65,7 @@ def test_pinning_multiple_times(): pkgs = ["pkg-a"] with pytest.raises(NotImplementedError): - release._pin_versions_of_packages_list(pkgs, constraints) + release._pin_versions_of_packages_list(pkgs, constraints) # noqa: SLF001 def test_pinning_with_constraints(): @@ -76,16 +74,16 @@ def test_pinning_with_constraints(): pkgs = ["pkg-a == 2.0"] # Constraints can not be set in the packages list. with pytest.raises(ValueError): - release._pin_versions_of_packages_list(pkgs, constraints) + release._pin_versions_of_packages_list(pkgs, constraints) # noqa: SLF001 constraints = [Requirement("pkg-a == 1.2.3")] pkgs = ["pkg-a; sys_platform != 'darwin'"] # Neither can the markers with pytest.raises(ValueError): - release._pin_versions_of_packages_list(pkgs, constraints) + release._pin_versions_of_packages_list(pkgs, constraints) # noqa: SLF001 constraints = [Requirement("pkg-a == 1.2.3")] pkgs = ["pkg-a(extra)"] # Nor the extras with pytest.raises(ValueError): - release._pin_versions_of_packages_list(pkgs, constraints) + release._pin_versions_of_packages_list(pkgs, constraints) # noqa: SLF001