210 lines
		
	
	
		
			7.7 KiB
		
	
	
	
		
			Python
		
	
	
			
		
		
	
	
			210 lines
		
	
	
		
			7.7 KiB
		
	
	
	
		
			Python
		
	
	
#  Copyright 2022 The Matrix.org Foundation C.I.C.
 | 
						|
#
 | 
						|
#  Licensed under the Apache License, Version 2.0 (the "License");
 | 
						|
#  you may not use this file except in compliance with the License.
 | 
						|
#  You may obtain a copy of the License at
 | 
						|
#
 | 
						|
#      http://www.apache.org/licenses/LICENSE-2.0
 | 
						|
#
 | 
						|
#  Unless required by applicable law or agreed to in writing, software
 | 
						|
#  distributed under the License is distributed on an "AS IS" BASIS,
 | 
						|
#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
						|
#  See the License for the specific language governing permissions and
 | 
						|
#  limitations under the License.
 | 
						|
#
 | 
						|
 | 
						|
"""
 | 
						|
This module exposes a single function which checks synapse's dependencies are present
 | 
						|
and correctly versioned. It makes use of `importlib.metadata` to do so. The details
 | 
						|
are a bit murky: there's no easy way to get a map from "extras" to the packages they
 | 
						|
require. But this is probably just symptomatic of Python's package management.
 | 
						|
"""
 | 
						|
 | 
						|
import logging
 | 
						|
from importlib import metadata
 | 
						|
from typing import Iterable, NamedTuple, Optional
 | 
						|
 | 
						|
from packaging.requirements import Requirement
 | 
						|
 | 
						|
DISTRIBUTION_NAME = "matrix-synapse"
 | 
						|
 | 
						|
 | 
						|
__all__ = ["check_requirements"]
 | 
						|
 | 
						|
 | 
						|
class DependencyException(Exception):
 | 
						|
    @property
 | 
						|
    def message(self) -> str:
 | 
						|
        return "\n".join(
 | 
						|
            [
 | 
						|
                "Missing Requirements: %s" % (", ".join(self.dependencies),),
 | 
						|
                "To install run:",
 | 
						|
                "    pip install --upgrade --force %s" % (" ".join(self.dependencies),),
 | 
						|
                "",
 | 
						|
            ]
 | 
						|
        )
 | 
						|
 | 
						|
    @property
 | 
						|
    def dependencies(self) -> Iterable[str]:
 | 
						|
        for i in self.args[0]:
 | 
						|
            yield '"' + i + '"'
 | 
						|
 | 
						|
 | 
						|
DEV_EXTRAS = {"lint", "mypy", "test", "dev"}
 | 
						|
ALL_EXTRAS = metadata.metadata(DISTRIBUTION_NAME).get_all("Provides-Extra")
 | 
						|
assert ALL_EXTRAS is not None
 | 
						|
RUNTIME_EXTRAS = set(ALL_EXTRAS) - DEV_EXTRAS
 | 
						|
VERSION = metadata.version(DISTRIBUTION_NAME)
 | 
						|
 | 
						|
 | 
						|
def _is_dev_dependency(req: Requirement) -> bool:
 | 
						|
    return req.marker is not None and any(
 | 
						|
        req.marker.evaluate({"extra": e}) for e in DEV_EXTRAS
 | 
						|
    )
 | 
						|
 | 
						|
 | 
						|
def _should_ignore_runtime_requirement(req: Requirement) -> bool:
 | 
						|
    # This is a build-time dependency. Irritatingly, `poetry build` ignores the
 | 
						|
    # requirements listed in the [build-system] section of pyproject.toml, so in order
 | 
						|
    # to support `poetry install --no-dev` we have to mark it as a runtime dependency.
 | 
						|
    # See discussion on https://github.com/python-poetry/poetry/issues/6154 (it sounds
 | 
						|
    # like the poetry authors don't consider this a bug?)
 | 
						|
    #
 | 
						|
    # In any case, workaround this by ignoring setuptools_rust here. (It might be
 | 
						|
    # slightly cleaner to put `setuptools_rust` in a `build` extra or similar, but for
 | 
						|
    # now let's do something quick and dirty.
 | 
						|
    if req.name == "setuptools_rust":
 | 
						|
        return True
 | 
						|
    return False
 | 
						|
 | 
						|
 | 
						|
class Dependency(NamedTuple):
 | 
						|
    requirement: Requirement
 | 
						|
    must_be_installed: bool
 | 
						|
 | 
						|
 | 
						|
def _generic_dependencies() -> Iterable[Dependency]:
 | 
						|
    """Yield pairs (requirement, must_be_installed)."""
 | 
						|
    requirements = metadata.requires(DISTRIBUTION_NAME)
 | 
						|
    assert requirements is not None
 | 
						|
    for raw_requirement in requirements:
 | 
						|
        req = Requirement(raw_requirement)
 | 
						|
        if _is_dev_dependency(req) or _should_ignore_runtime_requirement(req):
 | 
						|
            continue
 | 
						|
 | 
						|
        # https://packaging.pypa.io/en/latest/markers.html#usage notes that
 | 
						|
        #   > Evaluating an extra marker with no environment is an error
 | 
						|
        # so we pass in a dummy empty extra value here.
 | 
						|
        must_be_installed = req.marker is None or req.marker.evaluate({"extra": ""})
 | 
						|
        yield Dependency(req, must_be_installed)
 | 
						|
 | 
						|
 | 
						|
def _dependencies_for_extra(extra: str) -> Iterable[Dependency]:
 | 
						|
    """Yield additional dependencies needed for a given `extra`."""
 | 
						|
    requirements = metadata.requires(DISTRIBUTION_NAME)
 | 
						|
    assert requirements is not None
 | 
						|
    for raw_requirement in requirements:
 | 
						|
        req = Requirement(raw_requirement)
 | 
						|
        if _is_dev_dependency(req):
 | 
						|
            continue
 | 
						|
        # Exclude mandatory deps by only selecting deps needed with this extra.
 | 
						|
        if (
 | 
						|
            req.marker is not None
 | 
						|
            and req.marker.evaluate({"extra": extra})
 | 
						|
            and not req.marker.evaluate({"extra": ""})
 | 
						|
        ):
 | 
						|
            yield Dependency(req, True)
 | 
						|
 | 
						|
 | 
						|
def _not_installed(requirement: Requirement, extra: Optional[str] = None) -> str:
 | 
						|
    if extra:
 | 
						|
        return (
 | 
						|
            f"Synapse {VERSION} needs {requirement.name} for {extra}, "
 | 
						|
            f"but it is not installed"
 | 
						|
        )
 | 
						|
    else:
 | 
						|
        return f"Synapse {VERSION} needs {requirement.name}, but it is not installed"
 | 
						|
 | 
						|
 | 
						|
def _incorrect_version(
 | 
						|
    requirement: Requirement, got: str, extra: Optional[str] = None
 | 
						|
) -> str:
 | 
						|
    if extra:
 | 
						|
        return (
 | 
						|
            f"Synapse {VERSION} needs {requirement} for {extra}, "
 | 
						|
            f"but got {requirement.name}=={got}"
 | 
						|
        )
 | 
						|
    else:
 | 
						|
        return (
 | 
						|
            f"Synapse {VERSION} needs {requirement}, but got {requirement.name}=={got}"
 | 
						|
        )
 | 
						|
 | 
						|
 | 
						|
def _no_reported_version(requirement: Requirement, extra: Optional[str] = None) -> str:
 | 
						|
    if extra:
 | 
						|
        return (
 | 
						|
            f"Synapse {VERSION} needs {requirement} for {extra}, "
 | 
						|
            f"but can't determine {requirement.name}'s version"
 | 
						|
        )
 | 
						|
    else:
 | 
						|
        return (
 | 
						|
            f"Synapse {VERSION} needs {requirement}, "
 | 
						|
            f"but can't determine {requirement.name}'s version"
 | 
						|
        )
 | 
						|
 | 
						|
 | 
						|
def check_requirements(extra: Optional[str] = None) -> None:
 | 
						|
    """Check Synapse's dependencies are present and correctly versioned.
 | 
						|
 | 
						|
    If provided, `extra` must be the name of an pacakging extra (e.g. "saml2" in
 | 
						|
    `pip install matrix-synapse[saml2]`).
 | 
						|
 | 
						|
    If `extra` is None, this function checks that
 | 
						|
    - all mandatory dependencies are installed and correctly versioned, and
 | 
						|
    - each optional dependency that's installed is correctly versioned.
 | 
						|
 | 
						|
    If `extra` is not None, this function checks that
 | 
						|
    - the dependencies needed for that extra are installed and correctly versioned.
 | 
						|
 | 
						|
    :raises DependencyException: if a dependency is missing or incorrectly versioned.
 | 
						|
    :raises ValueError: if this extra does not exist.
 | 
						|
    """
 | 
						|
    # First work out which dependencies are required, and which are optional.
 | 
						|
    if extra is None:
 | 
						|
        dependencies = _generic_dependencies()
 | 
						|
    elif extra in RUNTIME_EXTRAS:
 | 
						|
        dependencies = _dependencies_for_extra(extra)
 | 
						|
    else:
 | 
						|
        raise ValueError(f"Synapse {VERSION} does not provide the feature '{extra}'")
 | 
						|
 | 
						|
    deps_unfulfilled = []
 | 
						|
    errors = []
 | 
						|
 | 
						|
    for requirement, must_be_installed in dependencies:
 | 
						|
        try:
 | 
						|
            dist: metadata.Distribution = metadata.distribution(requirement.name)
 | 
						|
        except metadata.PackageNotFoundError:
 | 
						|
            if must_be_installed:
 | 
						|
                deps_unfulfilled.append(requirement.name)
 | 
						|
                errors.append(_not_installed(requirement, extra))
 | 
						|
        else:
 | 
						|
            if dist.version is None:
 | 
						|
                # This shouldn't happen---it suggests a borked virtualenv. (See #12223)
 | 
						|
                # Try to give a vaguely helpful error message anyway.
 | 
						|
                # Type-ignore: the annotations don't reflect reality: see
 | 
						|
                #     https://github.com/python/typeshed/issues/7513
 | 
						|
                #     https://bugs.python.org/issue47060
 | 
						|
                deps_unfulfilled.append(requirement.name)  # type: ignore[unreachable]
 | 
						|
                errors.append(_no_reported_version(requirement, extra))
 | 
						|
 | 
						|
            # We specify prereleases=True to allow prereleases such as RCs.
 | 
						|
            elif not requirement.specifier.contains(dist.version, prereleases=True):
 | 
						|
                deps_unfulfilled.append(requirement.name)
 | 
						|
                errors.append(_incorrect_version(requirement, dist.version, extra))
 | 
						|
 | 
						|
    if deps_unfulfilled:
 | 
						|
        for err in errors:
 | 
						|
            logging.error(err)
 | 
						|
 | 
						|
        raise DependencyException(deps_unfulfilled)
 |