From 8e2b6953562cfe53a085f3a309ddb0709c55a4f3 Mon Sep 17 00:00:00 2001 From: Brian McGillion Date: Sun, 13 Sep 2026 05:48:02 +0400 Subject: [PATCH] python3Packages.fs: fix build with setuptools 83 and Python 3.14 setuptools 82 dropped pkg_resources, which fs imports at run time; take the two open upstream PRs that move it to importlib.metadata and pkgutil. Also patch url_quote, since Python 3.14's pathname2url gives absolute paths an empty authority and fs was emitting URLs like osfs://///tmp/foo. Signed-off-by: Brian McGillion --- .../development/python-modules/fs/default.nix | 11 +- .../fs/drop-pkg-resources.patch | 229 ++++++++++++++++++ .../fs/python-3.14-pathname2url.patch | 49 ++++ 3 files changed, 283 insertions(+), 6 deletions(-) create mode 100644 pkgs/development/python-modules/fs/drop-pkg-resources.patch create mode 100644 pkgs/development/python-modules/fs/python-3.14-pathname2url.patch diff --git a/pkgs/development/python-modules/fs/default.nix b/pkgs/development/python-modules/fs/default.nix index d0dd7d12ff6a..4aef9983d110 100644 --- a/pkgs/development/python-modules/fs/default.nix +++ b/pkgs/development/python-modules/fs/default.nix @@ -10,7 +10,6 @@ psutil, pyftpdlib, pytestCheckHook, - pythonAtLeast, pytz, setuptools, six, @@ -21,14 +20,16 @@ buildPythonPackage rec { version = "2.4.16"; pyproject = true; - # https://github.com/PyFilesystem/pyfilesystem2/issues/596 - disabled = pythonAtLeast "3.14"; - src = fetchPypi { inherit pname version; hash = "sha256-rpfH1RIT9LcLapWCklMCiQkN46fhWEHhCPvhRPBp0xM="; }; + patches = [ + ./drop-pkg-resources.patch + ./python-3.14-pathname2url.patch + ]; + postPatch = '' # https://github.com/PyFilesystem/pyfilesystem2/pull/591 substituteInPlace tests/test_ftpfs.py \ @@ -88,8 +89,6 @@ buildPythonPackage rec { __darwinAllowLocalNetworking = true; meta = { - # https://github.com/PyFilesystem/pyfilesystem2/issues/577 - broken = lib.versionAtLeast setuptools.version "82"; description = "Filesystem abstraction"; homepage = "https://github.com/PyFilesystem/pyfilesystem2"; changelog = "https://github.com/PyFilesystem/pyfilesystem2/blob/v${version}/CHANGELOG.md"; diff --git a/pkgs/development/python-modules/fs/drop-pkg-resources.patch b/pkgs/development/python-modules/fs/drop-pkg-resources.patch new file mode 100644 index 000000000000..2d9bda8016f3 --- /dev/null +++ b/pkgs/development/python-modules/fs/drop-pkg-resources.patch @@ -0,0 +1,229 @@ +setuptools 82 dropped pkg_resources, which fs imports at run time. Move entry +point handling to importlib.metadata and the namespace declarations to pkgutil. + +https://github.com/PyFilesystem/pyfilesystem2/pull/589 +https://github.com/PyFilesystem/pyfilesystem2/pull/590 + +carrying these 2 PRs until merged + +--- a/fs/__init__.py ++++ b/fs/__init__.py +@@ -1,7 +1,7 @@ + """Python filesystem abstraction layer. + """ + +-__import__("pkg_resources").declare_namespace(__name__) # type: ignore ++__path__ = __import__("pkgutil").extend_path(__path__, __name__) + + from . import path + from ._fscompat import fsdecode, fsencode +--- a/fs/opener/__init__.py ++++ b/fs/opener/__init__.py +@@ -3,7 +3,7 @@ + """ + + # Declare fs.opener as a namespace package +-__import__("pkg_resources").declare_namespace(__name__) # type: ignore ++__path__ = __import__("pkgutil").extend_path(__path__, __name__) + + # Import opener modules so that `registry.install` if called on each opener + from . import appfs, ftpfs, memoryfs, osfs, tarfs, tempfs, zipfs +--- a/fs/opener/registry.py ++++ b/fs/opener/registry.py +@@ -8,7 +8,7 @@ import typing + + import collections + import contextlib +-import pkg_resources ++import sys + + from ..errors import ResourceReadOnly + from .base import Opener +@@ -21,6 +21,30 @@ if typing.TYPE_CHECKING: + from ..base import FS + + ++if sys.version_info >= (3, 8): ++ import importlib.metadata ++ ++ if sys.version_info >= (3, 10): ++ ++ def entrypoints(group, name=None): ++ ep = importlib.metadata.entry_points(group=group) ++ return tuple(n for n in ep if name is None or n.name == name) ++ ++ else: ++ ++ def entrypoints(group, name=None): ++ ep = importlib.metadata.entry_points() ++ if name: ++ return tuple(n for n in ep.get(group, ()) if n.name == name) ++ return ep.get(group, ()) ++ ++else: ++ import pkg_resources ++ ++ def entrypoints(group, name=None): ++ return tuple(pkg_resources.iter_entry_points(group, name)) ++ ++ + class Registry(object): + """A registry for `Opener` instances.""" + +@@ -74,10 +98,7 @@ class Registry(object): + """`list`: the list of supported protocols.""" + _protocols = list(self._protocols) + if self.load_extern: +- _protocols.extend( +- entry_point.name +- for entry_point in pkg_resources.iter_entry_points("fs.opener") +- ) ++ _protocols.extend(n.name for n in entrypoints("fs.opener")) + _protocols = list(collections.OrderedDict.fromkeys(_protocols)) + return _protocols + +@@ -101,10 +122,9 @@ class Registry(object): + """ + protocol = protocol or self.default_opener + +- if self.load_extern: +- entry_point = next( +- pkg_resources.iter_entry_points("fs.opener", protocol), None +- ) ++ ep = entrypoints("fs.opener", protocol) ++ if self.load_extern and ep: ++ entry_point = ep[0] + else: + entry_point = None + +--- a/tests/test_opener.py ++++ b/tests/test_opener.py +@@ -3,7 +3,6 @@ from __future__ import unicode_literals + import sys + + import os +-import pkg_resources + import shutil + import tempfile + import unittest +@@ -21,6 +20,11 @@ try: + except ImportError: + import mock + ++if sys.version_info >= (3, 8): ++ import importlib.metadata ++else: ++ import pkg_resources ++ + + class TestParse(unittest.TestCase): + def test_registry_repr(self): +@@ -111,14 +115,25 @@ class TestRegistry(unittest.TestCase): + + def test_registry_protocols(self): + # Check registry.protocols list the names of all available extension +- extensions = [ +- pkg_resources.EntryPoint("proto1", "mod1"), +- pkg_resources.EntryPoint("proto2", "mod2"), +- ] +- m = mock.MagicMock(return_value=extensions) +- with mock.patch.object( +- sys.modules["pkg_resources"], "iter_entry_points", new=m +- ): ++ if sys.version_info >= (3, 8): ++ extensions = ( ++ importlib.metadata.EntryPoint("proto1", "mod1", "fs.opener"), ++ importlib.metadata.EntryPoint("proto2", "mod2", "fs.opener"), ++ ) ++ if sys.version_info >= (3, 10): ++ m = mock.MagicMock(return_value=extensions) ++ else: ++ m = mock.MagicMock(return_value={"fs.opener": extensions}) ++ patch = mock.patch("importlib.metadata.entry_points", m) ++ else: ++ extensions = [ ++ pkg_resources.EntryPoint("proto1", "mod1"), ++ pkg_resources.EntryPoint("proto2", "mod2"), ++ ] ++ m = mock.MagicMock(return_value=extensions) ++ patch = mock.patch("pkg_resources.iter_entry_points", m) ++ ++ with patch: + self.assertIn("proto1", opener.registry.protocols) + self.assertIn("proto2", opener.registry.protocols) + +@@ -129,11 +144,19 @@ class TestRegistry(unittest.TestCase): + def test_entry_point_load_error(self): + + entry_point = mock.MagicMock() ++ entry_point.name = "test" + entry_point.load.side_effect = ValueError("some error") + +- iter_entry_points = mock.MagicMock(return_value=iter([entry_point])) +- +- with mock.patch("pkg_resources.iter_entry_points", iter_entry_points): ++ if sys.version_info >= (3, 8): ++ if sys.version_info >= (3, 10): ++ entry_points = mock.MagicMock(return_value=tuple([entry_point])) ++ else: ++ entry_points = mock.MagicMock(return_value={"fs.opener": [entry_point]}) ++ patch = mock.patch("importlib.metadata.entry_points", entry_points) ++ else: ++ iter_entry_points = mock.MagicMock(return_value=iter([entry_point])) ++ patch = mock.patch("pkg_resources.iter_entry_points", iter_entry_points) ++ with patch: + with self.assertRaises(errors.EntryPointError) as ctx: + opener.open_fs("test://") + self.assertEqual( +@@ -145,10 +168,19 @@ class TestRegistry(unittest.TestCase): + pass + + entry_point = mock.MagicMock() ++ entry_point.name = "test" + entry_point.load = mock.MagicMock(return_value=NotAnOpener) +- iter_entry_points = mock.MagicMock(return_value=iter([entry_point])) + +- with mock.patch("pkg_resources.iter_entry_points", iter_entry_points): ++ if sys.version_info >= (3, 8): ++ if sys.version_info >= (3, 10): ++ entry_points = mock.MagicMock(return_value=tuple([entry_point])) ++ else: ++ entry_points = mock.MagicMock(return_value={"fs.opener": [entry_point]}) ++ patch = mock.patch("importlib.metadata.entry_points", entry_points) ++ else: ++ iter_entry_points = mock.MagicMock(return_value=iter([entry_point])) ++ patch = mock.patch("pkg_resources.iter_entry_points", iter_entry_points) ++ with patch: + with self.assertRaises(errors.EntryPointError) as ctx: + opener.open_fs("test://") + self.assertEqual("entry point did not return an opener", str(ctx.exception)) +@@ -162,10 +194,20 @@ class TestRegistry(unittest.TestCase): + pass + + entry_point = mock.MagicMock() ++ entry_point.name = "test" + entry_point.load = mock.MagicMock(return_value=BadOpener) +- iter_entry_points = mock.MagicMock(return_value=iter([entry_point])) + +- with mock.patch("pkg_resources.iter_entry_points", iter_entry_points): ++ if sys.version_info >= (3, 8): ++ if sys.version_info >= (3, 10): ++ entry_points = mock.MagicMock(return_value=tuple([entry_point])) ++ else: ++ entry_points = mock.MagicMock(return_value={"fs.opener": [entry_point]}) ++ patch = mock.patch("importlib.metadata.entry_points", entry_points) ++ else: ++ iter_entry_points = mock.MagicMock(return_value=iter([entry_point])) ++ patch = mock.patch("pkg_resources.iter_entry_points", iter_entry_points) ++ ++ with patch: + with self.assertRaises(errors.EntryPointError) as ctx: + opener.open_fs("test://") + self.assertEqual( +@@ -218,5 +260,5 @@ class TestOpeners(unittest.TestCase): + def test_repr(self): + # Check __repr__ works +- for entry_point in pkg_resources.iter_entry_points("fs.opener"): ++ for entry_point in importlib.metadata.entry_points(group="fs.opener"): + _opener = entry_point.load() + repr(_opener()) diff --git a/pkgs/development/python-modules/fs/python-3.14-pathname2url.patch b/pkgs/development/python-modules/fs/python-3.14-pathname2url.patch new file mode 100644 index 000000000000..45cae431cb3e --- /dev/null +++ b/pkgs/development/python-modules/fs/python-3.14-pathname2url.patch @@ -0,0 +1,49 @@ +Python 3.14 gives absolute paths an empty authority, so pathname2url("/tmp") +returns "///tmp" rather than "/tmp". fs appends that to a protocol prefix that +already ends in "//", producing URLs like "osfs://///tmp/foo". + +https://github.com/PyFilesystem/pyfilesystem2/issues/596 + +--- a/fs/_url_tools.py ++++ b/fs/_url_tools.py +@@ -3,6 +3,7 @@ + import platform + import re + import six ++import sys + + if typing.TYPE_CHECKING: + from typing import Text +@@ -10,6 +11,17 @@ + _WINDOWS_PLATFORM = platform.system() == "Windows" + + ++def _pathname2url(path): ++ # type: (Text) -> Text ++ """Convert a path to a URL path, as Python < 3.14 used to.""" ++ url = six.moves.urllib.request.pathname2url(path) ++ # Python 3.14 gives absolute paths an empty authority, so that the ++ # result can be appended to "file:" rather than to "file://". ++ if sys.version_info >= (3, 14) and url.startswith("///"): ++ url = url[2:] ++ return url ++ ++ + def url_quote(path_snippet): + # type: (Text) -> Text + """Quote a URL without quoting the Windows drive letter, if any. +@@ -26,12 +38,12 @@ + drive_letter, path = path_snippet.split(":", 1) + if six.PY2: + path = path.encode("utf-8") +- path = six.moves.urllib.request.pathname2url(path) ++ path = _pathname2url(path) + path_snippet = "{}:{}".format(drive_letter, path) + else: + if six.PY2: + path_snippet = path_snippet.encode("utf-8") +- path_snippet = six.moves.urllib.request.pathname2url(path_snippet) ++ path_snippet = _pathname2url(path_snippet) + return path_snippet + +