diff --git a/README.md b/README.md index 0ee80a8fdf..7264272214 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,27 @@ pypi: https://pypi.org/project/envoy.code_format.python_check --- +#### [envoy.dependency.check](envoy.dependency.check) + +version: 0.0.1 + +pypi: https://pypi.org/project/envoy.dependency.check + +##### requirements: + +- [abstracts](https://pypi.org/project/abstracts) >=0.0.12 +- [aio.api.github](https://pypi.org/project/aio.api.github) >=0.0.2 +- [aio.functional](https://pypi.org/project/aio.functional) >=0.0.9 +- [aio.tasks](https://pypi.org/project/aio.tasks) >=0.0.4 +- [aiohttp](https://pypi.org/project/aiohttp) +- [envoy.base.checker](https://pypi.org/project/envoy.base.checker) >=0.0.3 +- [envoy.base.utils](https://pypi.org/project/envoy.base.utils) >=0.0.13 +- [gidgethub](https://pypi.org/project/gidgethub) +- [packaging](https://pypi.org/project/packaging) + +--- + + #### [envoy.dependency.cve_scan](envoy.dependency.cve_scan) version: 0.0.2.dev0 diff --git a/deps/requirements.txt b/deps/requirements.txt index 01e816df64..e7fe8c5669 100644 --- a/deps/requirements.txt +++ b/deps/requirements.txt @@ -1,3 +1,4 @@ +aio.api.github>=0.0.2 aio.functional>=0.0.9 aio.stream>=0.0.2 aio.subprocess>=0.0.4 @@ -10,9 +11,9 @@ colorama coloredlogs docutils~=0.16.0 envoy.abstract.command>=0.0.3 -envoy.base.checker>=0.0.2 +envoy.base.checker>=0.0.3 envoy.base.runner>=0.0.4 -envoy.base.utils>=0.0.12 +envoy.base.utils>=0.0.13 envoy.code_format.python_check>=0.0.4 envoy.dependency.pip_check>=0.0.4 envoy.distribution.distrotest>=0.0.4 diff --git a/envoy.dependency.check/BUILD b/envoy.dependency.check/BUILD new file mode 100644 index 0000000000..6bcbc14d2d --- /dev/null +++ b/envoy.dependency.check/BUILD @@ -0,0 +1,2 @@ + +pytooling_package("envoy.dependency.check") diff --git a/envoy.dependency.check/README.rst b/envoy.dependency.check/README.rst new file mode 100644 index 0000000000..ec16e094cd --- /dev/null +++ b/envoy.dependency.check/README.rst @@ -0,0 +1,5 @@ + +envoy.dependency.check +====================== + +Dependency checker used in Envoy proxy's CI diff --git a/envoy.dependency.check/VERSION b/envoy.dependency.check/VERSION new file mode 100644 index 0000000000..8acdd82b76 --- /dev/null +++ b/envoy.dependency.check/VERSION @@ -0,0 +1 @@ +0.0.1 diff --git a/envoy.dependency.check/envoy/dependency/check/BUILD b/envoy.dependency.check/envoy/dependency/check/BUILD new file mode 100644 index 0000000000..6e398d54ca --- /dev/null +++ b/envoy.dependency.check/envoy/dependency/check/BUILD @@ -0,0 +1,23 @@ + +pytooling_library( + "envoy.dependency.check", + dependencies=[ + "//deps:abstracts", + "//deps:aio.functional", + "//deps:aio.api.github", + "//deps:aio.tasks", + "//deps:envoy.base.checker", + "//deps:envoy.base.utils", + "//deps:aiohttp", + "//deps:packaging", + ], + sources=[ + "abstract/__init__.py", + "abstract/checker.py", + "abstract/dependency.py", + "abstract/issues.py", + "abstract/release.py", + "abstract/typing.py", + "exceptions.py", + ], +) diff --git a/envoy.dependency.check/envoy/dependency/check/__init__.py b/envoy.dependency.check/envoy/dependency/check/__init__.py new file mode 100644 index 0000000000..94785db80d --- /dev/null +++ b/envoy.dependency.check/envoy/dependency/check/__init__.py @@ -0,0 +1,32 @@ + +from . import abstract, exceptions +from .abstract import ( + ADependencyChecker, + ADependencyGithubRelease, + AGithubDependency, + AGithubDependencyIssue, + AGithubDependencyIssues) +from .checker import ( + DependencyChecker, + DependencyGithubRelease, + GithubDependency, + GithubDependencyIssue, + GithubDependencyIssues) +from .cmd import cmd, main + + +__all__ = ( + "abstract", + "ADependencyChecker", + "ADependencyGithubRelease", + "AGithubDependency", + "AGithubDependencyIssue", + "AGithubDependencyIssues", + "cmd", + "DependencyChecker", + "DependencyGithubRelease", + "GithubDependency", + "GithubDependencyIssue", + "GithubDependencyIssues", + "exceptions", + "main") diff --git a/envoy.dependency.check/envoy/dependency/check/abstract/__init__.py b/envoy.dependency.check/envoy/dependency/check/abstract/__init__.py new file mode 100644 index 0000000000..5e4e5ca933 --- /dev/null +++ b/envoy.dependency.check/envoy/dependency/check/abstract/__init__.py @@ -0,0 +1,13 @@ + +from .checker import ADependencyChecker +from .issues import AGithubDependencyIssue, AGithubDependencyIssues +from .release import ADependencyGithubRelease +from .dependency import AGithubDependency + + +__all__ = ( + "AGithubDependency", + "ADependencyChecker", + "AGithubDependencyIssue", + "AGithubDependencyIssues", + "ADependencyGithubRelease") diff --git a/envoy.dependency.check/envoy/dependency/check/abstract/checker.py b/envoy.dependency.check/envoy/dependency/check/abstract/checker.py new file mode 100644 index 0000000000..def85ac29a --- /dev/null +++ b/envoy.dependency.check/envoy/dependency/check/abstract/checker.py @@ -0,0 +1,308 @@ +"""Abstract dependency checker.""" + +import abc +import argparse +import json +import os +import pathlib +from functools import cached_property +from typing import Optional, Tuple, Type + +import aiohttp + +import abstracts + +from aio.api import github + +from envoy.base import checker + +from envoy.dependency.check import abstract, exceptions +from . import typing + + +class ADependencyChecker( + checker.AsyncChecker, + metaclass=abstracts.Abstraction): + """Dependency checker.""" + + checks = ("dates", "issues", "releases") + + @property + @abc.abstractmethod + def access_token(self) -> Optional[str]: + """Github access token.""" + if self.args.github_token: + return pathlib.Path(self.args.github_token).read_text().strip() + return os.getenv('GITHUB_TOKEN') + + @cached_property + def dep_ids(self) -> Tuple[str, ...]: + """Tuple of dependency ids.""" + return tuple(dep.id for dep in self.dependencies) + + @cached_property + def dependencies(self) -> Tuple["abstract.AGithubDependency", ...]: + """Tuple of dependencies.""" + deps = [] + for k, v in self.dependency_metadata.items(): + try: + deps.append(self.github_dependency_class(k, v, self.github)) + except exceptions.NotGithubDependency as e: + self.log.info(e) + return tuple(sorted(deps)) + + @property + @abc.abstractmethod + def dependency_metadata(self) -> typing.DependenciesDict: + """Dependency metadata (derived in Envoy's case from + `repository_locations.bzl`).""" + return json.loads(self.repository_locations_path.read_text()) + + @cached_property + def github(self) -> github.GithubAPI: + """Github API.""" + return github.GithubAPI( + self.session, "", + oauth_token=self.access_token) + + @property # type:ignore + @abstracts.interfacemethod + def github_dependency_class(self) -> Type["abstract.AGithubDependency"]: + """Dependency class.""" + raise NotImplementedError + + @cached_property + def issues(self) -> "abstract.AGithubDependencyIssues": + """Dependency issues.""" + return self.issues_class(self.github) + + @property # type:ignore + @abstracts.interfacemethod + def issues_class(self) -> Type["abstract.AGithubDependencyIssues"]: + """Dependency issues class.""" + raise NotImplementedError + + @property + def repository_locations_path(self) -> pathlib.Path: + return pathlib.Path(self.args.repository_locations) + + @cached_property + def session(self) -> aiohttp.ClientSession: + """HTTP client session.""" + return aiohttp.ClientSession() + + @property + def sync_issues(self) -> bool: + """Flag to determine whether to sync issues, or just warn.""" + return self.args.sync_issues + + def add_arguments(self, parser: argparse.ArgumentParser) -> None: + super().add_arguments(parser) + parser.add_argument('--github_token') + parser.add_argument('--repository_locations') + parser.add_argument('--sync_issues', action="store_true") + + async def check_dates(self) -> None: + """Check recorded dates match for dependencies.""" + for dep in self.dependencies: + await self.dep_date_check(dep) + + async def check_issues(self) -> None: + """Check dependency issues.""" + await self.issues_labels_check() + for dep in self.dependencies: + await self.dep_issue_check(dep) + await self.issues_missing_dep_check() + await self.issues_duplicate_check() + + async def check_releases(self) -> None: + """Check dependencies for new releases.""" + for dep in self.dependencies: + await self.dep_release_check(dep) + + async def dep_date_check( + self, + dep: "abstract.AGithubDependency") -> None: + """Check dates for dependency.""" + if not await dep.release.date: + self.error( + "dates", + [f"{dep.id} is a GitHub repository with no no inferrable " + "release date"]) + elif await dep.release_date_mismatch: + self.error( + "dates", + [f"Date mismatch: {dep.id} " + f"{dep.release_date} != {await dep.release.date}"]) + else: + self.succeed( + "dates", + [f"Date matches({dep.release_date}): {dep.id}"]) + + async def dep_issue_check( + self, + dep: "abstract.AGithubDependency") -> None: + """Check issues for dependency.""" + issue = (await self.issues.dep_issues).get(dep.id) + newer_release = await dep.newer_release + if not newer_release: + if issue: + # There is an open issue, but the dep is already + # up-to-date. + self.warn( + "issues", + [f"Stale issue: {dep.id} #{issue.number}"]) + if self.sync_issues: + await self._dep_issue_close_stale(issue, dep) + else: + # No issue required + self.succeed( + "issues", + [f"No issue required: {dep.id}"]) + return + if issue: + if issue.version == (await dep.newer_release).version: + # Required issue exists + self.succeed( + "issues", + [f"Issue exists (#{issue.number}): {dep.id}"]) + return + # Existing issue is showing incorrect version + self.warn( + "issues", + [f"Out-of-date issue (#{issue.number}): {dep.id} " + f"({issue.version} -> {newer_release.version})"]) + else: + # Issue is required to be added + self.warn( + "issues", + [f"Missing issue: {dep.id} ({newer_release.version})"]) + if self.sync_issues: + await self._dep_issue_create(issue, dep) + + async def dep_release_check( + self, + dep: "abstract.AGithubDependency") -> None: + """Check releases for dependency.""" + newer_release = await dep.newer_release + if newer_release: + self.warn( + "releases", + [f"Newer release ({newer_release.tag_name}): {dep.id}\n" + f"{dep.release_date} " + f"{dep.github_version_name}\n" + f"{await newer_release.date} " + f"{newer_release.tag_name} "]) + elif await dep.has_recent_commits: + self.warn( + "releases", + [f"Recent commits ({await dep.recent_commits}): {dep.id}\n" + f"There have been {await dep.recent_commits} commits since " + f"{dep.github_version_name} landed on " + f"{dep.release_date}"]) + else: + self.succeed( + "releases", + [f"Up-to-date ({dep.github_version_name}): {dep.id}"]) + + async def issues_duplicate_check(self) -> None: + """Check for duplicate issues for dependencies.""" + duplicates = False + async for issue in self.issues.duplicate_issues: + duplicates = True + self.warn( + "issues", + [f"Duplicate issue for dependency (#{issue.number}): " + f"{issue.dep}"]) + if self.sync_issues: + await self._issue_close_duplicate(issue) + if not duplicates: + self.succeed( + "issues", + ["No duplicate issues found."]) + + async def issues_labels_check(self) -> None: + """Check expected labels are present.""" + missing = False + for label in await self.issues.missing_labels: + missing = True + # TODO: make this a warning if `sync_issues` and fix + self.error( + "issues", + [f"Missing label: {label}"]) + if not missing: + self.succeed( + "issues", + [f"All ({len(self.issues.labels)}) " + "required labels are available."]) + + async def issues_missing_dep_check(self) -> None: + """Check for missing dependencies for issues.""" + closed = False + issues = await self.issues.open_issues + for issue in issues: + if issue.dep not in self.dep_ids: + closed = True + self.warn( + "issues", + [f"Missing dependency (#{issue.number}): {issue.dep}"]) + if self.sync_issues: + await self._issue_close_missing_dep(issue) + if not closed: + self.succeed( + "issues", + [f"All ({len(issues)}) issues have current dependencies."]) + + async def on_checks_complete(self) -> int: + await self.session.close() + return await super().on_checks_complete() + + async def _dep_issue_close_stale( + self, + issue: "abstract.AGithubDependencyIssue", + dep: "abstract.AGithubDependency") -> None: + await issue.close() + self.log.notice( + f"Closed stale issue (#{issue.number}): {dep.id}\n" + f"{issue.title}\n{issue.body}") + + async def _dep_issue_create( + self, + issue: "abstract.AGithubDependencyIssue", + dep: "abstract.AGithubDependency") -> None: + if await self.issues.missing_labels: + self.error( + "issues", + [f"Unable to create issue for {dep.id}: missing labels"]) + return + new_issue = await self.issues.create(dep) + self.log.notice( + f"Created issue (#{new_issue.number}): " + f"{dep.id} {new_issue.version}\n" + f"{new_issue.title}\n{new_issue.body}") + if not issue: + return + await new_issue.close_old(issue, dep) + self.log.notice( + f"Closed old issue (#{issue.number}): " + f"{dep.id} {issue.version}\n" + f"{issue.title}\n{issue.body}") + + async def _issue_close_duplicate( + self, + issue: "abstract.AGithubDependencyIssue") -> None: + current_issue = (await self.issues.dep_issues)[issue.dep] + await current_issue.close_duplicate(issue) + self.log.notice( + f"Closed duplicate issue (#{issue.number}): {issue.dep}\n" + f" {issue.title}\n" + f"current issue #({current_issue.number}):\n" + f" {current_issue.title}") + + async def _issue_close_missing_dep( + self, + issue: "abstract.AGithubDependencyIssue") -> None: + """Close an issue that has no current dependency.""" + await issue.close() + self.log.notice( + f"Closed issue with no current dependency (#{issue.number})") diff --git a/envoy.dependency.check/envoy/dependency/check/abstract/dependency.py b/envoy.dependency.check/envoy/dependency/check/abstract/dependency.py new file mode 100644 index 0000000000..b4792ade0e --- /dev/null +++ b/envoy.dependency.check/envoy/dependency/check/abstract/dependency.py @@ -0,0 +1,168 @@ +"""Abstract dependency.""" + +from functools import cached_property +from typing import List, Optional, Type + +from packaging import version + +import abstracts + +from aio.functional import async_property + +from aio.api import github + +from envoy.dependency.check import abstract, exceptions +from . import typing + + +class AGithubDependency(metaclass=abstracts.Abstraction): + """Github dependency.""" + + def __init__( + self, + id: str, + metadata: "typing.DependencyMetadataDict", + github: github.AGithubAPI) -> None: + self.id = id + self.metadata = metadata + self.github = github + if not self.github_url: + urls = "\n".join(self.urls) + raise exceptions.NotGithubDependency( + f'{self.id} is not a GitHub repository\n{urls}') + + def __gt__(self, other: "AGithubDependency") -> bool: + return self.id > other.id + + def __lt__(self, other: "AGithubDependency") -> bool: + return self.id < other.id + + def __str__(self): + return f"{self.id}@{self.version}" + + @async_property(cache=True) + async def commits_since_current(self) -> int: + """Commits since current commit/tag.""" + count = await self.repo.commits( + since=await self.release.timestamp_commit).total_count + return count and count - 1 or count + + @cached_property + def github_url(self) -> Optional[str]: + """Github URL.""" + for url in self.urls: + if url.startswith('https://github.com/'): + return url + + @cached_property + def github_version(self) -> str: + """Github version, as parsed from the URL.""" + if self.url_components[5] != 'archive': + # Release tag is a path component. + # assert (components[5] == 'releases') + return self.url_components[7] + # Only support .tar.gz, .zip today. Figure out the release tag + # from this filename. + if self.url_components[-1].endswith('.tar.gz'): + return self.url_components[-1][:-len('.tar.gz')] + # assert (components[-1].endswith('.zip')) + return self.url_components[-1][:-len('.zip')] + + @property + def github_version_name(self) -> str: + """Github version, truncated to 7 char if its sha_hash.""" + return ( + self.github_version[0:7] + if not self.release.tagged + else self.github_version) + + @async_property + async def has_recent_commits(self) -> bool: + """Flag indicating whether there are more recent commits than the + current pinned commit.""" + return await self.recent_commits > 1 + + @async_property(cache=True) + async def newer_release( + self) -> Optional["abstract.ADependencyGithubRelease"]: + """Release with highest semantic version if newer than the current + release, or where pin is to tag or commit.""" + # TODO: consider adding `newer_tags` for deps that only create + # tags and not releases (eg tclap) + newer_release = await self.repo.highest_release( + since=await self.release.timestamp) + return ( + self.release_class( + self.repo, + newer_release.tag_name, + release=newer_release) + if (newer_release + and (version.parse(newer_release.tag_name) + != self.release.version)) + else None) + + @property + def organization(self) -> str: + """Github organization name.""" + return self.url_components[3] + + @property + def project(self) -> str: + """Github project name.""" + return self.url_components[4] + + @async_property(cache=True) + async def recent_commits(self) -> int: + """Count of commits since current pinned commit.""" + return ( + await self.commits_since_current + if not self.release.tagged + else 0) + + @cached_property + def release(self) -> "abstract.ADependencyGithubRelease": + """Github release.""" + return self.release_class(self.repo, self.github_version) + + @property # type:ignore + @abstracts.interfacemethod + def release_class(self) -> Type["abstract.ADependencyGithubRelease"]: + """Github release class.""" + raise NotImplementedError + + @property + def release_date(self) -> str: + """Release (or published) date of this dependency.""" + return self.metadata["release_date"] + + @async_property + async def release_date_mismatch(self) -> bool: + """Flag indicating the metadata date doesnt match the Github date.""" + return ( + self.release_date + != await self.release.date) + + @cached_property + def repo(self) -> github.AGithubRepo: + """Github repo for this dependency.""" + return self.github[f"{self.organization}/{self.project}"] + + @cached_property + def url_components(self) -> List[str]: + """Github URL components.""" + if not self.github_url: + urls = "\n".join(self.urls) + raise exceptions.NotGithubDependency( + f'{self.id} is not a GitHub repository\n{urls}') + # TODO: add/use a proper `GithubURLParser` + return self.github_url.split('/') + + @property + def urls(self) -> List[str]: + """Urls of this dependency.""" + return self.metadata["urls"] + + @property + def version(self) -> str: + """Version string of this dependency.""" + return self.metadata["version"] diff --git a/envoy.dependency.check/envoy/dependency/check/abstract/issues.py b/envoy.dependency.check/envoy/dependency/check/abstract/issues.py new file mode 100644 index 0000000000..497a348a1a --- /dev/null +++ b/envoy.dependency.check/envoy/dependency/check/abstract/issues.py @@ -0,0 +1,271 @@ + +import re +from functools import cached_property +from typing import ( + Any, AsyncGenerator, Dict, Optional, Pattern, Tuple, Type, Union) + +from packaging import version + +import abstracts + +from aio.functional import async_property + +from aio.api import github + +from envoy.dependency.check import abstract + + +GITHUB_REPO_LOCATION = "envoyproxy/envoy" +LABELS = ("dependencies", "area/build", "no stalebot") +BODY_TPL = """ +Package Name: {dep} +Current Version: {dep.github_version_name}@{release_date} +Available Version: {newer_release.tag_name}@{newer_release_date} +Upstream releases: https://github.com/{dep.release.repo.name}/releases +""" +CLOSING_TPL = """ +New version is available for this package +New Version: {newer_release.tag_name}@{newer_release_date} +Upstream releases: https://github.com/{full_name}/releases +New Issue Link: https://github.com/{repo_location}/issues/{number} +""" +ISSUES_SEARCH_TPL = "in:title {self.title_prefix} is:open" +TITLE_PREFIX = "Newer release available" +TITLE_RE_TPL = r"{title_prefix} [`]?([\w\-\.]+)[`]?: ([\w\-\.]+)" +TITLE_TPL = ( + "{title_prefix} `{dep.id}`: {newer_release.tag_name} " + "(current: {dep.github_version_name})") + + +class AGithubDependencyIssue(metaclass=abstracts.Abstraction): + """Github issue associated with a dependency.""" + + def __init__( + self, + issues: "abstract.AGithubDependencyIssues", + issue: github.AGithubIssue) -> None: + self.issues = issues + self.issue = issue + + @property + def body(self) -> str: + """Github issue body.""" + return self.issue.body + + @property + def closing_tpl(self) -> str: + """String template for closing comment.""" + return self.issues.closing_tpl + + @property + def dep(self) -> Optional[str]: + """Associated dependency id.""" + return self.parsed.get("dep") + + @property + def number(self) -> int: + """Github issue number.""" + return self.issue.number + + @cached_property + def parsed(self) -> Dict[str, str]: + """Parsed element from issue title.""" + parsed = self.title_re.search(self.title) + return ( + dict(dep=parsed.group(1), + version=parsed.group(2)) + if parsed + else {}) + + @property + def repo_name(self) -> str: + """Github repo name.""" + return self.issues.repo_name + + @property + def title(self) -> str: + """Github issue title.""" + return self.issue.title + + @property + def title_re(self) -> Pattern[str]: + return self.issues.title_re + + @cached_property + def version(self) -> Optional[ + Union[ + version.LegacyVersion, + version.Version]]: + """Version parsed from the title.""" + return ( + version.parse(self.parsed["version"]) + if "version" in self.parsed + else None) + + async def close(self) -> github.AGithubIssue: + """Close this issue.""" + return await self.issue.close() + + async def close_duplicate( + self, + old_issue: "AGithubDependencyIssue") -> None: + """Close a duplicate issue of this one.""" + # TODO: add "closed as duplicate" comment + await old_issue.close() + + async def close_old( + self, + old_issue: "AGithubDependencyIssue", + dep: "abstract.AGithubDependency") -> None: + """Close old associated issue.""" + # TODO: reassign any users old -> new issue + newer_release = await dep.newer_release + await old_issue.comment( + self.closing_tpl.format( + newer_release=newer_release, + newer_release_date=await newer_release.date, + full_name=dep.repo.name, + repo_location=self.repo_name, + number=self.number)) + await old_issue.close() + + async def comment(self, comment: str) -> Any: + """Comment on this issue.""" + return await self.issue.comment(comment) + + +class AGithubDependencyIssues(metaclass=abstracts.Abstraction): + """Github issues associated with dependencies.""" + + def __init__( + self, + github, + body_tpl: str = BODY_TPL, + closing_tpl: str = CLOSING_TPL, + issues_search_tpl: str = ISSUES_SEARCH_TPL, + labels: Tuple[str, ...] = LABELS, + repo_name: str = GITHUB_REPO_LOCATION, + title_prefix: str = TITLE_PREFIX, + title_re_tpl: str = TITLE_RE_TPL, + title_tpl: str = TITLE_TPL) -> None: + self.github = github + self.body_tpl = body_tpl + self.closing_tpl = closing_tpl + self.issues_search_tpl = issues_search_tpl + self.labels = labels + self.repo_name = repo_name + self.title_prefix = title_prefix + self.title_re_tpl = title_re_tpl + self.title_tpl = title_tpl + + async def __aiter__( + self) -> AsyncGenerator[ + AGithubDependencyIssue, + github.GithubIssue]: + async for issue in self.iter_issues(): + issue = self.issue_class(self, issue) + if issue.dep: + yield issue + + @async_property(cache=True) + async def dep_issues(self) -> Dict[str, AGithubDependencyIssue]: + """Dependency dictionary of current issues.""" + issues: Dict[str, AGithubDependencyIssue] = {} + for issue in await self.open_issues: + if issue.dep in issues: + if issue.version <= issues[issue.dep].version: + continue + issues[issue.dep] = issue + return issues + + @async_property + async def duplicate_issues( + self) -> AsyncGenerator[ + AGithubDependencyIssue, + AGithubDependencyIssue]: + """Iterate duplicate issues.""" + dep_issues = await self.dep_issues + for issue in await self.open_issues: + if issue not in dep_issues.values(): + yield issue + + @property # type:ignore + @abstracts.interfacemethod + def issue_class(self) -> Type[AGithubDependencyIssue]: + """Issue class.""" + raise NotImplementedError + + @async_property(cache=True) + async def missing_labels(self) -> Tuple[str, ...]: + """Missing Github issue labels.""" + found = [] + async for label in self.repo.labels: + if label.name in self.labels: + found.append(label.name) + if len(found) == len(self.labels): + break + return tuple( + label + for label + in self.labels + if label not in found) + + @async_property(cache=True) + async def open_issues(self) -> Tuple[AGithubDependencyIssue, ...]: + """All current open, matching issues.""" + issues = [] + async for issue in self: + issues.append(issue) + return tuple(issues) + + @cached_property + def repo(self) -> github.AGithubRepo: + """Github repo.""" + return self.github[self.repo_name] + + @cached_property + def title_re(self) -> Pattern[str]: + """Regex for matching/parsing issue titles.""" + return re.compile( + self.title_re_tpl.format( + title_prefix=self.title_prefix)) + + @async_property(cache=True) + async def titles(self) -> Tuple[str, ...]: + """Tuple of current issue titles.""" + return tuple(issue.title for issue in await self.open_issues) + + async def create( + self, + dep: "abstract.AGithubDependency") -> AGithubDependencyIssue: + """Create an issue for a dependency.""" + issue_title = await self.issue_title(dep) + if issue_title in await self.titles: + raise github.exceptions.IssueExists(issue_title) + return self.issue_class( + self, + await self.repo.issues.create( + issue_title, + body=await self.issue_body(dep), + labels=self.labels)) + + async def issue_body(self, dep: "abstract.AGithubDependency") -> str: + """Issue body for a dependency.""" + newer_release = await dep.newer_release + return self.body_tpl.format( + dep=dep, + newer_release=newer_release, + newer_release_date=await newer_release.date, + release_date=await dep.release.date) + + async def issue_title(self, dep: "abstract.AGithubDependency") -> str: + """Issue title for a dependency.""" + return self.title_tpl.format( + dep=dep, + title_prefix=self.title_prefix, + newer_release=await dep.newer_release) + + def iter_issues(self) -> github.AGithubIterator: + """Issues search iterator.""" + return self.repo.issues.search( + self.issues_search_tpl.format(self=self)) diff --git a/envoy.dependency.check/envoy/dependency/check/abstract/release.py b/envoy.dependency.check/envoy/dependency/check/abstract/release.py new file mode 100644 index 0000000000..ab61e5652f --- /dev/null +++ b/envoy.dependency.check/envoy/dependency/check/abstract/release.py @@ -0,0 +1,111 @@ + +from datetime import datetime +from functools import cached_property +from typing import Optional, Union + +from packaging import version + +import gidgethub + +import abstracts + +from aio.api import github +from aio.functional import async_property + +from envoy.base import utils + + +class ADependencyGithubRelease(metaclass=abstracts.Abstraction): + """Github release associated with a dependency.""" + + def __init__( + self, + repo: github.AGithubRepo, + version: str, + release: Optional[github.AGithubRelease] = None) -> None: + self.repo = repo + self._version = version + self._release = release + + @async_property(cache=True) + async def commit(self) -> Optional[github.AGithubCommit]: + """Github commit for this release.""" + try: + return await self.repo.commit(self.tag_name) + except gidgethub.BadRequest as e: + if e.args[0] == "Not Found": + return None + raise e + + @async_property(cache=True) + async def date(self) -> str: + """UTC date of this release.""" + return utils.dt_to_utc_isoformat(await self.timestamp) + + @async_property(cache=True) + async def release(self) -> Optional[github.AGithubRelease]: + """Github release.""" + if self._release: + return self._release + try: + return await self.repo.release(self.tag_name) + except gidgethub.BadRequest as e: + if e.args[0] == "Not Found": + return None + raise e + + @async_property(cache=True) + async def tag(self) -> Optional[github.AGithubTag]: + """Github tag.""" + try: + return await self.repo.tag(self.tag_name) + except (gidgethub.BadRequest, github.exceptions.TagNotFound) as e: + do_raise = ( + isinstance(e, gidgethub.BadRequest) + and e.args[0] != "Not Found") + if do_raise: + raise e + return None + + @property + def tag_name(self) -> str: + """Github tag name.""" + return self._version + + @cached_property + def tagged(self) -> bool: + """Flag to indicate whether this release has a name.""" + return not utils.is_sha(self.tag_name) + + @async_property(cache=True) + async def timestamp(self) -> datetime: + """Timestamp of this release.""" + return ( + await self.timestamp_tag + if self.tagged + else await self.timestamp_commit) + + @async_property(cache=True) + async def timestamp_commit(self) -> datetime: + """Timestamp of the commit of this release.""" + return (await self.commit).timestamp + + @async_property(cache=True) + async def timestamp_tag(self) -> datetime: + """Timestamp of this release, resolved from the release, tag, or + commit, in that order.""" + if await self.release: + return (await self.release).published_at + if await self.tag: + return (await (await self.tag).commit).timestamp + # Its not clear why this is required - it seems to be legacy tags + # in this case fetching the `tag_name` as a commit seems to work. + return await self.timestamp_commit + + @cached_property + def version( + self) -> Union[ + version.LegacyVersion, + version.Version]: + """Semantic version of this release.""" + return version.parse(self.tag_name) diff --git a/envoy.dependency.check/envoy/dependency/check/abstract/typing.py b/envoy.dependency.check/envoy/dependency/check/abstract/typing.py new file mode 100644 index 0000000000..496188a8b6 --- /dev/null +++ b/envoy.dependency.check/envoy/dependency/check/abstract/typing.py @@ -0,0 +1,18 @@ + +from typing import ( + Dict, List, Optional, TypedDict) + + +# Package defined types + +class BaseDependencyMetadataDict(TypedDict): + release_date: str + version: str + + +class DependencyMetadataDict(BaseDependencyMetadataDict, total=False): + cpe: Optional[str] + urls: List[str] + + +DependenciesDict = Dict[str, DependencyMetadataDict] diff --git a/envoy.dependency.check/envoy/dependency/check/abstracts.py b/envoy.dependency.check/envoy/dependency/check/abstracts.py new file mode 100644 index 0000000000..9baca35a26 --- /dev/null +++ b/envoy.dependency.check/envoy/dependency/check/abstracts.py @@ -0,0 +1,4 @@ + + +class ADependencyChecker: + pass diff --git a/envoy.dependency.check/envoy/dependency/check/checker.py b/envoy.dependency.check/envoy/dependency/check/checker.py new file mode 100644 index 0000000000..2322f9f629 --- /dev/null +++ b/envoy.dependency.check/envoy/dependency/check/checker.py @@ -0,0 +1,63 @@ + +import pathlib +import sys +from functools import cached_property +from typing import Type + +import abstracts + +from envoy.dependency import check + + +@abstracts.implementer(check.AGithubDependency) +class GithubDependency: + + @property + def release_class(self): + return DependencyGithubRelease + + +@abstracts.implementer(check.ADependencyGithubRelease) +class DependencyGithubRelease: + pass + + +@abstracts.implementer(check.AGithubDependencyIssue) +class GithubDependencyIssue: + pass + + +@abstracts.implementer(check.AGithubDependencyIssues) +class GithubDependencyIssues: + + @property + def issue_class(self): + return GithubDependencyIssue + + +@abstracts.implementer(check.ADependencyChecker) +class DependencyChecker: + + @cached_property + def access_token(self): + return super().access_token + + @cached_property + def dependency_metadata(self): + return super().dependency_metadata + + @property + def github_dependency_class(self): + return GithubDependency + + @property + def issues_class(self): + return GithubDependencyIssues + + +def main(*args) -> int: + return DependencyChecker(*args)() + + +if __name__ == "__main__": + sys.exit(main(*sys.argv[1:])) diff --git a/envoy.dependency.check/envoy/dependency/check/cmd.py b/envoy.dependency.check/envoy/dependency/check/cmd.py new file mode 100644 index 0000000000..b4dea82a52 --- /dev/null +++ b/envoy.dependency.check/envoy/dependency/check/cmd.py @@ -0,0 +1,16 @@ + +import sys + +from .checker import DependencyChecker + + +def main(*args: str) -> int: + return DependencyChecker(*args).run() + + +def cmd(): + sys.exit(main(*sys.argv[1:])) + + +if __name__ == "__main__": + cmd() diff --git a/envoy.dependency.check/envoy/dependency/check/exceptions.py b/envoy.dependency.check/envoy/dependency/check/exceptions.py new file mode 100644 index 0000000000..f3674dfcaf --- /dev/null +++ b/envoy.dependency.check/envoy/dependency/check/exceptions.py @@ -0,0 +1,13 @@ + +# Thrown on errors related to release date or version. +class ReleaseDateVersionError(Exception): + pass + + +# Errors that happen during issue creation. +class DependencyUpdateError(Exception): + pass + + +class NotGithubDependency(Exception): + pass diff --git a/envoy.dependency.check/envoy/dependency/check/py.typed b/envoy.dependency.check/envoy/dependency/check/py.typed new file mode 100644 index 0000000000..e69de29bb2 diff --git a/envoy.dependency.check/mypy.ini b/envoy.dependency.check/mypy.ini new file mode 100644 index 0000000000..e5ddf2f13a --- /dev/null +++ b/envoy.dependency.check/mypy.ini @@ -0,0 +1,2 @@ +[mypy] +plugins = mypy_abstracts diff --git a/envoy.dependency.check/setup.cfg b/envoy.dependency.check/setup.cfg new file mode 100644 index 0000000000..3d3904281e --- /dev/null +++ b/envoy.dependency.check/setup.cfg @@ -0,0 +1,58 @@ +[metadata] +name = envoy.dependency.check +version = file: VERSION +author = Ryan Northey +author_email = ryan@synca.io +maintainer = Ryan Northey +maintainer_email = ryan@synca.io +license = Apache Software License 2.0 +url = https://github.com/envoyproxy/pytooling/tree/main/envoy.dependency.check +description = "Dependency checker used in Envoy proxy's CI" +long_description = file: README.rst +classifiers = + Development Status :: 4 - Beta + Framework :: Pytest + Intended Audience :: Developers + Topic :: Software Development :: Testing + Programming Language :: Python + Programming Language :: Python :: 3 + Programming Language :: Python :: 3.8 + Programming Language :: Python :: 3.9 + Programming Language :: Python :: 3 :: Only + Programming Language :: Python :: Implementation :: CPython + Operating System :: OS Independent + License :: OSI Approved :: Apache Software License + +[options] +python_requires = >=3.5 +py_modules = envoy.dependency.check +packages = find_namespace: +install_requires = + abstracts>=0.0.12 + aio.api.github>=0.0.2 + aio.functional>=0.0.9 + aio.tasks>=0.0.4 + aiohttp + envoy.base.checker>=0.0.3 + envoy.base.utils>=0.0.13 + gidgethub + packaging + +[options.extras_require] +test = + pytest + pytest-asyncio + pytest-coverage + pytest-patches +lint = flake8 +types = + mypy + mypy-abstracts +publish = wheel + +[options.package_data] +* = py.typed + +[options.entry_points] +console_scripts = + envoy.dependency.check = envoy.dependency.check:cmd diff --git a/envoy.dependency.check/setup.py b/envoy.dependency.check/setup.py new file mode 100644 index 0000000000..1f6a64b9cf --- /dev/null +++ b/envoy.dependency.check/setup.py @@ -0,0 +1,5 @@ +#!/usr/bin/env python + +from setuptools import setup # type:ignore + +setup() diff --git a/envoy.dependency.check/tests/BUILD b/envoy.dependency.check/tests/BUILD new file mode 100644 index 0000000000..2945c9ec72 --- /dev/null +++ b/envoy.dependency.check/tests/BUILD @@ -0,0 +1,15 @@ + +pytooling_tests( + "envoy.dependency.check", + dependencies=[ + "//deps:abstracts", + "//deps:aio.functional", + "//deps:aio.api.github", + "//deps:aio.tasks", + "//deps:envoy.base.checker", + "//deps:envoy.base.utils", + "//deps:aiohttp", + "//deps:packaging", + "//deps:pytest-asyncio", + ], +) diff --git a/envoy.dependency.check/tests/test_abstract_checker.py b/envoy.dependency.check/tests/test_abstract_checker.py new file mode 100644 index 0000000000..62886c446a --- /dev/null +++ b/envoy.dependency.check/tests/test_abstract_checker.py @@ -0,0 +1,851 @@ + +from unittest.mock import AsyncMock, MagicMock, PropertyMock + +import pytest + +import abstracts + +from aio.functional import async_property + +from envoy.base.checker import AsyncChecker + +from envoy.dependency.check import ADependencyChecker, exceptions + + +@abstracts.implementer(ADependencyChecker) +class DummyDependencyChecker: + + @property + def access_token(self): + return super().access_token + + @property + def dependency_metadata(self): + return super().dependency_metadata + + @property + def github_dependency_class(self): + return super().github_dependency_class + + @property + def issues_class(self): + return super().issues_class + + +def test_checker_constructor(): + + with pytest.raises(TypeError): + ADependencyChecker() + + checker = DummyDependencyChecker() + assert isinstance(checker, AsyncChecker) + assert checker.checks == ("dates", "issues", "releases") + + iface_props = [ + "dependency_metadata", + "github_dependency_class", "issues_class"] + + for prop in iface_props: + with pytest.raises(NotImplementedError): + getattr(checker, prop) + + +@pytest.mark.parametrize("arg", [True, False]) +def test_checker_access_token(patches, arg): + checker = DummyDependencyChecker() + patched = patches( + "os", + "pathlib", + ("ADependencyChecker.args", + dict(new_callable=PropertyMock)), + prefix="envoy.dependency.check.abstract.checker") + + with patched as (m_os, m_plib, m_args): + if not arg: + m_args.return_value.github_token = None + assert ( + checker.access_token + == ((m_plib.Path.return_value + .read_text.return_value + .strip.return_value) + if arg + else m_os.getenv.return_value)) + + if arg: + assert not m_os.getenv.called + assert ( + list(m_plib.Path.call_args) + == [(m_args.return_value.github_token, ), {}]) + assert ( + list(m_plib.Path.return_value.read_text.call_args) + == [(), {}]) + assert ( + list(m_plib.Path.return_value + .read_text.return_value + .strip.call_args) + == [(), {}]) + else: + assert not m_plib.Path.called + assert ( + list(m_os.getenv.call_args) + == [("GITHUB_TOKEN", ), {}]) + assert "access_token" not in checker.__dict__ + + +def test_checker_dep_ids(patches): + checker = DummyDependencyChecker() + patched = patches( + ("ADependencyChecker.dependencies", + dict(new_callable=PropertyMock)), + prefix="envoy.dependency.check.abstract.checker") + deps = [MagicMock()] * 5 + + with patched as (m_deps, ): + m_deps.return_value = deps + assert checker.dep_ids == tuple(dep.id for dep in deps) + + assert "dep_ids" in checker.__dict__ + + +@pytest.mark.parametrize( + "failures", + [{}, + {1: exceptions.NotGithubDependency, 2: exceptions.NotGithubDependency}, + {1: Exception, 2: exceptions.NotGithubDependency}, + {1: exceptions.NotGithubDependency, 2: Exception}]) +def test_checker_dependencies(patches, failures): + checker = DummyDependencyChecker() + patched = patches( + "sorted", + "tuple", + ("ADependencyChecker.dependency_metadata", + dict(new_callable=PropertyMock)), + ("ADependencyChecker.github", + dict(new_callable=PropertyMock)), + ("ADependencyChecker.github_dependency_class", + dict(new_callable=PropertyMock)), + ("ADependencyChecker.log", + dict(new_callable=PropertyMock)), + prefix="envoy.dependency.check.abstract.checker") + deps = [(i, MagicMock()) for i in range(0, 5)] + errors = {} + + def dep_class(i, v, github): + if i in failures: + errors[i] = failures[i]("BOOM") + raise errors[i] + return f"DEP{i}" + + does_fail = any(f == Exception for f in failures.values()) + + with patched as (m_sorted, m_tuple, m_meta, m_github, m_dep, m_log): + m_meta.return_value.items.return_value = deps + m_dep.return_value.side_effect = dep_class + + if does_fail: + with pytest.raises(Exception): + checker.dependencies + return + + assert checker.dependencies == m_tuple.return_value + + assert ( + list(m_tuple.call_args) + == [(m_sorted.return_value, ), {}]) + assert ( + list(m_sorted.call_args) + == [([f"DEP{i}" + for i in range(0, 5) + if i not in failures], ), {}]) + assert ( + list(list(c) for c in m_dep.return_value.call_args_list) + == [[(i, dep, m_github.return_value), {}] + for i, dep in deps]) + assert ( + list(list(c) for c in m_log.return_value.info.call_args_list) + == [[(errors[i], ), {}] + for i, v in deps if i in failures]) + assert "dependencies" in checker.__dict__ + + +def test_checker_github(patches): + checker = DummyDependencyChecker() + patched = patches( + "github", + ("ADependencyChecker.access_token", + dict(new_callable=PropertyMock)), + ("ADependencyChecker.session", + dict(new_callable=PropertyMock)), + prefix="envoy.dependency.check.abstract.checker") + + with patched as (m_github, m_token, m_session): + assert checker.github == m_github.GithubAPI.return_value + + assert ( + list(m_github.GithubAPI.call_args) + == [(m_session.return_value, ""), + dict(oauth_token=m_token.return_value)]) + assert "github" in checker.__dict__ + + +def test_checker_issues(patches): + checker = DummyDependencyChecker() + patched = patches( + ("ADependencyChecker.github", + dict(new_callable=PropertyMock)), + ("ADependencyChecker.issues_class", + dict(new_callable=PropertyMock)), + prefix="envoy.dependency.check.abstract.checker") + + with patched as (m_github, m_issues): + assert checker.issues == m_issues.return_value.return_value + + assert ( + list(m_issues.return_value.call_args) + == [(m_github.return_value, ), {}]) + assert "issues" in checker.__dict__ + + +def test_checker_session(patches): + checker = DummyDependencyChecker() + patched = patches( + "aiohttp", + prefix="envoy.dependency.check.abstract.checker") + + with patched as (m_aiohttp, ): + assert checker.session == m_aiohttp.ClientSession.return_value + + assert ( + list(m_aiohttp.ClientSession.call_args) + == [(), {}]) + assert "session" in checker.__dict__ + + +def test_checker_sync_issues(patches): + checker = DummyDependencyChecker() + patched = patches( + ("ADependencyChecker.args", + dict(new_callable=PropertyMock)), + prefix="envoy.dependency.check.abstract.checker") + + with patched as (m_args, ): + assert checker.sync_issues == m_args.return_value.sync_issues + + assert "sync_issues" not in checker.__dict__ + + +def test_checker_add_arguments(patches): + checker = DummyDependencyChecker() + patched = patches( + "checker.AsyncChecker.add_arguments", + prefix="envoy.dependency.check.abstract.checker") + parser = MagicMock() + + with patched as (m_super, ): + assert not checker.add_arguments(parser) + + assert ( + list(m_super.call_args) + == [(parser, ), {}]) + assert ( + list(list(c) for c in parser.add_argument.call_args_list) + == [[('--github_token',), {}], + [('--sync_issues',), {'action': 'store_true'}]]) + + +@pytest.mark.asyncio +async def test_checker_check_dates(patches): + checker = DummyDependencyChecker() + patched = patches( + ("ADependencyChecker.dependencies", + dict(new_callable=PropertyMock)), + "ADependencyChecker.dep_date_check", + prefix="envoy.dependency.check.abstract.checker") + deps = [MagicMock() for i in range(0, 5)] + + with patched as (m_deps, m_check): + m_deps.return_value = deps + assert not await checker.check_dates() + + assert ( + list(list(c) for c in m_check.call_args_list) + == [[(mock,), {}] for mock in deps]) + + +@pytest.mark.asyncio +async def test_checker_check_issues(patches): + checker = DummyDependencyChecker() + patched = patches( + ("ADependencyChecker.dependencies", + dict(new_callable=PropertyMock)), + "ADependencyChecker.dep_issue_check", + "ADependencyChecker.issues_missing_dep_check", + "ADependencyChecker.issues_duplicate_check", + "ADependencyChecker.issues_labels_check", + prefix="envoy.dependency.check.abstract.checker") + deps = [MagicMock() for i in range(0, 5)] + + with patched as (m_deps, m_dep_check, m_issue_check, m_dupes, m_labels): + m_deps.return_value = deps + assert not await checker.check_issues() + + assert ( + list(m_labels.call_args) + == [(), {}]) + assert ( + list(m_issue_check.call_args) + == [(), {}]) + assert ( + list(m_dupes.call_args) + == [(), {}]) + assert ( + list(list(c) for c in m_dep_check.call_args_list) + == [[(mock,), {}] for mock in deps]) + + +@pytest.mark.asyncio +async def test_checker_check_releases(patches): + checker = DummyDependencyChecker() + patched = patches( + ("ADependencyChecker.dependencies", + dict(new_callable=PropertyMock)), + "ADependencyChecker.dep_release_check", + prefix="envoy.dependency.check.abstract.checker") + deps = [MagicMock() for i in range(0, 5)] + + with patched as (m_deps, m_check): + m_deps.return_value = deps + assert not await checker.check_releases() + + assert ( + list(list(c) for c in m_check.call_args_list) + == [[(mock,), {}] for mock in deps]) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("gh_date", [None, "GH_DATE"]) +@pytest.mark.parametrize("mismatch", [True, False]) +async def test_checker_dep_date_check(patches, gh_date, mismatch): + checker = DummyDependencyChecker() + patched = patches( + "ADependencyChecker.error", + "ADependencyChecker.succeed", + prefix="envoy.dependency.check.abstract.checker") + + class DummyDepRelease: + + @async_property + async def date(self): + return gh_date + + class DummyDep: + id = "DUMMY_DEP" + release_date = "DUMMY_RELEASE_DATE" + + @property + def release(self): + return DummyDepRelease() + + @async_property + async def release_date_mismatch(self): + return mismatch + + dep = DummyDep() + + with patched as (m_error, m_succeed): + assert not await checker.dep_date_check(dep) + + if not gh_date: + assert ( + list(m_error.call_args) + == [("dates", + ["DUMMY_DEP is a GitHub repository with no no inferrable " + "release date"]), + {}]) + assert not m_succeed.called + return + if mismatch: + assert ( + list(m_error.call_args) + == [("dates", + ["Date mismatch: DUMMY_DEP " + f"DUMMY_RELEASE_DATE != {gh_date}"]), + {}]) + assert not m_succeed.called + return + assert not m_error.called + assert ( + list(m_succeed.call_args) + == [("dates", ["Date matches(DUMMY_RELEASE_DATE): DUMMY_DEP"]), + {}]) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("issue", [None, "ISSUE"]) +@pytest.mark.parametrize("newer_release", [True, False]) +@pytest.mark.parametrize("sync_issues", [True, False]) +@pytest.mark.parametrize("version_matches", [True, False]) +async def test_checker_dep_issue_check( + patches, issue, newer_release, sync_issues, version_matches): + checker = DummyDependencyChecker() + patched = patches( + ("ADependencyChecker.issues", + dict(new_callable=PropertyMock)), + ("ADependencyChecker.sync_issues", + dict(new_callable=PropertyMock)), + "ADependencyChecker.succeed", + "ADependencyChecker.warn", + "ADependencyChecker._dep_issue_close_stale", + "ADependencyChecker._dep_issue_create", + prefix="envoy.dependency.check.abstract.checker") + + class DummyDep: + id = "DUMMY_DEP" + + @async_property + async def newer_release(self): + if newer_release: + mock_release = MagicMock() + mock_release.version = "NEWER_RELEASE_NAME" + return mock_release + + dep = DummyDep() + issues_dict = MagicMock() + + if issue: + mock_issue = MagicMock() + if version_matches: + mock_issue.version = "NEWER_RELEASE_NAME" + issues_dict.get.return_value = mock_issue + else: + mock_issue = None + issues_dict.get.return_value = None + + with patched as patchy: + (m_issue, m_manage, + m_succeed, m_warn, m_close, m_create) = patchy + dep_issues = AsyncMock(return_value=issues_dict) + m_issue.return_value.dep_issues = dep_issues() + m_manage.return_value = sync_issues + assert not await checker.dep_issue_check(dep) + + if not newer_release: + assert not m_create.called + if issue: + assert ( + list(list(c) for c in m_warn.call_args_list) + == [[('issues', + [f"Stale issue: DUMMY_DEP #{mock_issue.number}"]), + {}]]) + assert not m_succeed.called + if sync_issues: + assert ( + list(m_close.call_args) + == [(mock_issue, dep), {}]) + else: + assert not m_close.called + else: + assert ( + list(list(c) for c in m_succeed.call_args_list) + == [[('issues', + ["No issue required: DUMMY_DEP"]), + {}]]) + assert not m_warn.called + assert not m_close.called + assert not m_manage.called + return + assert not m_close.called + if issue: + if version_matches: + assert ( + list(list(c) for c in m_succeed.call_args_list) + == [[('issues', + [f"Issue exists (#{mock_issue.number}): DUMMY_DEP"]), + {}]]) + assert not m_warn.called + assert not m_manage.called + assert not m_create.called + return + assert ( + list(list(c) for c in m_warn.call_args_list) + == [[('issues', + [f"Out-of-date issue (#{mock_issue.number}): " + f"DUMMY_DEP ({mock_issue.version} -> NEWER_RELEASE_NAME)"]), + {}]]) + assert not m_succeed.called + if sync_issues: + assert ( + list(m_create.call_args) + == [(mock_issue, dep), {}]) + else: + assert not m_create.called + + +@pytest.mark.asyncio +@pytest.mark.parametrize("newer_release", [True, False]) +@pytest.mark.parametrize("recent_commits", [True, False]) +async def test_checker_dep_release_check( + patches, newer_release, recent_commits): + checker = DummyDependencyChecker() + patched = patches( + "ADependencyChecker.warn", + "ADependencyChecker.succeed", + prefix="envoy.dependency.check.abstract.checker") + + class DummyDep: + id = "DUMMY_DEP" + release_date = "DUMMY_RELEASE_DATE" + github_version_name = "GH_VERSION_NAME" + + @async_property + async def has_recent_commits(self): + return recent_commits + + @async_property + async def newer_release(self): + if newer_release: + mock_release = MagicMock() + mock_release.tag_name = "NEWER_RELEASE_NAME" + mock_release.date = AsyncMock( + return_value="NEWER_RELEASE_DATE")() + return mock_release + + @async_property + async def recent_commits(self): + return 23 + + dep = DummyDep() + + with patched as (m_warn, m_succeed): + assert not await checker.dep_release_check(dep) + + if newer_release: + assert ( + list(m_warn.call_args) + == [("releases", + ["Newer release (NEWER_RELEASE_NAME): DUMMY_DEP\n" + "DUMMY_RELEASE_DATE " + "GH_VERSION_NAME\n" + "NEWER_RELEASE_DATE " + "NEWER_RELEASE_NAME "]), + {}]) + assert not m_succeed.called + return + if recent_commits: + assert ( + list(m_warn.call_args) + == [("releases", + ["Recent commits (23): DUMMY_DEP\n" + "There have been 23 commits since " + "GH_VERSION_NAME landed on " + "DUMMY_RELEASE_DATE"]), + {}]) + assert not m_succeed.called + return + assert not m_warn.called + assert ( + list(m_succeed.call_args) + == [("releases", ["Up-to-date (GH_VERSION_NAME): DUMMY_DEP"]), + {}]) + + +@pytest.mark.asyncio +async def test_checker_on_checks_complete(patches): + checker = DummyDependencyChecker() + patched = patches( + "checker.AsyncChecker.on_checks_complete", + ("ADependencyChecker.session", + dict(new_callable=PropertyMock)), + prefix="envoy.dependency.check.abstract.checker") + + with patched as (m_super, m_session): + m_session.return_value.close = AsyncMock() + assert await checker.on_checks_complete() == m_super.return_value + + assert ( + list(m_session.return_value.close.call_args) + == [(), {}]) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("sync_issues", [True, False]) +@pytest.mark.parametrize("dupes", [0, 1, 3, 5]) +async def test_checker_issues_duplicate_check(patches, sync_issues, dupes): + checker = DummyDependencyChecker() + patched = patches( + ("ADependencyChecker.issues", + dict(new_callable=PropertyMock)), + ("ADependencyChecker.sync_issues", + dict(new_callable=PropertyMock)), + "ADependencyChecker.warn", + "ADependencyChecker.succeed", + "ADependencyChecker._issue_close_duplicate", + prefix="envoy.dependency.check.abstract.checker") + mock_dupes = [] + + for dupe in range(0, dupes): + mock_dupe = MagicMock() + mock_dupes.append(mock_dupe) + + async def dupe_iter(): + for dupe in mock_dupes: + yield dupe + + with patched as (m_issues, m_manage, m_warn, m_succeed, m_close): + m_manage.return_value = sync_issues + m_issues.return_value.duplicate_issues = dupe_iter() + assert not await checker.issues_duplicate_check() + + assert ( + list(list(c) for c in m_warn.call_args_list) + == [[('issues', + [f"Duplicate issue for dependency (#{issue.number}): " + f"{issue.dep}"]), {}] + for issue in mock_dupes]) + if sync_issues: + assert ( + list(list(c) for c in m_close.call_args_list) + == [[(issue, ), {}] + for issue in mock_dupes]) + else: + assert not m_close.called + if not dupes: + assert not m_warn.called + assert not m_close.called + assert ( + list(list(c) for c in m_succeed.call_args_list) + == [[('issues', + ["No duplicate issues found."]), + {}]]) + else: + assert not m_succeed.called + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "missing_labels", + [[], + [f"LABEL{i}" for i in range(0, 5)]]) +async def test_checker_issues_labels_check(patches, missing_labels): + checker = DummyDependencyChecker() + patched = patches( + ("ADependencyChecker.issues", + dict(new_callable=PropertyMock)), + "ADependencyChecker.error", + "ADependencyChecker.succeed", + prefix="envoy.dependency.check.abstract.checker") + + with patched as (m_issues, m_error, m_succeed): + m_issues.return_value.missing_labels = AsyncMock( + return_value=missing_labels)() + assert not await checker.issues_labels_check() + + assert ( + list(list(c) for c in m_error.call_args_list) + == [[("issues", [f"Missing label: {label}"]), {}] + for label in missing_labels]) + if not missing_labels: + assert not m_error.called + assert ( + list(list(c) for c in m_succeed.call_args_list) + == [[('issues', + [f"All ({m_issues.return_value.labels.__len__.return_value}) " + "required labels are available."]), + {}]]) + else: + assert not m_succeed.called + + +@pytest.mark.asyncio +@pytest.mark.parametrize("sync_issues", [True, False]) +@pytest.mark.parametrize( + "open_issues", + [[], + [True, False, True], + [False, False, False], + [True, True, True]]) +async def test_checker_issues_missing_dep_check( + patches, sync_issues, open_issues): + checker = DummyDependencyChecker() + patched = patches( + ("ADependencyChecker.dep_ids", + dict(new_callable=PropertyMock)), + ("ADependencyChecker.issues", + dict(new_callable=PropertyMock)), + ("ADependencyChecker.sync_issues", + dict(new_callable=PropertyMock)), + "ADependencyChecker.warn", + "ADependencyChecker.succeed", + "ADependencyChecker._issue_close_missing_dep", + prefix="envoy.dependency.check.abstract.checker") + ids = [] + mock_issues = [] + should_fail = any(open_issues) + + for issue in open_issues: + mock_issue = MagicMock() + if not issue: + ids.append(mock_issue.dep) + mock_issues.append(mock_issue) + + with patched as (m_ids, m_issues, m_manage, m_warn, m_succeed, m_close): + m_issues.return_value.open_issues = AsyncMock( + return_value=mock_issues)() + m_ids.return_value = ids + m_manage.return_value = sync_issues + assert not await checker.issues_missing_dep_check() + + assert ( + list(list(c) for c in m_warn.call_args_list) + == [[('issues', + [f"Missing dependency (#{issue.number}): {issue.dep}"]), {}] + for issue in mock_issues if issue.dep not in ids]) + if sync_issues: + assert ( + list(list(c) for c in m_close.call_args_list) + == [[(issue, ), {}] + for issue in mock_issues if issue.dep not in ids]) + else: + assert not m_close.called + if not should_fail: + assert not m_warn.called + assert not m_close.called + assert ( + list(list(c) for c in m_succeed.call_args_list) + == [[('issues', + [f"All ({len(open_issues)}) issues have " + "current dependencies."]), + {}]]) + else: + assert not m_succeed.called + + +@pytest.mark.asyncio +async def test_checker__dep_issue_close_stale(patches): + checker = DummyDependencyChecker() + patched = patches( + ("ADependencyChecker.log", + dict(new_callable=PropertyMock)), + prefix="envoy.dependency.check.abstract.checker") + issue = AsyncMock() + dep = MagicMock() + + with patched as (m_log, ): + assert not await checker._dep_issue_close_stale(issue, dep) + + assert ( + list(issue.close.call_args) + == [(), {}]) + assert ( + list(m_log.return_value.notice.call_args) + == [(f"Closed stale issue (#{issue.number}): {dep.id}\n" + f"{issue.title}\n{issue.body}", ), {}]) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("issue", [True, False]) +@pytest.mark.parametrize("missing_labels", [True, False]) +async def test_checker__dep_issue_create(patches, issue, missing_labels): + checker = DummyDependencyChecker() + patched = patches( + ("ADependencyChecker.issues", + dict(new_callable=PropertyMock)), + ("ADependencyChecker.log", + dict(new_callable=PropertyMock)), + "ADependencyChecker.error", + prefix="envoy.dependency.check.abstract.checker") + issue = ( + AsyncMock() + if issue + else None) + dep = MagicMock() + + with patched as (m_issues, m_log, m_error): + m_issues.return_value.missing_labels = AsyncMock( + return_value=missing_labels)() + create = AsyncMock() + m_issues.return_value.create = create + assert not await checker._dep_issue_create(issue, dep) + + if missing_labels: + assert ( + list(m_error.call_args) + == [("issues", + [f"Unable to create issue for {dep.id}: missing labels"]), + {}]) + assert not m_log.return_value.called + assert not create.called + return + + assert not m_error.called + new_issue = create.return_value + assert ( + list(m_log.return_value.notice.call_args_list[0]) + == [(f"Created issue (#{new_issue.number}): " + f"{dep.id} {new_issue.version}\n" + f"{new_issue.title}\n{new_issue.body}", ), {}]) + if not issue: + assert len(m_log.return_value.notice.call_args_list) == 1 + assert not new_issue.close_old.called + return + assert len(m_log.return_value.notice.call_args_list) == 2 + assert ( + list(new_issue.close_old.call_args) + == [(issue, dep), {}]) + assert ( + list(m_log.return_value.notice.call_args_list[1]) + == [(f"Closed old issue (#{issue.number}): " + f"{dep.id} {issue.version}\n" + f"{issue.title}\n{issue.body}", ), {}]) + + +@pytest.mark.asyncio +async def test_checker__issue_close_duplicate(patches): + checker = DummyDependencyChecker() + patched = patches( + ("ADependencyChecker.issues", + dict(new_callable=PropertyMock)), + ("ADependencyChecker.log", + dict(new_callable=PropertyMock)), + prefix="envoy.dependency.check.abstract.checker") + issue = AsyncMock() + issue.dep = "DEP" + current_issue = AsyncMock() + issues_dict = dict(DEP=current_issue) + + with patched as (m_issues, m_log): + dep_issues = AsyncMock(return_value=issues_dict) + m_issues.return_value.dep_issues = dep_issues() + assert not await checker._issue_close_duplicate(issue) + + assert ( + list(current_issue.close_duplicate.call_args) + == [(issue, ), {}]) + assert ( + list(m_log.return_value.notice.call_args) + == [(f"Closed duplicate issue (#{issue.number}): {issue.dep}\n" + f" {issue.title}\n" + f"current issue #({current_issue.number}):\n" + f" {current_issue.title}", ), + {}]) + + +@pytest.mark.asyncio +async def test_checker__issue_close_missing_dep(patches): + checker = DummyDependencyChecker() + patched = patches( + ("ADependencyChecker.log", + dict(new_callable=PropertyMock)), + prefix="envoy.dependency.check.abstract.checker") + issue = AsyncMock() + + with patched as (m_log, ): + assert not await checker._issue_close_missing_dep(issue) + + assert ( + list(issue.close.call_args) + == [(), {}]) + assert ( + list(m_log.return_value.notice.call_args) + == [(f"Closed issue with no current dependency (#{issue.number})", ), + {}]) diff --git a/envoy.dependency.check/tests/test_abstract_dependency.py b/envoy.dependency.check/tests/test_abstract_dependency.py new file mode 100644 index 0000000000..dfb10d47d5 --- /dev/null +++ b/envoy.dependency.check/tests/test_abstract_dependency.py @@ -0,0 +1,487 @@ + +from unittest.mock import AsyncMock, MagicMock, PropertyMock + +import pytest + +import abstracts + +from envoy.dependency.check import AGithubDependency, exceptions + + +@abstracts.implementer(AGithubDependency) +class DummyGithubDependency: + + @property + def release_class(self): + return super().release_class + + +class DummyGithubDependency2(DummyGithubDependency): + + def __init__(self, id, metadata, github): + self.id = id + self.metadata = metadata + self.github = github + + +@pytest.mark.parametrize("github_url", [True, False]) +def test_dependency_constructor(patches, github_url): + patched = patches( + ("AGithubDependency.github_url", + dict(new_callable=PropertyMock)), + ("AGithubDependency.urls", + dict(new_callable=PropertyMock)), + prefix="envoy.dependency.check.abstract.dependency") + + with patched as (m_gh_url, m_urls): + m_gh_url.return_value = github_url + m_urls.return_value = [f"URL{i}" for i in range(0, 3)] + + with pytest.raises(TypeError): + AGithubDependency("ID", "METADATA", "GITHUB") + + if not github_url: + with pytest.raises(exceptions.NotGithubDependency) as e: + DummyGithubDependency("ID", "METADATA", "GITHUB") + assert ( + e.value.args[0] + == 'ID is not a GitHub repository\nURL0\nURL1\nURL2') + return + dependency = DummyGithubDependency("ID", "METADATA", "GITHUB") + + assert not m_urls.called + assert dependency.id == "ID" + assert dependency.metadata == "METADATA" + assert dependency.github == "GITHUB" + + +@pytest.mark.parametrize("id", range(0, 3)) +@pytest.mark.parametrize("other_id", range(0, 3)) +def test_dependency_dunder_gt(id, other_id): + dependency1 = DummyGithubDependency2(id, "METADATA", "GITHUB") + dependency2 = DummyGithubDependency2(other_id, "METADATA", "GITHUB") + assert (dependency1 > dependency2) == (id > other_id) + + +@pytest.mark.parametrize("id", range(0, 3)) +@pytest.mark.parametrize("other_id", range(0, 3)) +def test_dependency_dunder_lt(id, other_id): + dependency1 = DummyGithubDependency2(id, "METADATA", "GITHUB") + dependency2 = DummyGithubDependency2(other_id, "METADATA", "GITHUB") + assert (dependency1 < dependency2) == (id < other_id) + + +def test_dependency_dunder_str(patches): + dependency = DummyGithubDependency2("ID", "METADATA", "GITHUB") + patched = patches( + ("AGithubDependency.version", + dict(new_callable=PropertyMock)), + prefix="envoy.dependency.check.abstract.dependency") + + with patched as (m_version, ): + assert ( + str(dependency) + == f"ID@{m_version.return_value}") + + +@pytest.mark.asyncio +@pytest.mark.parametrize("count", [None] + list(range(0, 3))) +async def test_dependency_commits_since_current(patches, count): + dependency = DummyGithubDependency2("ID", "METADATA", "GITHUB") + patched = patches( + ("AGithubDependency.release", + dict(new_callable=PropertyMock)), + ("AGithubDependency.repo", + dict(new_callable=PropertyMock)), + prefix="envoy.dependency.check.abstract.dependency") + + with patched as (m_release, m_repo): + m_repo.return_value.commits.return_value.total_count = AsyncMock( + return_value=count)() + m_release.return_value.timestamp_commit = AsyncMock( + return_value="TIMESTAMP")() + result = await dependency.commits_since_current + assert ( + result + == (count and count - 1 or count)) + + assert ( + list(m_repo.return_value.commits.call_args) + == [(), dict(since="TIMESTAMP")]) + assert ( + getattr( + dependency, + AGithubDependency.commits_since_current.cache_name)[ + "commits_since_current"] + == result) + + +@pytest.mark.parametrize( + "urls", + [[False, False, True], + [False, False, False], + [False, True, True]]) +def test_dependency_github_url(patches, urls): + dependency = DummyGithubDependency2("ID", "METADATA", "GITHUB") + patched = patches( + ("AGithubDependency.urls", + dict(new_callable=PropertyMock)), + prefix="envoy.dependency.check.abstract.dependency") + + mock_urls = [] + expected_url = None + for url in urls: + mock_url = MagicMock() + mock_url.startswith.return_value = url + if url and not expected_url: + expected_url = mock_url + mock_urls.append(mock_url) + + with patched as (m_urls, ): + m_urls.return_value = mock_urls + assert dependency.github_url == expected_url + + for i, url in enumerate(urls): + mock_url = mock_urls[i] + assert ( + list(mock_url.startswith.call_args) + == [('https://github.com/',), {}]) + if url: + for other_url in mock_urls[i + 1:]: + assert not other_url.startswith.called + break + assert "github_url" in dependency.__dict__ + + +@pytest.mark.parametrize("archive", [True, False]) +@pytest.mark.parametrize("endswith", [True, False]) +def test_dependency_github_version(patches, archive, endswith): + dependency = DummyGithubDependency2("ID", "METADATA", "GITHUB") + patched = patches( + ("AGithubDependency.url_components", + dict(new_callable=PropertyMock)), + prefix="envoy.dependency.check.abstract.dependency") + component = MagicMock() + component.endswith.return_value = endswith + + def get_component(item): + if archive and item == 5: + return "archive" + return component + + with patched as (m_components, ): + m_components.return_value.__getitem__.side_effect = get_component + result = dependency.github_version + + assert "github_version" in dependency.__dict__ + if not archive: + assert result == component + assert not component.__getitem__.called + assert not component.endswith.called + assert ( + list(list(c) + for c + in m_components.return_value.__getitem__.call_args_list) + == [[(5, ), {}], [(7, ), {}]]) + return + assert result == component.__getitem__.return_value + assert ( + list(component.endswith.call_args) + == [('.tar.gz', ), {}]) + assert ( + list(list(c) + for c + in m_components.return_value.__getitem__.call_args_list) + == [[(5, ), {}], [(-1, ), {}], [(-1, ), {}]]) + if endswith: + assert ( + list(component.__getitem__.call_args) + == [(slice(None, -len('.tar.gz')), ), {}]) + return + assert ( + list(component.__getitem__.call_args) + == [(slice(None, -len('.zip')), ), {}]) + + +@pytest.mark.parametrize("tagged", [True, False]) +def test_dependency_github_version_name(patches, tagged): + dependency = DummyGithubDependency2("ID", "METADATA", "GITHUB") + patched = patches( + ("AGithubDependency.github_version", + dict(new_callable=PropertyMock)), + ("AGithubDependency.release", + dict(new_callable=PropertyMock)), + prefix="envoy.dependency.check.abstract.dependency") + + with patched as (m_version, m_release): + m_release.return_value.tagged = tagged + assert ( + dependency.github_version_name + == (m_version.return_value.__getitem__.return_value + if not tagged + else m_version.return_value)) + + assert "github_version_name" not in dependency.__dict__ + if tagged: + assert not m_version.return_value.__getitem__.called + return + assert ( + list(m_version.return_value.__getitem__.call_args) + == [(slice(0, 7), ), {}]) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("commits", range(0, 5)) +async def test_dependency_has_recent_commits(patches, commits): + dependency = DummyGithubDependency2("ID", "METADATA", "GITHUB") + patched = patches( + ("AGithubDependency.recent_commits", + dict(new_callable=PropertyMock)), + prefix="envoy.dependency.check.abstract.dependency") + + with patched as (m_recent, ): + m_recent.return_value = AsyncMock(return_value=commits)() + assert await dependency.has_recent_commits == (commits > 1) + + assert not getattr( + dependency, + AGithubDependency.has_recent_commits.cache_name, + None) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("newest", [None, "BINGO", "BLOOP"]) +async def test_dependency_newer_release(patches, newest): + dependency = DummyGithubDependency2("ID", "METADATA", "GITHUB") + patched = patches( + "version", + ("AGithubDependency.release", + dict(new_callable=PropertyMock)), + ("AGithubDependency.release_class", + dict(new_callable=PropertyMock)), + ("AGithubDependency.repo", + dict(new_callable=PropertyMock)), + prefix="envoy.dependency.check.abstract.dependency") + + if newest: + newer_release = MagicMock() + newer_release.tag_name = newest + else: + newer_release = None + + with patched as (m_version, m_release, m_class, m_repo): + m_version.parse.side_effect = lambda x: x + m_release.return_value.version = "BLOOP" + timestamp = AsyncMock() + m_release.return_value.timestamp = timestamp() + m_repo.return_value.highest_release = AsyncMock( + return_value=newer_release) + result = await dependency.newer_release + assert ( + result + == (m_class.return_value.return_value + if newest and newest != "BLOOP" + else None)) + + if newest and newest != "BLOOP": + assert ( + list(m_class.return_value.call_args) + == [(m_repo.return_value, newest), + dict(release=newer_release)]) + else: + assert not m_class.called + assert ( + list(m_repo.return_value.highest_release.call_args) + == [(), dict(since=timestamp.return_value)]) + assert ( + getattr( + dependency, + AGithubDependency.newer_release.cache_name)[ + "newer_release"] + == result) + + +def test_dependency_organization(patches): + dependency = DummyGithubDependency2("ID", "METADATA", "GITHUB") + patched = patches( + ("AGithubDependency.url_components", + dict(new_callable=PropertyMock)), + prefix="envoy.dependency.check.abstract.dependency") + + with patched as (m_components, ): + assert ( + dependency.organization + == m_components.return_value.__getitem__.return_value) + + assert ( + list(m_components.return_value.__getitem__.call_args) + == [(3, ), {}]) + assert "organization" not in dependency.__dict__ + + +def test_dependency_project(patches): + dependency = DummyGithubDependency2("ID", "METADATA", "GITHUB") + patched = patches( + ("AGithubDependency.url_components", + dict(new_callable=PropertyMock)), + prefix="envoy.dependency.check.abstract.dependency") + + with patched as (m_components, ): + assert ( + dependency.project + == m_components.return_value.__getitem__.return_value) + + assert ( + list(m_components.return_value.__getitem__.call_args) + == [(4, ), {}]) + assert "project" not in dependency.__dict__ + + +@pytest.mark.asyncio +@pytest.mark.parametrize("tagged", [True, False]) +async def test_dependency_recent_commits(patches, tagged): + dependency = DummyGithubDependency2("ID", "METADATA", "GITHUB") + patched = patches( + ("AGithubDependency.commits_since_current", + dict(new_callable=PropertyMock)), + ("AGithubDependency.release", + dict(new_callable=PropertyMock)), + prefix="envoy.dependency.check.abstract.dependency") + + with patched as (m_commits, m_release): + m_release.return_value.tagged = tagged + commits = AsyncMock() + m_commits.side_effect = commits + result = await dependency.recent_commits + assert ( + result + == (commits.return_value + if not tagged + else 0)) + + assert ( + getattr( + dependency, + AGithubDependency.recent_commits.cache_name)[ + "recent_commits"] + == result) + + +def test_dependency_release(patches): + dependency = DummyGithubDependency2("ID", "METADATA", "GITHUB") + patched = patches( + ("AGithubDependency.github_version", + dict(new_callable=PropertyMock)), + ("AGithubDependency.release_class", + dict(new_callable=PropertyMock)), + ("AGithubDependency.repo", + dict(new_callable=PropertyMock)), + prefix="envoy.dependency.check.abstract.dependency") + + with patched as (m_version, m_class, m_repo): + assert dependency.release == m_class.return_value.return_value + + assert ( + list(m_class.return_value.call_args) + == [(m_repo.return_value, m_version.return_value), {}]) + assert "release" in dependency.__dict__ + + +def test_dependency_release_date(): + metadata = MagicMock() + dependency = DummyGithubDependency2("ID", metadata, "GITHUB") + assert dependency.release_date == metadata.__getitem__.return_value + assert "release_date" not in dependency.__dict__ + + +@pytest.mark.asyncio +@pytest.mark.parametrize("date1", range(0, 5)) +@pytest.mark.parametrize("date2", range(0, 5)) +async def test_dependency_release_date_mismatch(patches, date1, date2): + dependency = DummyGithubDependency2("ID", "METADATA", "GITHUB") + patched = patches( + ("AGithubDependency.release", + dict(new_callable=PropertyMock)), + ("AGithubDependency.release_date", + dict(new_callable=PropertyMock)), + prefix="envoy.dependency.check.abstract.dependency") + + with patched as (m_release, m_date): + m_date.return_value = date1 + m_release.return_value.date = AsyncMock(return_value=date2)() + assert await dependency.release_date_mismatch == (date1 != date2) + + assert not getattr( + dependency, + AGithubDependency.release_date_mismatch.cache_name, + None) + + +def test_dependency_repo(patches): + github = MagicMock() + dependency = DummyGithubDependency2("ID", "METADATA", github) + patched = patches( + ("AGithubDependency.organization", + dict(new_callable=PropertyMock)), + ("AGithubDependency.project", + dict(new_callable=PropertyMock)), + prefix="envoy.dependency.check.abstract.dependency") + + with patched as (m_org, m_project): + assert ( + dependency.repo + == github.__getitem__.return_value) + + assert ( + list(github.__getitem__.call_args) + == [(f"{m_org.return_value}/{m_project.return_value}", ), {}]) + assert "repo" in dependency.__dict__ + + +@pytest.mark.parametrize("github_url", [True, False]) +def test_dependency_url_components(patches, github_url): + dependency = DummyGithubDependency2("ID", "METADATA", "GITHUB") + patched = patches( + ("AGithubDependency.github_url", + dict(new_callable=PropertyMock)), + ("AGithubDependency.urls", + dict(new_callable=PropertyMock)), + prefix="envoy.dependency.check.abstract.dependency") + urls = [f"URL{i}" for i in range(0, 5)] + + with patched as (m_url, m_urls): + if not github_url: + m_url.return_value = None + m_urls.return_value = urls + + if not github_url: + with pytest.raises(exceptions.NotGithubDependency) as e: + dependency.url_components + urls = "\n".join(urls) + assert ( + e.value.args[0] + == f'ID is not a GitHub repository\n{urls}') + else: + assert ( + dependency.url_components + == m_url.return_value.split.return_value) + + if github_url: + assert ( + list(m_url.return_value.split.call_args) + == [("/", ), {}]) + assert "url_components" in dependency.__dict__ + + +def test_dependency_urls(): + metadata = MagicMock() + dependency = DummyGithubDependency2("ID", metadata, "GITHUB") + assert dependency.urls == metadata.__getitem__.return_value + assert "release_date" not in dependency.__dict__ + + +def test_dependency_version(): + metadata = MagicMock() + dependency = DummyGithubDependency2("ID", metadata, "GITHUB") + assert dependency.version == metadata.__getitem__.return_value + assert "release_date" not in dependency.__dict__ diff --git a/envoy.dependency.check/tests/test_abstract_issues.py b/envoy.dependency.check/tests/test_abstract_issues.py new file mode 100644 index 0000000000..5067749985 --- /dev/null +++ b/envoy.dependency.check/tests/test_abstract_issues.py @@ -0,0 +1,657 @@ + +from unittest.mock import AsyncMock, MagicMock, PropertyMock + +import pytest + +import abstracts + +from aio.api import github + +from envoy.dependency.check import ( + abstract, + AGithubDependencyIssue, + AGithubDependencyIssues) + + +@abstracts.implementer(AGithubDependencyIssue) +class DummyGithubDependencyIssue: + pass + + +@abstracts.implementer(AGithubDependencyIssues) +class DummyGithubDependencyIssues: + + @property + def issue_class(self): + return super().issue_class + + +def test_issue_constructor(): + issue = DummyGithubDependencyIssue("ISSUES", "ISSUE") + assert issue.issues == "ISSUES" + assert issue.issue == "ISSUE" + + +@pytest.mark.parametrize( + "param", ["body", "number", "title"]) +def test_issue_issue_params(param): + mock_issue = MagicMock() + issue = DummyGithubDependencyIssue("ISSUES", mock_issue) + assert getattr(issue, param) == getattr(mock_issue, param) + assert param not in issue.__dict__ + + +@pytest.mark.parametrize( + "param", + ["closing_tpl", "repo_name", "title_re"]) +def test_issue_issues_params(param): + mock_issues = MagicMock() + issue = DummyGithubDependencyIssue(mock_issues, "ISSUE") + assert getattr(issue, param) == getattr(mock_issues, param) + assert param not in issue.__dict__ + + +def test_issue_dep(patches): + issue = DummyGithubDependencyIssue("ISSUES", "ISSUE") + patched = patches( + ("AGithubDependencyIssue.parsed", + dict(new_callable=PropertyMock)), + prefix="envoy.dependency.check.abstract.issues") + + with patched as (m_parsed, ): + assert issue.dep == m_parsed.return_value.get.return_value + + assert ( + list(m_parsed.return_value.get.call_args) + == [("dep", ), {}]) + assert "dep" not in issue.__dict__ + + +@pytest.mark.parametrize("parsed", [True, False]) +def test_issue_parsed(patches, parsed): + issue = DummyGithubDependencyIssue("ISSUES", "ISSUE") + patched = patches( + ("AGithubDependencyIssue.title", + dict(new_callable=PropertyMock)), + ("AGithubDependencyIssue.title_re", + dict(new_callable=PropertyMock)), + prefix="envoy.dependency.check.abstract.issues") + + with patched as (m_title, m_re): + if not parsed: + m_re.return_value.search.return_value = None + else: + m_re.return_value.search.return_value.group.side_effect = ( + lambda x: x) + assert ( + issue.parsed + == (dict(dep=1, version=2) + if parsed + else {})) + + assert "parsed" in issue.__dict__ + assert ( + list(m_re.return_value.search.call_args) + == [(m_title.return_value, ), {}]) + if not parsed: + return + assert ( + list(list(c) + for c + in m_re.return_value.search.return_value.group.call_args_list) + == [[(1, ), {}], [(2, ), {}]]) + + +@pytest.mark.parametrize("parsed", [True, False]) +def test_issue_version(patches, parsed): + issue = DummyGithubDependencyIssue("ISSUES", "ISSUE") + patched = patches( + "version", + ("AGithubDependencyIssue.parsed", + dict(new_callable=PropertyMock)), + prefix="envoy.dependency.check.abstract.issues") + + with patched as (m_version, m_parsed): + m_parsed.return_value = ( + dict(version=23) + if parsed + else {}) + assert ( + issue.version + == (m_version.parse.return_value + if parsed + else None)) + + assert "version" in issue.__dict__ + if not parsed: + assert not m_version.parse.called + return + assert ( + list(m_version.parse.call_args) + == [(23, ), {}]) + + +@pytest.mark.asyncio +async def test_issue_close(): + mock_issue = AsyncMock() + issue = DummyGithubDependencyIssue("ISSUES", mock_issue) + assert ( + await issue.close() + == mock_issue.close.return_value) + assert ( + list(mock_issue.close.call_args) + == [(), {}]) + + +@pytest.mark.asyncio +async def test_issue_close_duplicate(patches): + issue = DummyGithubDependencyIssue("ISSUES", "ISSUE") + dupe = AsyncMock() + assert not await issue.close_duplicate(dupe) + assert ( + list(dupe.close.call_args) + == [(), {}]) + + +@pytest.mark.asyncio +async def test_issue_close_old(patches): + issue = DummyGithubDependencyIssue("ISSUES", "ISSUE") + patched = patches( + ("AGithubDependencyIssue.closing_tpl", + dict(new_callable=PropertyMock)), + ("AGithubDependencyIssue.number", + dict(new_callable=PropertyMock)), + ("AGithubDependencyIssue.repo_name", + dict(new_callable=PropertyMock)), + prefix="envoy.dependency.check.abstract.issues") + old_issue = AsyncMock() + dep = MagicMock() + mock_release = MagicMock() + date = AsyncMock() + mock_release.date = date() + newer_release = AsyncMock(return_value=mock_release) + dep.newer_release = newer_release() + + with patched as (m_tpl, m_number, m_name): + assert not await issue.close_old(old_issue, dep) + + assert ( + list(old_issue.comment.call_args) + == [(m_tpl.return_value.format.return_value, ), {}]) + assert ( + list(m_tpl.return_value.format.call_args) + == [(), + dict(newer_release=newer_release.return_value, + newer_release_date=date.return_value, + full_name=dep.repo.name, + repo_location=m_name.return_value, + number=m_number.return_value)]) + assert ( + list(old_issue.close.call_args) + == [(), {}]) + + +@pytest.mark.asyncio +async def test_issue_comment(): + mock_issue = AsyncMock() + issue = DummyGithubDependencyIssue("ISSUES", mock_issue) + assert ( + await issue.comment("COMMENT") + == mock_issue.comment.return_value) + assert ( + list(mock_issue.comment.call_args) + == [("COMMENT", ), {}]) + + +@pytest.mark.parametrize("body_tpl", [None, "BODY_TPL"]) +@pytest.mark.parametrize("closing_tpl", [None, "CLOSING_TPL"]) +@pytest.mark.parametrize("issues_search_tpl", [None, "ISSUES_SEARCH_TPL"]) +@pytest.mark.parametrize("labels", [None, "LABELS"]) +@pytest.mark.parametrize("repo_name", [None, "REPO_NAME"]) +@pytest.mark.parametrize("title_prefix", [None, "TITLE_PREFIX"]) +@pytest.mark.parametrize("title_re_tpl", [None, "TITLE_RE_TPL"]) +@pytest.mark.parametrize("title_tpl", [None, "TITLE_TPL"]) +def test_issues_constructor( + body_tpl, closing_tpl, issues_search_tpl, labels, + repo_name, title_prefix, title_re_tpl, title_tpl): + kwargs = {} + if body_tpl: + kwargs["body_tpl"] = body_tpl + if closing_tpl: + kwargs["closing_tpl"] = closing_tpl + if issues_search_tpl: + kwargs["issues_search_tpl"] = issues_search_tpl + if labels: + kwargs["labels"] = labels + if repo_name: + kwargs["repo_name"] = repo_name + if title_prefix: + kwargs["title_prefix"] = title_prefix + if title_re_tpl: + kwargs["title_re_tpl"] = title_re_tpl + if title_tpl: + kwargs["title_tpl"] = title_tpl + + with pytest.raises(TypeError): + AGithubDependencyIssues("GITHUB", **kwargs) + + issues = DummyGithubDependencyIssues("GITHUB", **kwargs) + assert ( + issues.body_tpl + == (body_tpl or abstract.issues.BODY_TPL)) + assert ( + issues.closing_tpl + == (closing_tpl or abstract.issues.CLOSING_TPL)) + assert ( + issues.issues_search_tpl + == (issues_search_tpl or abstract.issues.ISSUES_SEARCH_TPL)) + assert ( + issues.labels + == (labels or abstract.issues.LABELS)) + assert ( + issues.repo_name + == (repo_name or abstract.issues.GITHUB_REPO_LOCATION)) + assert ( + issues.title_prefix + == (title_prefix or abstract.issues.TITLE_PREFIX)) + assert ( + issues.title_re_tpl + == (title_re_tpl or abstract.issues.TITLE_RE_TPL)) + assert ( + issues.title_tpl + == (title_tpl or abstract.issues.TITLE_TPL)) + + with pytest.raises(NotImplementedError): + issues.issue_class + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "search_results", + [[], + [True, False, True], + [False, False, False], + [True, True, True]]) +async def test_issues_dunder_aiter(patches, search_results): + issues = DummyGithubDependencyIssues("GITHUB") + patched = patches( + ("AGithubDependencyIssues.issue_class", + dict(new_callable=PropertyMock)), + "AGithubDependencyIssues.iter_issues", + prefix="envoy.dependency.check.abstract.issues") + expected = [] + results = [] + mock_issues = [] + for issue in search_results: + mock_issue = MagicMock() + mock_issue.dep = issue + if issue: + expected.append(mock_issue) + mock_issues.append(mock_issue) + + async def search_iter(): + for issue in mock_issues: + yield issue + + with patched as (m_class, m_iter): + m_iter.side_effect = search_iter + m_class.return_value.side_effect = lambda s, x: x + async for result in issues: + results.append(result) + + assert results == expected + assert ( + list(list(c) for c in m_class.return_value.call_args_list) + == [[(issues, issue), {}] for issue in mock_issues]) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "open_issues", + [[], + [dict(dep="DEP1", version=1)], + [dict(dep="DEP1", version=1), + dict(dep="DEP2", version=1)], + [dict(dep="DEP1", version=1), + dict(dep="DEP2", version=1), + dict(dep="DEP1", version=2)], + [dict(dep="DEP1", version=2), + dict(dep="DEP2", version=1), + dict(dep="DEP1", version=1)], + [dict(dep="DEP1", version=1), + dict(dep="DEP2", version=1), + dict(dep="DEP1", version=1)]]) +async def test_issues_dep_issues(patches, open_issues): + issues = DummyGithubDependencyIssues("GITHUB") + patched = patches( + ("AGithubDependencyIssues.open_issues", + dict(new_callable=PropertyMock)), + prefix="envoy.dependency.check.abstract.issues") + + mock_issues = [] + expected = {} + for issue in open_issues: + open_issue = MagicMock() + open_issue.dep = issue["dep"] + open_issue.version = issue["version"] + mock_issues.append(open_issue) + if issue["dep"] in expected: + if issue["version"] > expected[issue["dep"]].version: + expected[issue["dep"]] = open_issue + else: + expected[issue["dep"]] = open_issue + + with patched as (m_open, ): + m_open.side_effect = AsyncMock(return_value=mock_issues) + assert await issues.dep_issues == expected + + assert ( + getattr( + issues, + AGithubDependencyIssues.dep_issues.cache_name)[ + "dep_issues"] + == expected) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "open_issues", [[], range(0, 5), range(0, 3), range(2, 5)]) +@pytest.mark.parametrize( + "dep_issues", [[], range(0, 5), range(0, 3), range(2, 5)]) +async def test_issues_duplicate_issues(patches, open_issues, dep_issues): + issues = DummyGithubDependencyIssues("GITHUB") + patched = patches( + ("AGithubDependencyIssues.dep_issues", + dict(new_callable=PropertyMock)), + ("AGithubDependencyIssues.open_issues", + dict(new_callable=PropertyMock)), + prefix="envoy.dependency.check.abstract.issues") + results = set() + expected = set(open_issues) - set(dep_issues) + deps_obj = MagicMock() + deps_obj.values.return_value = dep_issues + deps = AsyncMock(return_value=deps_obj) + + with patched as (m_dep, m_open): + m_open.side_effect = AsyncMock(return_value=open_issues) + m_dep.side_effect = deps + + async for issue in issues.duplicate_issues: + results.add(issue) + + assert results == expected + assert not getattr( + issues, + AGithubDependencyIssues.duplicate_issues.cache_name, + None) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "repo_labels", + [[], + [f"LABEL{i}" for i in range(0, 5)], + [f"LABEL{i}" for i in range(0, 3)], + [f"LABEL{i}" for i in range(0, 10)], + [f"LABEL{i}" for i in range(2, 7)]]) +async def test_issues_missing_labels(patches, repo_labels): + labels = [f"LABEL{i}" for i in range(1, 5)] + issues = DummyGithubDependencyIssues("GITHUB", labels=labels) + patched = patches( + ("AGithubDependencyIssues.repo", + dict(new_callable=PropertyMock)), + prefix="envoy.dependency.check.abstract.issues") + + expected = 0 + found = {} + for label in repo_labels: + expected += 1 + if label in labels: + found[label] = None + if len(labels) == len(found): + break + + class LabelIter: + count = 0 + + async def __aiter__(self): + for issue in repo_labels: + self.count += 1 + mock_label = MagicMock() + mock_label.name = issue + yield mock_label + + label_iter = LabelIter() + + with patched as (m_repo, ): + m_repo.return_value.labels = label_iter + result = await issues.missing_labels + assert ( + result + == tuple( + label + for label + in labels + if label not in repo_labels)) + assert label_iter.count == expected + assert ( + getattr( + issues, + AGithubDependencyIssues.missing_labels.cache_name)[ + "missing_labels"] + == result) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("self_issues", [[], range(0, 5)]) +async def test_issues_open_issues(patches, self_issues): + issues = DummyGithubDependencyIssues("GITHUB") + patched = patches( + "AGithubDependencyIssues.__aiter__", + prefix="envoy.dependency.check.abstract.issues") + mock_issues = [] + + for issue in self_issues: + mock_issue = MagicMock() + mock_issues.append(mock_issue) + + async def iter_issues(): + for issue in mock_issues: + yield issue + + with patched as (m_iter, ): + m_iter.side_effect = iter_issues + result = await issues.open_issues + assert result == tuple(mock_issues) + + assert ( + getattr( + issues, + AGithubDependencyIssues.open_issues.cache_name)[ + "open_issues"] + == result) + + +def test_issues_repo(patches): + github = MagicMock() + issues = DummyGithubDependencyIssues( + github, repo_name="REPO_NAME") + assert issues.repo == github.__getitem__.return_value + assert ( + list(github.__getitem__.call_args) + == [("REPO_NAME", ), {}]) + assert "repo" in issues.__dict__ + + +def test_issues_title_re(patches): + title_prefix = MagicMock() + title_re_tpl = MagicMock() + issues = DummyGithubDependencyIssues( + "GITHUB", + title_re_tpl=title_re_tpl, + title_prefix=title_prefix) + patched = patches( + "re", + prefix="envoy.dependency.check.abstract.issues") + + with patched as (m_re, ): + assert issues.title_re == m_re.compile.return_value + + assert "title_re" in issues.__dict__ + assert ( + list(m_re.compile.call_args) + == [(title_re_tpl.format.return_value, ), {}]) + assert ( + list(title_re_tpl.format.call_args) + == [(), dict(title_prefix=title_prefix)]) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "titles", + [[], + [f"TITLE{i}" for i in range(0, 5)]]) +async def test_issues_titles(patches, titles): + issues = DummyGithubDependencyIssues("GITHUB") + patched = patches( + ("AGithubDependencyIssues.open_issues", + dict(new_callable=PropertyMock)), + prefix="envoy.dependency.check.abstract.issues") + + mock_titles = [] + for title in titles: + mock_title = MagicMock() + mock_title.title = title + mock_titles.append(mock_title) + + with patched as (m_open, ): + m_open.side_effect = AsyncMock(return_value=mock_titles) + result = await issues.titles + assert ( + result + == tuple(titles)) + + assert ( + getattr( + issues, + AGithubDependencyIssues.titles.cache_name)[ + "titles"] + == result) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("in_titles", [True, False]) +async def test_issues_create(patches, in_titles): + issues = DummyGithubDependencyIssues("GITHUB", labels=["LABEL"]) + patched = patches( + ("AGithubDependencyIssues.repo", + dict(new_callable=PropertyMock)), + ("AGithubDependencyIssues.issue_class", + dict(new_callable=PropertyMock)), + ("AGithubDependencyIssues.titles", + dict(new_callable=PropertyMock)), + "AGithubDependencyIssues.issue_body", + "AGithubDependencyIssues.issue_title", + prefix="envoy.dependency.check.abstract.issues") + titles = [f"TITLE{i}" for i in range(0, 5)] + dep = MagicMock() + + with patched as (m_repo, m_class, m_titles, m_body, m_title): + m_titles.side_effect = AsyncMock(return_value=titles) + if in_titles: + titles.append(m_title.return_value) + m_repo.return_value.issues.create = AsyncMock() + if in_titles: + with pytest.raises(github.exceptions.IssueExists) as e: + await issues.create(dep) + assert e.value.args[0] == m_title.return_value + else: + assert ( + await issues.create(dep) + == m_class.return_value.return_value) + + assert ( + list(m_title.call_args) + == [(dep, ), {}]) + if in_titles: + assert not m_class.called + assert not m_repo.called + assert not m_body.called + return + assert ( + list(m_class.return_value.call_args) + == [(issues, m_repo.return_value.issues.create.return_value), {}]) + assert ( + list(m_repo.return_value.issues.create.call_args) + == [(m_title.return_value,), + dict(body=m_body.return_value, labels=["LABEL"])]) + assert ( + list(m_body.call_args) + == [(dep, ), {}]) + + +@pytest.mark.asyncio +async def test_issues_issue_body(): + body_tpl = MagicMock() + issues = DummyGithubDependencyIssues("GITHUB", body_tpl=body_tpl) + dep = MagicMock() + date = AsyncMock() + mock_release = MagicMock() + mock_release.date = date() + newer_release = AsyncMock(return_value=mock_release) + dep.newer_release = newer_release() + release_date = AsyncMock() + dep.release.date = release_date() + assert ( + await issues.issue_body(dep) + == body_tpl.format.return_value) + assert ( + list(body_tpl.format.call_args) + == [(), + dict(dep=dep, + newer_release=newer_release.return_value, + newer_release_date=date.return_value, + release_date=release_date.return_value)]) + + +@pytest.mark.asyncio +async def test_issues_issue_title(): + title_tpl = MagicMock() + issues = DummyGithubDependencyIssues( + "GITHUB", + title_tpl=title_tpl, + title_prefix="TITLE_PREFIX") + dep = MagicMock() + newer_release = AsyncMock() + dep.newer_release = newer_release() + assert ( + await issues.issue_title(dep) + == title_tpl.format.return_value) + assert ( + list(title_tpl.format.call_args) + == [(), + dict(dep=dep, + newer_release=newer_release.return_value, + title_prefix="TITLE_PREFIX")]) + + +def test_issues_iter_issues(patches): + issues_search_tpl = MagicMock() + issues = DummyGithubDependencyIssues( + "GITHUB", issues_search_tpl=issues_search_tpl) + patched = patches( + ("AGithubDependencyIssues.repo", + dict(new_callable=PropertyMock)), + prefix="envoy.dependency.check.abstract.issues") + + with patched as (m_repo, ): + assert ( + issues.iter_issues() + == m_repo.return_value.issues.search.return_value) + + assert ( + list(m_repo.return_value.issues.search.call_args) + == [(issues_search_tpl.format.return_value, ), {}]) + assert ( + list(issues_search_tpl.format.call_args) + == [(), dict(self=issues)]) diff --git a/envoy.dependency.check/tests/test_abstract_release.py b/envoy.dependency.check/tests/test_abstract_release.py new file mode 100644 index 0000000000..f05bb983df --- /dev/null +++ b/envoy.dependency.check/tests/test_abstract_release.py @@ -0,0 +1,344 @@ + +from unittest.mock import AsyncMock, MagicMock, PropertyMock + +import pytest + +import gidgethub + +import abstracts + +from aio.api import github + +from envoy.dependency.check import ADependencyGithubRelease + + +@abstracts.implementer(ADependencyGithubRelease) +class DummyDependencyGithubRelease: + pass + + +def test_release_constructor(): + release = DummyDependencyGithubRelease("REPO", "VERSION") + assert release.repo == "REPO" + assert release._version == "VERSION" + assert release.tag_name == "VERSION" + assert "tag_name" not in release.__dict__ + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "raises", + [None, Exception, gidgethub.BadRequest]) +@pytest.mark.parametrize("err", ["", "SOMETHING ELSE", "Not Found"]) +async def test_release_commit(patches, raises, err): + repo = AsyncMock() + if raises: + msg = MagicMock() + msg.phrase = err + error = raises(msg) + repo.commit.side_effect = error + release = DummyDependencyGithubRelease(repo, "VERSION") + patched = patches( + ("ADependencyGithubRelease.tag_name", + dict(new_callable=PropertyMock)), + prefix="envoy.dependency.check.abstract.release") + should_fail = ( + raises + and not (raises == gidgethub.BadRequest + and err == "Not Found")) + + with patched as (m_tagname, ): + if should_fail: + with pytest.raises(raises): + await release.commit + else: + result = await release.commit + assert ( + result + == (repo.commit.return_value + if not raises + else None)) + assert ( + list(repo.commit.call_args) + == [(m_tagname.return_value, ), {}]) + + if not should_fail: + assert ( + getattr( + release, + ADependencyGithubRelease.commit.cache_name)[ + "commit"] + == result) + + +@pytest.mark.asyncio +async def test_release_date(patches): + release = DummyDependencyGithubRelease("REPO", "VERSION") + patched = patches( + "utils", + ("ADependencyGithubRelease.timestamp", + dict(new_callable=PropertyMock)), + prefix="envoy.dependency.check.abstract.release") + + with patched as (m_utils, m_timestamp): + m_timestamp.side_effect = AsyncMock(return_value=23) + result = await release.date + assert ( + result + == m_utils.dt_to_utc_isoformat.return_value) + + assert ( + list(m_utils.dt_to_utc_isoformat.call_args) + == [(23, ), {}]) + assert ( + getattr( + release, + ADependencyGithubRelease.date.cache_name)[ + "date"] + == result) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "raises", + [None, Exception, gidgethub.BadRequest]) +@pytest.mark.parametrize("provided", [True, False]) +@pytest.mark.parametrize("err", ["", "SOMETHING ELSE", "Not Found"]) +async def test_release_release(patches, raises, err, provided): + repo = AsyncMock() + if raises: + msg = MagicMock() + msg.phrase = err + error = raises(msg) + repo.release.side_effect = error + kwargs = {} + if provided: + kwargs["release"] = MagicMock() + release = DummyDependencyGithubRelease(repo, "VERSION", **kwargs) + patched = patches( + ("ADependencyGithubRelease.tag_name", + dict(new_callable=PropertyMock)), + prefix="envoy.dependency.check.abstract.release") + should_fail = ( + raises + and not provided + and not (raises == gidgethub.BadRequest + and err == "Not Found")) + + with patched as (m_tagname, ): + if should_fail: + with pytest.raises(raises): + await release.release + else: + result = await release.release + + if not should_fail: + if provided: + assert result == kwargs["release"] + else: + assert ( + result + == (repo.release.return_value + if not raises + else None)) + assert ( + getattr( + release, + ADependencyGithubRelease.release.cache_name)[ + "release"] + == result) + if not provided: + assert ( + list(repo.release.call_args) + == [(m_tagname.return_value, ), {}]) + else: + assert not repo.release.called + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "raises", + [None, Exception, gidgethub.BadRequest, github.exceptions.TagNotFound]) +@pytest.mark.parametrize("err", ["", "SOMETHING ELSE", "Not Found"]) +async def test_release_tag(patches, raises, err): + repo = AsyncMock() + if raises: + msg = MagicMock() + msg.phrase = err + error = raises(msg) + repo.tag.side_effect = error + release = DummyDependencyGithubRelease(repo, "VERSION") + patched = patches( + ("ADependencyGithubRelease.tag_name", + dict(new_callable=PropertyMock)), + prefix="envoy.dependency.check.abstract.release") + should_fail = ( + raises + and not (raises == gidgethub.BadRequest + and err == "Not Found") + and not raises == github.exceptions.TagNotFound) + + with patched as (m_tagname, ): + if should_fail: + with pytest.raises(raises): + await release.tag + else: + result = await release.tag + if not should_fail: + assert ( + result + == (repo.tag.return_value + if not raises + else None)) + assert ( + getattr( + release, + ADependencyGithubRelease.tag.cache_name)[ + "tag"] + == result) + assert ( + list(repo.tag.call_args) + == [(m_tagname.return_value, ), {}]) + + +@pytest.mark.parametrize("is_sha", [True, False]) +def test_release_tagged(patches, is_sha): + release = DummyDependencyGithubRelease("REPO", "VERSION") + patched = patches( + "utils", + ("ADependencyGithubRelease.tag_name", + dict(new_callable=PropertyMock)), + prefix="envoy.dependency.check.abstract.release") + + with patched as (m_utils, m_tagname): + m_utils.is_sha.return_value = is_sha + assert release.tagged == (not is_sha) + + assert ( + list(m_utils.is_sha.call_args) + == [(m_tagname.return_value, ), {}]) + assert "tagged" in release.__dict__ + + +@pytest.mark.asyncio +@pytest.mark.parametrize("tagged", [True, False]) +async def test_release_timestamp(patches, tagged): + release = DummyDependencyGithubRelease("REPO", "VERSION") + patched = patches( + ("ADependencyGithubRelease.tagged", + dict(new_callable=PropertyMock)), + ("ADependencyGithubRelease.timestamp_commit", + dict(new_callable=PropertyMock)), + ("ADependencyGithubRelease.timestamp_tag", + dict(new_callable=PropertyMock)), + prefix="envoy.dependency.check.abstract.release") + tcommit = AsyncMock() + ttag = AsyncMock() + + with patched as (m_tagged, m_tcommit, m_ttag): + m_tagged.return_value = tagged + m_tcommit.side_effect = tcommit + m_ttag.side_effect = ttag + result = await release.timestamp + assert ( + result + == (ttag.return_value + if tagged + else tcommit.return_value)) + + assert ( + getattr( + release, + ADependencyGithubRelease.timestamp.cache_name)[ + "timestamp"] + == result) + + +@pytest.mark.asyncio +async def test_release_timestamp_commit(patches): + release = DummyDependencyGithubRelease("REPO", "VERSION") + patched = patches( + ("ADependencyGithubRelease.commit", + dict(new_callable=PropertyMock)), + prefix="envoy.dependency.check.abstract.release") + + with patched as (m_commit, ): + commit = AsyncMock() + m_commit.side_effect = commit + result = await release.timestamp_commit + assert ( + result + == commit.return_value.timestamp) + + assert ( + getattr( + release, + ADependencyGithubRelease.timestamp_commit.cache_name)[ + "timestamp_commit"] + == result) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("has_release", [True, False]) +@pytest.mark.parametrize("has_tag", [True, False]) +async def test_release_timestamp_tag(patches, has_release, has_tag): + release = DummyDependencyGithubRelease("REPO", "VERSION") + patched = patches( + ("ADependencyGithubRelease.release", + dict(new_callable=PropertyMock)), + ("ADependencyGithubRelease.tag", + dict(new_callable=PropertyMock)), + ("ADependencyGithubRelease.timestamp_commit", + dict(new_callable=PropertyMock)), + prefix="envoy.dependency.check.abstract.release") + git_release = AsyncMock() + if not has_release: + git_release.return_value = None + git_tag = AsyncMock() + if not has_tag: + git_tag.return_value = None + else: + git_commit = AsyncMock() + type(git_tag.return_value).commit = PropertyMock( + side_effect=git_commit) + + with patched as (m_release, m_tag, m_commit): + m_release.side_effect = git_release + m_tag.side_effect = git_tag + commit = AsyncMock() + m_commit.side_effect = commit + result = await release.timestamp_tag + + if not has_release and not has_tag: + assert ( + result + == commit.return_value) + else: + assert ( + result + == (git_release.return_value.published_at + if has_release + else git_commit.return_value.timestamp)) + assert ( + getattr( + release, + ADependencyGithubRelease.timestamp_tag.cache_name)[ + "timestamp_tag"] + == result) + + +def test_release_version(patches): + release = DummyDependencyGithubRelease("REPO", "VERSION") + patched = patches( + "version", + ("ADependencyGithubRelease.tag_name", + dict(new_callable=PropertyMock)), + prefix="envoy.dependency.check.abstract.release") + + with patched as (m_version, m_tagname): + assert release.version == m_version.parse.return_value + + assert ( + list(m_version.parse.call_args) + == [(m_tagname.return_value, ), {}]) + assert "version" in release.__dict__