diff --git a/mercurial/utils/procutil.py b/mercurial/utils/procutil.py --- a/mercurial/utils/procutil.py +++ b/mercurial/utils/procutil.py @@ -12,6 +12,7 @@ import contextlib import errno import imp +import importlib import io import os import signal @@ -544,3 +545,125 @@ else: return os.path.dirname( os.path.dirname(pycompat.fsencode(__file__))) + +class filesystemresourcereader(object): + """An importlib.abc.ResourceReader that loads from the filesystem. + + This API and the loader's ``get_resource_loader()`` method were introduced + in Python 3.7. This class implements the interface for older Python versions + and for Python 3.7+ environments where ``module.__loader__`` doesn't define + ``get_resource_loader()``. + + In addition to implementing the basic functionality, we also tweak the API + to operate in the domain of bytes instead of str. This is wrong. But since + Mercurial should be the only consumer of this API, it should be OK. + """ + + def __init__(self, basepath): + self._base = basepath + + def _raise_file_not_found(self): + # Various methods are mandated to raise FileNotFoundError. But this + # exception doesn't exist on older Python. + try: + raise FileNotFoundError + except NameError: + raise OSError(errno.ENOENT, r'file not found') + + def open_resource(self, resource): + # Subdirectory checking isn't performed in at least Python 3.7.2. It is + # weird that is_resource() does do this checking and open_resource() + # will allow paths with separators. See also + # https://bugs.python.org/issue36128. We keep the Python 3.7 behavior + # for compatibility. + + path = os.path.join(self._base, resource) + + # We should raise FileNotFoundError on missing resource. But since this + # exception doesn't exist on older Python, we simply let the original + # exception be raised. + return open(path, r'rb') + + def resource_path(self, resource): + if not self.is_resource(resource): + self._raise_file_not_found() + + return pycompat.fsencode(os.path.join(self._base, resource)) + + def is_resource(self, resource): + # See comment in open_resource() about the odd behavior of checking for + # path separators. + if b'/' in resource or b'\\' in resource: + return False + + path = os.path.join(self._base, resource) + return os.path.isfile(path) + + def contents(self): + return iter( + sorted(pycompat.fsencode(p) for p in os.listdir(self._base))) + +class bytesresourceloaderproxy(object): + """An importlib.abc.ResourceReader proxy that uses bytes instead of str. + + The ResourceReader API is supposed to operate in the domain of str. + But all of Mercurial uses bytes. Rather than have all callers worry about + bytes/str, we use this class to wrap an existing ResourceReader so the + bytes<->str conversion happens automatically. This violates the API + contract. But there should be no consumers of ResourceReader outside + of Mercurial, so this should be fine. Once the source code is Python + 3 native, we can consider removing this. + + We assume this class is only ever used in Python 3[.7]+. + """ + def __init__(self, orig): + self._orig = orig + + def open_resource(self, resource): + return self._orig.open_resource(pycompat.fsdecode(resource)) + + def resource_path(self, resource): + return os.fsencode( + self._orig.resource_path(pycompat.fsdecode(resource))) + + def is_resource(self, resource): + return self._orig.is_resource(pycompat.fsdecode(resource)) + + def contents(self): + # Throw in a sort for good measure because we like determinism. + return iter(sorted(pycompat.fsencode(p) for p in self._orig.contents())) + +def resourcereader(package): + """Obtain an importlib.abc.ResourceReader for a Python package.""" + # Raising TypeError here is a little weird. But it is what the standard + # library does. + package = pycompat.sysstr(package) + module = importlib.import_module(package) + + spec = getattr(module, '__spec__', None) + + # __spec__ should only be present in Python 3. + if spec: + if spec.submodule_search_locations is None: + raise TypeError(r'%s is not a package' % package) + + try: + orig = spec.loader.get_resource_reader(spec.name) + + if orig: + return bytesresourceloaderproxy(orig) + except Exception: + pass + + # Fall through to lack of __spec__ case. + + # We couldn't load a ResourceReader from an existing importlib.abc.Loader. + # So return one based on the filesystem location. + + # Namespace packages can't have ResourceReaders. + if module.__file__ is None: + raise TypeError(r'%s is a namespace package, which cannot have ' + r'resource readers' % package) + + return filesystemresourcereader( + pycompat.fsencode(os.path.dirname(module.__file__)))