Replace pkg_resources with importlib for Python >= 3.9

Fixes #1184
This commit is contained in:
Unrud 2022-04-04 18:17:01 +02:00
parent a97093d001
commit 2b8f4b9419
6 changed files with 88 additions and 45 deletions

View File

@ -29,13 +29,11 @@ import os
import threading import threading
from typing import Iterable, Optional, cast from typing import Iterable, Optional, cast
import pkg_resources from radicale import config, log, types, utils
from radicale import config, log, types
from radicale.app import Application from radicale.app import Application
from radicale.log import logger from radicale.log import logger
VERSION: str = pkg_resources.get_distribution("radicale").version VERSION: str = utils.package_version("radicale")
_application_instance: Optional[Application] = None _application_instance: Optional[Application] = None
_application_config_path: Optional[str] = None _application_config_path: Optional[str] = None

View File

@ -24,13 +24,25 @@ Helper functions for HTTP.
import contextlib import contextlib
import os import os
import pathlib
import sys
import time import time
from http import client from http import client
from typing import List, Mapping, cast from typing import List, Mapping, Union, cast
from radicale import config, pathutils, types from radicale import config, pathutils, types
from radicale.log import logger from radicale.log import logger
if sys.version_info < (3, 9):
import pkg_resources
_TRAVERSABLE_LIKE_TYPE = pathlib.Path
else:
import importlib.abc
from importlib import resources
_TRAVERSABLE_LIKE_TYPE = Union[importlib.abc.Traversable, pathlib.Path]
NOT_ALLOWED: types.WSGIResponse = ( NOT_ALLOWED: types.WSGIResponse = (
client.FORBIDDEN, (("Content-Type", "text/plain"),), client.FORBIDDEN, (("Content-Type", "text/plain"),),
"Access to the requested resource forbidden.") "Access to the requested resource forbidden.")
@ -140,36 +152,63 @@ def redirect(location: str, status: int = client.FOUND) -> types.WSGIResponse:
"Redirected to %s" % location) "Redirected to %s" % location)
def serve_folder(folder: str, base_prefix: str, path: str, def _serve_traversable(
path_prefix: str = "/.web", index_file: str = "index.html", traversable: _TRAVERSABLE_LIKE_TYPE, base_prefix: str, path: str,
mimetypes: Mapping[str, str] = MIMETYPES, path_prefix: str, index_file: str, mimetypes: Mapping[str, str],
fallback_mimetype: str = FALLBACK_MIMETYPE, fallback_mimetype: str) -> types.WSGIResponse:
) -> types.WSGIResponse:
if path != path_prefix and not path.startswith(path_prefix): if path != path_prefix and not path.startswith(path_prefix):
raise ValueError("path must start with path_prefix: %r --> %r" % raise ValueError("path must start with path_prefix: %r --> %r" %
(path_prefix, path)) (path_prefix, path))
assert pathutils.sanitize_path(path) == path assert pathutils.sanitize_path(path) == path
try: parts_path = path[len(path_prefix):].strip('/')
filesystem_path = pathutils.path_to_filesystem( parts = parts_path.split("/") if parts_path else []
folder, path[len(path_prefix):].strip("/")) for part in parts:
except ValueError as e: if not pathutils.is_safe_filesystem_path_component(part):
logger.debug("Web content with unsafe path %r requested: %s", logger.debug("Web content with unsafe path %r requested", path)
path, e, exc_info=True)
return NOT_FOUND return NOT_FOUND
if os.path.isdir(filesystem_path) and not path.endswith("/"): if (not traversable.is_dir() or
all(part != entry.name for entry in traversable.iterdir())):
return NOT_FOUND
traversable = traversable.joinpath(part)
if traversable.is_dir():
if not path.endswith("/"):
return redirect(base_prefix + path + "/") return redirect(base_prefix + path + "/")
if os.path.isdir(filesystem_path) and index_file: if not index_file:
filesystem_path = os.path.join(filesystem_path, index_file) return NOT_FOUND
if not os.path.isfile(filesystem_path): traversable = traversable.joinpath(index_file)
if not traversable.is_file():
return NOT_FOUND return NOT_FOUND
content_type = MIMETYPES.get( content_type = MIMETYPES.get(
os.path.splitext(filesystem_path)[1].lower(), FALLBACK_MIMETYPE) os.path.splitext(traversable.name)[1].lower(), FALLBACK_MIMETYPE)
with open(filesystem_path, "rb") as f: headers = {"Content-Type": content_type}
answer = f.read() if isinstance(traversable, pathlib.Path):
last_modified = time.strftime( headers["Last-Modified"] = time.strftime(
"%a, %d %b %Y %H:%M:%S GMT", "%a, %d %b %Y %H:%M:%S GMT",
time.gmtime(os.fstat(f.fileno()).st_mtime)) time.gmtime(traversable.stat().st_mtime))
headers = { answer = traversable.read_bytes()
"Content-Type": content_type,
"Last-Modified": last_modified}
return client.OK, headers, answer return client.OK, headers, answer
def serve_resource(
package: str, resource: str, base_prefix: str, path: str,
path_prefix: str = "/.web", index_file: str = "index.html",
mimetypes: Mapping[str, str] = MIMETYPES,
fallback_mimetype: str = FALLBACK_MIMETYPE) -> types.WSGIResponse:
if sys.version_info < (3, 9):
traversable = pathlib.Path(
pkg_resources.resource_filename(package, resource))
else:
traversable = resources.files(package).joinpath(resource)
return _serve_traversable(traversable, base_prefix, path, path_prefix,
index_file, mimetypes, fallback_mimetype)
def serve_folder(
folder: str, base_prefix: str, path: str,
path_prefix: str = "/.web", index_file: str = "index.html",
mimetypes: Mapping[str, str] = MIMETYPES,
fallback_mimetype: str = FALLBACK_MIMETYPE) -> types.WSGIResponse:
# deprecated: use `serve_resource` instead
traversable = pathlib.Path(folder)
return _serve_traversable(traversable, base_prefix, path, path_prefix,
index_file, mimetypes, fallback_mimetype)

View File

@ -29,7 +29,6 @@ from hashlib import sha256
from typing import (Iterable, Iterator, Mapping, Optional, Sequence, Set, from typing import (Iterable, Iterator, Mapping, Optional, Sequence, Set,
Tuple, Union, overload) Tuple, Union, overload)
import pkg_resources
import vobject import vobject
from radicale import config from radicale import config
@ -41,7 +40,7 @@ INTERNAL_TYPES: Sequence[str] = ("multifilesystem", "multifilesystem_nolock",)
CACHE_DEPS: Sequence[str] = ("radicale", "vobject", "python-dateutil",) CACHE_DEPS: Sequence[str] = ("radicale", "vobject", "python-dateutil",)
CACHE_VERSION: bytes = "".join( CACHE_VERSION: bytes = "".join(
"%s=%s;" % (pkg, pkg_resources.get_distribution(pkg).version) "%s=%s;" % (pkg, utils.package_version(pkg))
for pkg in CACHE_DEPS).encode() for pkg in CACHE_DEPS).encode()

View File

@ -16,12 +16,18 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with Radicale. If not, see <http://www.gnu.org/licenses/>. # along with Radicale. If not, see <http://www.gnu.org/licenses/>.
import sys
from importlib import import_module from importlib import import_module
from typing import Callable, Sequence, Type, TypeVar, Union from typing import Callable, Sequence, Type, TypeVar, Union
from radicale import config from radicale import config
from radicale.log import logger from radicale.log import logger
if sys.version_info < (3, 8):
import pkg_resources
else:
from importlib import metadata
_T_co = TypeVar("_T_co", covariant=True) _T_co = TypeVar("_T_co", covariant=True)
@ -43,3 +49,9 @@ def load_plugin(internal_types: Sequence[str], module_name: str,
(module_name, module, e)) from e (module_name, module, e)) from e
logger.info("%s type is %r", module_name, module) logger.info("%s type is %r", module_name, module)
return class_(configuration) return class_(configuration)
def package_version(name):
if sys.version_info < (3, 8):
return pkg_resources.get_distribution(name).version
return metadata.version(name)

View File

@ -25,9 +25,7 @@ Features:
""" """
import pkg_resources from radicale import httputils, types, web
from radicale import config, httputils, types, web
MIMETYPES = httputils.MIMETYPES # deprecated MIMETYPES = httputils.MIMETYPES # deprecated
FALLBACK_MIMETYPE = httputils.FALLBACK_MIMETYPE # deprecated FALLBACK_MIMETYPE = httputils.FALLBACK_MIMETYPE # deprecated
@ -35,13 +33,7 @@ FALLBACK_MIMETYPE = httputils.FALLBACK_MIMETYPE # deprecated
class Web(web.BaseWeb): class Web(web.BaseWeb):
folder: str
def __init__(self, configuration: config.Configuration) -> None:
super().__init__(configuration)
self.folder = pkg_resources.resource_filename(
__name__, "internal_data")
def get(self, environ: types.WSGIEnviron, base_prefix: str, path: str, def get(self, environ: types.WSGIEnviron, base_prefix: str, path: str,
user: str) -> types.WSGIResponse: user: str) -> types.WSGIResponse:
return httputils.serve_folder(self.folder, base_prefix, path) return httputils.serve_resource("radicale.web", "internal_data",
base_prefix, path)

View File

@ -49,6 +49,10 @@ WEB_FILES = ["web/internal_data/css/icon.png",
"web/internal_data/fn.js", "web/internal_data/fn.js",
"web/internal_data/index.html"] "web/internal_data/index.html"]
install_requires = ["defusedxml", "passlib", "vobject>=0.9.6",
"python-dateutil>=2.7.3"]
if sys.version_info < (3, 9):
install_requires.append("setuptools")
setup_requires = [] setup_requires = []
if {"pytest", "test", "ptr"}.intersection(sys.argv): if {"pytest", "test", "ptr"}.intersection(sys.argv):
setup_requires.append("pytest-runner") setup_requires.append("pytest-runner")
@ -76,8 +80,7 @@ setup(
exclude=["*.tests", "*.tests.*", "tests.*", "tests"]), exclude=["*.tests", "*.tests.*", "tests.*", "tests"]),
package_data={"radicale": [*WEB_FILES, "py.typed"]}, package_data={"radicale": [*WEB_FILES, "py.typed"]},
entry_points={"console_scripts": ["radicale = radicale.__main__:run"]}, entry_points={"console_scripts": ["radicale = radicale.__main__:run"]},
install_requires=["defusedxml", "passlib", "vobject>=0.9.6", install_requires=install_requires,
"python-dateutil>=2.7.3", "setuptools"],
setup_requires=setup_requires, setup_requires=setup_requires,
tests_require=tests_require, tests_require=tests_require,
extras_require={"test": tests_require, extras_require={"test": tests_require,