Jelajahi Sumber

Replace pkg_resources with importlib for Python >= 3.9

Fixes #1184
Unrud 3 tahun lalu
induk
melakukan
2b8f4b9419
6 mengubah file dengan 88 tambahan dan 45 penghapusan
  1. 2 4
      radicale/__init__.py
  2. 65 26
      radicale/httputils.py
  3. 1 2
      radicale/storage/__init__.py
  4. 12 0
      radicale/utils.py
  5. 3 11
      radicale/web/internal.py
  6. 5 2
      setup.py

+ 2 - 4
radicale/__init__.py

@@ -29,13 +29,11 @@ import os
 import threading
 from typing import Iterable, Optional, cast
 
-import pkg_resources
-
-from radicale import config, log, types
+from radicale import config, log, types, utils
 from radicale.app import Application
 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_config_path: Optional[str] = None

+ 65 - 26
radicale/httputils.py

@@ -24,13 +24,25 @@ Helper functions for HTTP.
 
 import contextlib
 import os
+import pathlib
+import sys
 import time
 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.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 = (
     client.FORBIDDEN, (("Content-Type", "text/plain"),),
     "Access to the requested resource forbidden.")
@@ -140,36 +152,63 @@ def redirect(location: str, status: int = client.FOUND) -> types.WSGIResponse:
             "Redirected to %s" % location)
 
 
-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:
+def _serve_traversable(
+        traversable: _TRAVERSABLE_LIKE_TYPE, base_prefix: str, path: str,
+        path_prefix: str, index_file: str, mimetypes: Mapping[str, str],
+        fallback_mimetype: str) -> types.WSGIResponse:
     if path != path_prefix and not path.startswith(path_prefix):
         raise ValueError("path must start with path_prefix: %r --> %r" %
                          (path_prefix, path))
     assert pathutils.sanitize_path(path) == path
-    try:
-        filesystem_path = pathutils.path_to_filesystem(
-            folder, path[len(path_prefix):].strip("/"))
-    except ValueError as e:
-        logger.debug("Web content with unsafe path %r requested: %s",
-                     path, e, exc_info=True)
-        return NOT_FOUND
-    if os.path.isdir(filesystem_path) and not path.endswith("/"):
-        return redirect(base_prefix + path + "/")
-    if os.path.isdir(filesystem_path) and index_file:
-        filesystem_path = os.path.join(filesystem_path, index_file)
-    if not os.path.isfile(filesystem_path):
+    parts_path = path[len(path_prefix):].strip('/')
+    parts = parts_path.split("/") if parts_path else []
+    for part in parts:
+        if not pathutils.is_safe_filesystem_path_component(part):
+            logger.debug("Web content with unsafe path %r requested", path)
+            return NOT_FOUND
+        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 + "/")
+        if not index_file:
+            return NOT_FOUND
+        traversable = traversable.joinpath(index_file)
+    if not traversable.is_file():
         return NOT_FOUND
     content_type = MIMETYPES.get(
-        os.path.splitext(filesystem_path)[1].lower(), FALLBACK_MIMETYPE)
-    with open(filesystem_path, "rb") as f:
-        answer = f.read()
-        last_modified = time.strftime(
+        os.path.splitext(traversable.name)[1].lower(), FALLBACK_MIMETYPE)
+    headers = {"Content-Type": content_type}
+    if isinstance(traversable, pathlib.Path):
+        headers["Last-Modified"] = time.strftime(
             "%a, %d %b %Y %H:%M:%S GMT",
-            time.gmtime(os.fstat(f.fileno()).st_mtime))
-    headers = {
-        "Content-Type": content_type,
-        "Last-Modified": last_modified}
+            time.gmtime(traversable.stat().st_mtime))
+    answer = traversable.read_bytes()
     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)

+ 1 - 2
radicale/storage/__init__.py

@@ -29,7 +29,6 @@ from hashlib import sha256
 from typing import (Iterable, Iterator, Mapping, Optional, Sequence, Set,
                     Tuple, Union, overload)
 
-import pkg_resources
 import vobject
 
 from radicale import config
@@ -41,7 +40,7 @@ INTERNAL_TYPES: Sequence[str] = ("multifilesystem", "multifilesystem_nolock",)
 
 CACHE_DEPS: Sequence[str] = ("radicale", "vobject", "python-dateutil",)
 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()
 
 

+ 12 - 0
radicale/utils.py

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

+ 3 - 11
radicale/web/internal.py

@@ -25,9 +25,7 @@ Features:
 
 """
 
-import pkg_resources
-
-from radicale import config, httputils, types, web
+from radicale import httputils, types, web
 
 MIMETYPES = httputils.MIMETYPES  # deprecated
 FALLBACK_MIMETYPE = httputils.FALLBACK_MIMETYPE  # deprecated
@@ -35,13 +33,7 @@ FALLBACK_MIMETYPE = httputils.FALLBACK_MIMETYPE  # deprecated
 
 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,
             user: str) -> types.WSGIResponse:
-        return httputils.serve_folder(self.folder, base_prefix, path)
+        return httputils.serve_resource("radicale.web", "internal_data",
+                                        base_prefix, path)

+ 5 - 2
setup.py

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