"""Check package URLs for updates Subclasses of `Hoster` define how to handle each hoster. Hosters are selected by regex matching each source URL in a recipe. The `HTMLHoster` provides parsing for hosting sites listing new releases in HTML format (probably covers most). Adding a hoster is as simple as defining a regex to match the existing source URL, a formatting string creating the URL of the relases page and a regex to match links and extract their version. - We need to use :conda:package:`regex` rather than `re` to allow recursive matching to manipulate capture groups in URL patterns as needed. (Technically, we could avoid this using a Snakemake wildcard type syntax to define the patterns - implementers welcome). """ import abc import inspect import json import logging import os from contextlib import redirect_stdout, redirect_stderr from distutils.version import LooseVersion from html.parser import HTMLParser from itertools import chain from typing import (Any, Dict, List, Match, Mapping, Pattern, Set, Tuple, Type, Optional, TYPE_CHECKING) from urllib.parse import urljoin import regex as re from .aiopipe import AsyncRequests logger = logging.getLogger(__name__) # pylint: disable=invalid-name #: Matches named capture groups #: This is so complicated because we need to parse matched, not-escaped #: parentheses to determine where the clause ends. #: Requires regex package for recursion. RE_CAPGROUP = re.compile(r"\(\?P<(\w+)>(?>[^()]+|\\\(|\\\)|(\((?>[^()]+|\\\(|\\\)|(?2))*\)))*\)") RE_REFGROUP = re.compile(r"\(\?P=(\w+)\)") def dedup_named_capture_group(pattern): """Replaces repetitions of capture groups with matches to first instance""" seen: Set[str] = set() def replace(match): "inner replace" name: str = match.group(1) if name in seen: return f"(?P={name})" seen.add(name) return match.group(0) return re.sub(RE_CAPGROUP, replace, pattern) def replace_named_capture_group(pattern, vals: Dict[str, str]): """Replaces capture groups with values from **vals**""" def replace(match): "inner replace" name = match.group(1) if name in vals: return vals[name] or "" return match.group(0) res = re.sub(RE_CAPGROUP, replace, pattern) res = re.sub(RE_REFGROUP, replace, res) return res class HosterMeta(abc.ABCMeta): """Meta-Class for Hosters By making Hosters classes of a metaclass, rather than instances of a class, we leave the option to add functions to a Hoster. """ hoster_types: List["HosterMeta"] = [] def __new__(cls, name: str, bases: Tuple[type, ...], namespace: Dict[str, Any], **kwargs) -> type: """Creates Hoster classes - expands references among ``{var}_pattern`` attributes - compiles ``{var}_pattern`` attributes to ``{var}_re`` - registers complete classes """ typ = super().__new__(cls, name, bases, namespace, **kwargs) if inspect.isabstract(typ): return typ if not typ.__name__.startswith("Custom"): cls.hoster_types.append(typ) patterns = {attr.replace("_pattern", ""): getattr(typ, attr) for attr in dir(typ) if attr.endswith("_pattern")} for pat in patterns: # expand pattern references: pattern = "" new_pattern = patterns[pat] while pattern != new_pattern: pattern = new_pattern new_pattern = re.sub(r"(\{\d+,?\d*\})", r"{\1}", pattern) new_pattern = new_pattern.format_map( {k: v.rstrip("$") for k, v in patterns.items()}) patterns[pat] = pattern # repair duplicate capture groups: pattern = dedup_named_capture_group(pattern) # save parsed and compiled pattern setattr(typ, pat + "_pattern_compiled", pattern) logger.debug("%s Pattern %s = %s", typ.__name__, pat, pattern) setattr(typ, pat + "_re", re.compile(pattern)) return typ @classmethod def select_hoster(cls, url: str, config: Dict[str, str]) -> Optional["Hoster"]: """Select `Hoster` able to handle **url** Returns: `Hoster` or `None` """ logger.debug("Matching url '%s'", url) for hoster_type in cls.hoster_types: hoster = hoster_type.try_make_hoster(url, config) if hoster: return hoster return None class Hoster(metaclass=HosterMeta): """Hoster Baseclass""" #: matches upstream version #: - begins with a number #: - then only numbers, characters or one of -, +, ., :, ~ #: - at most 31 characters length (to avoid matching checksums) #: - accept v or r as prefix if after slash, dot, underscore or dash version_pattern: str = r"(?:(?<=[/._-])[rv])?(?P<version>\d[\da-zA-Z\-+\.:\~_]{0,30})" #: matches archive file extensions ext_pattern: str = r"(?P<ext>(?i)\.(?:(?:(tar\.|t)(?:xz|bz2|gz))|zip|jar))" #: named patterns that will change with a version upgrade exclude = ['version'] @property @abc.abstractmethod def url_pattern(self) -> str: "matches upstream package url" #: will be generated as each class is created url_re: Pattern[str] = None @property @abc.abstractmethod def link_pattern(self) -> str: "matches links on relase page" @property @abc.abstractmethod def releases_formats(self) -> List[str]: "format template for release page URL" def __init__(self, url: str, match: Match[str]) -> None: self.vals = {k: v or "" for k, v in match.groupdict().items()} self.releases_urls = [ template.format_map(self.vals) for template in self.releases_formats ] logger.debug("%s matched %s with %s", self.__class__.__name__, url, self.vals) @classmethod def try_make_hoster(cls: Type["Hoster"], url: str, config: Dict[str, str]) -> Optional["Hoster"]: """Creates hoster if **url** is matched by its **url_pattern**""" if config: try: klass: Type["Hoster"] = type( "Customized" + cls.__name__, (cls,), {key+"_pattern":val for key, val in config.items()} ) except KeyError: logger.debug("Overrides invalid for %s - skipping", cls.__name__) return None else: klass = cls match = klass.url_re.search(url) if match: return klass(url, match) return None @classmethod @abc.abstractmethod def get_versions(cls, req: "AsyncRequests", orig_version: str) -> List[Mapping[str, Any]]: "Gets list of versions from upstream hosting site" class HrefParser(HTMLParser): """Extract link targets from HTML""" def __init__(self, link_re: Pattern[str]) -> None: super().__init__() self.link_re = link_re self.matches: List[Mapping[str, Any]] = [] def get_matches(self) -> List[Mapping[str, Any]]: """Return matches found for **link_re** in href links""" return self.matches def handle_starttag(self, tag: str, attrs: List[Tuple[str, str]]) -> None: if tag == "a": for key, val in attrs: if key == "href": self.handle_a_href(val) break def handle_a_href(self, href: str) -> None: """Process href attributes of anchor tags""" match = self.link_re.search(href) if match: data = match.groupdict() data["href"] = href self.matches.append(data) def error(self, message: str) -> None: logger.debug("Error parsing HTML: %s", message) # pylint: disable=abstract-method class HTMLHoster(Hoster): """Base for Hosters handling release listings in HTML format""" async def get_versions(self, req, orig_version): exclude = set(self.exclude) vals = {key: val for key, val in self.vals.items() if key not in exclude} link_pattern = replace_named_capture_group(self.link_pattern_compiled, vals) link_re = re.compile(link_pattern) result = [] for url in self.releases_urls: parser = HrefParser(link_re) parser.feed(await req.get_text_from_url(url)) for match in parser.get_matches(): match["link"] = urljoin(url, match["href"]) match["releases_url"] = url match["vals"] = vals result.append(match) return result class FTPHoster(Hoster): """Scans for updates on FTP servers""" async def get_versions(self, req, orig_version): exclude = set(self.exclude) vals = {key: val for key, val in self.vals.items() if key not in exclude} link_pattern = replace_named_capture_group(self.link_pattern_compiled, vals) link_re = re.compile(link_pattern) result = [] for url in self.releases_urls: files = await req.get_ftp_listing(url) for fname in files: match = link_re.search(fname) if match: data = match.groupdict() data['fn'] = fname data['link'] = "ftp://" + vals['host'] + fname data['releases_url'] = url result.append(data) return result version_pattern = r"(?:(?<=[/._-])[rv])?(?P<version>\d[\da-zA-Z\-+\.:\~_]{0,30}?)" host_pattern = r"(?P<host>[-_.\w]+)" path_pattern = r"(?P<path>[-_/.\w]+/)" package_pattern = r"(?P<package>[-_\w]+)" suffix_pattern = r"(?P<suffix>([-_](lin|linux|Linux|x64|x86|src|64|OSX))*)" link_pattern = "{path}{package}{version}{suffix}{ext}" url_pattern = r"ftp://{host}/{link}" releases_formats = ["ftp://{host}/{path}"] class OrderedHTMLHoster(HTMLHoster): """HTMLHoster for which we can expected newest releases at top The point isn't performance, but avoiding hassle with old versions which may follow different versioning schemes. E.g. 0.09 -> 0.10 -> 0.2 -> 0.2.1 FIXME: If the current version is not in the list, that's likely a pathologic case. Should be handled somewhere. """ async def get_versions(self, req, orig_version): matches = await super().get_versions(req, orig_version) num = None for num, match in enumerate(matches): if match["version"] == self.vals["version"]: break if num is None: return matches return matches[:num + 1] class GithubBase(OrderedHTMLHoster): """Base class for software hosted on github.com""" exclude = ['version', 'fname'] account_pattern = r"(?P<account>[-\w]+)" project_pattern = r"(?P<project>[-.\w]+)" prefix_pattern = r"(?P<prefix>[-_./\w]+?)" suffix_pattern = r"(?P<suffix>[-_](lin)?)" #tag_pattern = "{prefix}??{version}{suffix}??" tag_pattern = "{prefix}??{version}" url_pattern = r"github\.com{link}" fname_pattern = r"(?P<fname>[^/]+)" releases_formats = ["https://github.com/{account}/{project}/releases"] class GithubRelease(GithubBase): """Matches release artifacts uploaded to Github""" link_pattern = r"/{account}/{project}/releases/download/{tag}/{fname}{ext}?" class GithubTag(GithubBase): """Matches GitHub repository archives created automatically from tags""" link_pattern = r"/{account}/{project}/archive/{tag}{ext}" releases_formats = ["https://github.com/{account}/{project}/tags"] class GithubReleaseAttachment(GithubBase): """Matches release artifacts uploaded as attachment to release notes""" link_pattern = r"/{account}/{project}/files/\d+/{tag}{ext}" class GithubRepoStore(GithubBase): """Matches release artifacts stored in a github repo""" branch_pattern = r"(master|[\da-f]{40})" subdir_pattern = r"(?P<subdir>([-._\w]+/)+)" link_pattern = r"/{account}/{project}/blob/master/{subdir}{tag}{ext}" url_pattern = (r"(?:(?P<raw>raw\.githubusercontent)|github)\.com/" r"{account}/{project}/(?(raw)|(?:(?P<blob>blob/)|raw/))" r"{branch}/{subdir}?{tag}{ext}(?(blob)\?raw|)") releases_formats = ["https://github.com/{account}/{project}/tree/master/{subdir}"] class Bioconductor(HTMLHoster): """Matches R packages hosted at Bioconductor""" link_pattern = r"/src/contrib/(?P<package>[^/]+)_{version}{ext}" section_pattern = r"/(bioc|data/annotation|data/experiment)" url_pattern = r"bioconductor.org/packages/(?P<bioc>[\d\.]+){section}{link}" releases_formats = ["https://bioconductor.org/packages/{bioc}/bioc/html/{package}.html"] class CargoPort(HTMLHoster): """Matches source backup urls created by cargo-port""" os_pattern = r"_(?P<os>src_all|linux_x86|darwin_x86)" link_pattern = r"(?P<package>[^/]+)_{version}{os}{ext}" url_pattern = r"depot.galaxyproject.org/software/(?P<package>[^/]+)/{link}" releases_formats = ["https://depot.galaxyproject.org/software/{package}"] class SourceForge(HTMLHoster): """Matches packages hosted at SourceForge""" project_pattern = r"(?P<project>[-\w]+)" subproject_pattern = r"((?P<subproject>[-\w%]+)/)?" baseurl_pattern = r"sourceforge\.net/project(s)?/{project}/(?(1)files/|){subproject}" package_pattern = r"(?P<package>[-\w_\.+]*?[a-zA-Z+])" type_pattern = r"(?P<type>((linux|x?(64|86)|src|source|all|core|java\d?)[-_.])*)" type2_pattern = type_pattern.replace("type", "type2") sep_pattern = r"(?P<sep>[-_.]?)" # separator between package name and version filename_pattern = "{package}{sep}({type2}{sep})?{version}({sep}{type})?{ext}" url_pattern = r"{baseurl}{filename}" link_pattern = r"{baseurl}{filename}" releases_formats = ["https://sourceforge.net/projects/{project}/files/"] class JSONHoster(Hoster): """Base for Hosters handling release listings in JSON format""" async def get_versions(self, req, orig_version: str): result = [] for url in self.releases_urls: text = await req.get_text_from_url(url) data = json.loads(text) matches = await self.get_versions_from_json(data, req, orig_version) for match in matches: match['releases_url'] = url result.extend(matches) return result link_pattern = "https://{url}" @abc.abstractmethod async def get_versions_from_json(self, data, req, orig_version) -> List[Dict[str, Any]]: """Extract matches from json data in **data** """ class PyPi(JSONHoster): """Scans PyPi for updates""" async def get_versions_from_json(self, data, req, orig_version): latest = data["info"]["version"] result = [] for vers in list(set([latest, orig_version])): if vers not in data['releases']: continue for rel in data['releases'][vers]: if rel["packagetype"] == "sdist": rel["link"] = rel["url"] rel["version"] = vers rel["info"] = data['info'] result.append(rel) return result @staticmethod def _get_requirements(package, fname, url, digest, python_version, build_config): """Call into conda_build.skeletons.pypi to handle the ugly mess of extracting requirements from python packages. Note: It is not safe to call into conda multiple times parallel, and thus this function must not be called in parallel. """ from conda_build.skeletons.pypi import get_pkginfo, get_requirements with open("/dev/null", "w") as devnull: with redirect_stdout(devnull), redirect_stderr(devnull): try: pkg_info = get_pkginfo(package, fname, url, digest, python_version, [], build_config, []) requirements = get_requirements(package, pkg_info) except SystemExit as exc: raise Exception(exc) from None except Exception as exc: raise Exception(exc) from None if len(requirements) == 1 and isinstance(requirements[0], list): requirements = requirements[0] requirements_fixed = [] for req in requirements: if '\n' in req: requirements_fixed.extend(req.split('\n')) else: requirements_fixed.append(req) return pkg_info, requirements_fixed @staticmethod def _get_python_version(rel): """Try to determine correct python version""" choose_from = ('3.6', '3.5', '3.7', '2.7') requires_python = rel.get('requires_python') if requires_python: requires_python = requires_python.replace(" ", "") checks = [] for check in requires_python.split(","): for key, func in (('==', lambda x, y: x == y), ('!=', lambda x, y: x != y), ('<=', lambda x, y: x <= y), ('>=', lambda x, y: x >= y), ('>', lambda x, y: x > y), ('<', lambda x, y: x > y), ('~=', lambda x, y: x == y)): if check.startswith(key): checks.append((func, check[len(key):])) break else: checks.append((lambda x, y: x == y, check)) for vers in choose_from: try: if all(op(LooseVersion(vers), LooseVersion(check)) for op, check in checks): return vers except TypeError: logger.exception("Failed to compare %s to %s", vers, requires_python) python_versions = [ classifier.split('::')[-1].strip() for classifier in rel['info'].get('classifiers', []) if classifier.startswith('Programming Language :: Python ::') ] for vers in choose_from: if vers in python_versions: return vers return '2.7' async def get_deps(self, pipeline, build_config, package, rel): """Get dependencies for **package** using version data **rel** This is messy even though we use conda_build.skeleton.pypi to extract the requirements from a setup.py. Since the setup.py actually gets executed, all manner of things can happen (e.g. for one Bioconda package, this triggers compilation of a binary module). """ req = pipeline.req # We download ourselves to get async benefits target_file = rel['filename'] target_path = os.path.join(build_config.src_cache, target_file) if not os.path.exists(target_path): await req.get_file_from_url(target_path, rel['link'], target_file) python_version = self._get_python_version(rel) # Run code from conda_build.skeletons in ProcessPoolExecutor async with pipeline.conda_sem: try: pkg_info, depends = await pipeline.run_sp( self._get_requirements, package, target_file, rel['link'], ('sha256', rel['digests']['sha256']), python_version, build_config) except Exception: # pylint: disable=broad-except logger.info("Failed to get depends for PyPi %s (py=%s)", target_file, python_version) logger.debug("Exception data", exc_info=True) return logger.debug("PyPi info for %s: %s", target_file, pkg_info) # Convert into dict deps = {} for dep in depends: match = re.search(r'([^<>= ]+)(.*)', dep) if match: deps[match.group(1)] = match.group(2) # Write to rel dict for return rel['depends'] = {'host': deps, 'run': deps} releases_formats = ["https://pypi.org/pypi/{package}/json"] package_pattern = r"(?P<package>[\w\-\.]+)" source_pattern = r"{package}[-_]{version}{ext}" hoster_pattern = (r"(?P<hoster>" r"files.pythonhosted.org/packages|" r"pypi.python.org/packages|" r"pypi.io/packages)") url_pattern = r"{hoster}/.*/{source}" class Bioarchive(JSONHoster): """Scans for updates to packages hosted on bioarchive.galaxyproject.org""" async def get_versions_from_json(self, data, req, orig_version): try: latest = data["info"]["Version"] vals = {key: val for key, val in self.vals.items() if key not in self.exclude} vals['version'] = latest link = replace_named_capture_group(self.link_pattern, vals) return [{ "link": link, "version": latest, }] except KeyError: return [] releases_formats = ["https://bioarchive.galaxyproject.org/api/{package}.json"] package_pattern = r"(?P<package>[-\w.]+)" url_pattern = r"bioarchive.galaxyproject.org/{package}_{version}{ext}" class CPAN(JSONHoster): """Scans for updates to Perl packages hosted on CPAN""" @staticmethod def parse_deps(data): """Parse CPAN format dependencies""" run_deps = {} host_deps = {} for dep in data: if dep['relationship'] != 'requires': continue if dep['module'] in ('strict', 'warnings'): continue name = dep['module'].lower().replace('::', '-') if 'version' in dep and dep['version'] not in ('0', None, 'undef'): version = ">="+str(dep['version']) else: version = '' if name != 'perl': name = 'perl-' + name else: version = '' if dep['phase'] == 'runtime': run_deps[name] = version elif dep['phase'] in ('build', 'configure', 'test'): host_deps[name] = version return {'host': host_deps, 'run': run_deps} async def get_versions_from_json(self, data, req, orig_version): try: version = { 'link': data['download_url'], 'version': str(data['version']), 'depends': self.parse_deps(data['dependency']) } result = [version] if version['version'] != orig_version: url = self.orig_release_format.format(vers=orig_version, dist=data['distribution']) text = await req.get_text_from_url(url) data2 = json.loads(text) if data2['hits']['total']: data = data2['hits']['hits'][0]['_source'] orig_vers = { 'link': data['download_url'], 'version': str(data['version']), 'depends': self.parse_deps(data['dependency']) } result.append(orig_vers) return result except KeyError: return [] package_pattern = r"(?P<package>[-\w.+]+)" author_pattern = r"(?P<author>[A-Z]+)" url_pattern = (r"(www.cpan.org|cpan.metacpan.org|search.cpan.org/CPAN)" r"/authors/id/./../{author}/([^/]+/|){package}-v?{version}{ext}") releases_formats = ["https://fastapi.metacpan.org/v1/release/{package}"] orig_release_format = ("https://fastapi.metacpan.org/v1/release/_search" "?q=distribution:{dist}%20AND%20version:{vers}") class CRAN(JSONHoster): """R packages hosted on r-project.org (CRAN)""" async def get_versions_from_json(self, data, _, orig_version): res = [] versions = list(set((str(data["latest"]), self.vals["version"], orig_version))) for vers in versions: if vers not in data['versions']: continue vdata = data['versions'][vers] depends = { "r-" + pkg.lower() if pkg != 'R' else 'r-base': spec.replace(" ", "").replace("\n", "").replace("*", "") for pkg, spec in chain(vdata.get('Depends', {}).items(), vdata.get('Imports', {}).items(), vdata.get('LinkingTo', {}).items()) } version = { 'link': '', 'version': vers, 'depends': {'host': depends, 'run': depends}, } res.append(version) return res package_pattern = r"(?P<package>[\w.]+)" url_pattern = (r"r-project\.org/src/contrib" r"(/Archive)?/{package}(?(1)/{package}|)" r"_{version}{ext}") releases_formats = ["https://crandb.r-pkg.org/{package}/all"] # pylint: disable=abstract-method class BitBucketBase(OrderedHTMLHoster): # abstract """Base class for hosting at bitbucket.org""" account_pattern = r"(?P<account>[-\w]+)" project_pattern = r"(?P<project>[-.\w]+)" prefix_pattern = r"(?P<prefix>[-_./\w]+?)??" url_pattern = r"bitbucket\.org{link}" class BitBucketTag(BitBucketBase): """Tag based releases hosted at bitbucket.org""" link_pattern = "/{account}/{project}/get/{prefix}{version}{ext}" releases_formats = ["https://bitbucket.org/{account}/{project}/downloads/?tab=tags", "https://bitbucket.org/{account}/{project}/downloads/?tab=branches"] class BitBucketDownload(BitBucketBase): """Uploaded releases hosted at bitbucket.org""" link_pattern = "/{account}/{project}/downloads/{prefix}{version}{ext}" releases_formats = ["https://bitbucket.org/{account}/{project}/downloads/?tab=downloads"] class GitlabTag(OrderedHTMLHoster): """Tag based releases hosted at gitlab.com""" account_pattern = r"(?P<account>[-\w]+)" subgroup_pattern = r"(?P<subgroup>(?:/[-\w]+|))" project_pattern = r"(?P<project>[-.\w]+)" link_pattern = (r"/{account}{subgroup}/{project}/(repository|-/archive)/" r"{version}/(archive|{project}-{version}){ext}") url_pattern = r"gitlab\.com{link}" releases_formats = ["https://gitlab.com/{account}{subgroup}/{project}/tags"] logger.info(f"Hosters loaded: %s", [h.__name__ for h in HosterMeta.hoster_types])