2022-03-03 07:47:55 -05:00
|
|
|
# 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.
|
|
|
|
"""
|
|
|
|
|
2022-03-01 12:44:41 -05:00
|
|
|
import logging
|
|
|
|
from typing import Iterable, NamedTuple, Optional
|
|
|
|
|
|
|
|
from packaging.requirements import Requirement
|
|
|
|
|
|
|
|
DISTRIBUTION_NAME = "matrix-synapse"
|
|
|
|
|
|
|
|
try:
|
|
|
|
from importlib import metadata
|
|
|
|
except ImportError:
|
|
|
|
import importlib_metadata as metadata # type: ignore[no-redef]
|
|
|
|
|
2022-03-03 07:47:55 -05:00
|
|
|
__all__ = ["check_requirements"]
|
|
|
|
|
2022-03-01 12:44:41 -05:00
|
|
|
|
|
|
|
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 + '"'
|
|
|
|
|
|
|
|
|
2022-03-03 07:47:55 -05:00
|
|
|
DEV_EXTRAS = {"lint", "mypy", "test", "dev"}
|
|
|
|
RUNTIME_EXTRAS = (
|
|
|
|
set(metadata.metadata(DISTRIBUTION_NAME).get_all("Provides-Extra")) - 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
|
|
|
|
)
|
2022-03-01 12:44:41 -05:00
|
|
|
|
|
|
|
|
2022-09-29 16:16:08 -04:00
|
|
|
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
|
|
|
|
|
|
|
|
|
2022-03-01 12:44:41 -05:00
|
|
|
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)
|
2022-09-29 16:16:08 -04:00
|
|
|
if _is_dev_dependency(req) or _should_ignore_runtime_requirement(req):
|
2022-03-03 07:47:55 -05:00
|
|
|
continue
|
|
|
|
|
2022-03-01 12:44:41 -05:00
|
|
|
# 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)
|
2022-03-03 07:47:55 -05:00
|
|
|
if _is_dev_dependency(req):
|
|
|
|
continue
|
2022-03-01 12:44:41 -05:00
|
|
|
# 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:
|
2022-03-03 07:47:55 -05:00
|
|
|
return (
|
|
|
|
f"Synapse {VERSION} needs {requirement.name} for {extra}, "
|
|
|
|
f"but it is not installed"
|
|
|
|
)
|
2022-03-01 12:44:41 -05:00
|
|
|
else:
|
2022-03-03 07:47:55 -05:00
|
|
|
return f"Synapse {VERSION} needs {requirement.name}, but it is not installed"
|
2022-03-01 12:44:41 -05:00
|
|
|
|
|
|
|
|
|
|
|
def _incorrect_version(
|
|
|
|
requirement: Requirement, got: str, extra: Optional[str] = None
|
|
|
|
) -> str:
|
|
|
|
if extra:
|
2022-03-03 07:47:55 -05:00
|
|
|
return (
|
|
|
|
f"Synapse {VERSION} needs {requirement} for {extra}, "
|
|
|
|
f"but got {requirement.name}=={got}"
|
|
|
|
)
|
2022-03-01 12:44:41 -05:00
|
|
|
else:
|
2022-03-03 07:47:55 -05:00
|
|
|
return (
|
|
|
|
f"Synapse {VERSION} needs {requirement}, but got {requirement.name}=={got}"
|
|
|
|
)
|
2022-03-01 12:44:41 -05:00
|
|
|
|
|
|
|
|
2022-03-18 15:03:46 -04:00
|
|
|
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"
|
|
|
|
)
|
|
|
|
|
|
|
|
|
2022-03-01 12:44:41 -05:00
|
|
|
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()
|
2022-03-03 07:47:55 -05:00
|
|
|
elif extra in RUNTIME_EXTRAS:
|
2022-03-01 12:44:41 -05:00
|
|
|
dependencies = _dependencies_for_extra(extra)
|
|
|
|
else:
|
2022-03-03 07:47:55 -05:00
|
|
|
raise ValueError(f"Synapse {VERSION} does not provide the feature '{extra}'")
|
2022-03-01 12:44:41 -05:00
|
|
|
|
|
|
|
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:
|
2022-03-18 15:03:46 -04:00
|
|
|
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))
|
|
|
|
|
2022-03-08 05:47:28 -05:00
|
|
|
# We specify prereleases=True to allow prereleases such as RCs.
|
2022-03-18 15:03:46 -04:00
|
|
|
elif not requirement.specifier.contains(dist.version, prereleases=True):
|
2022-03-01 12:44:41 -05:00
|
|
|
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)
|