Browse Source

Replace pkg_resources with importlib for Python >= 3.9

Fixes #1184
Unrud 3 years ago
parent
commit
2b8f4b9419
6 changed files with 88 additions and 45 deletions
  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
 import threading
 from typing import Iterable, Optional, cast
 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.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

+ 65 - 26
radicale/httputils.py

@@ -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,
-                 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):
     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:
-        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
         return NOT_FOUND
     content_type = MIMETYPES.get(
     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",
             "%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
     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,
 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()
 
 
 
 

+ 12 - 0
radicale/utils.py

@@ -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)

+ 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
 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)

+ 5 - 2
setup.py

@@ -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",
-                      "python-dateutil>=2.7.3", "setuptools"],
+    install_requires=install_requires,
     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,