Skip to content

Commit ccfc0ec

Browse files
committed
DEVOPS-810 fix: remove pkg_resources/PyFilesystem2 dependency
Replace the unmaintained `fs` (PyFilesystem2) package with a pathlib.Path-based FSResource implementation using stdlib only (shutil, urllib.parse). This eliminates the pkg_resources deprecation warning and the dependency on a package that will break when pkg_resources is removed from setuptools.
1 parent 5ce0ace commit ccfc0ec

5 files changed

Lines changed: 47 additions & 141 deletions

File tree

cumulusci/__init__.py

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,5 @@
11
import os
22
import sys
3-
import warnings
4-
5-
# Suppress pkg_resources deprecation warning from PyFilesystem (fs) package
6-
# See: https://github.com/PyFilesystem/pyfilesystem2/issues/577
7-
warnings.filterwarnings(
8-
"ignore",
9-
message="pkg_resources is deprecated as an API",
10-
category=UserWarning,
11-
)
123

134
from simple_salesforce import api, bulk
145

cumulusci/utils/fileutils.py

Lines changed: 46 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import os
2+
import shutil
23
import urllib.request
34
import webbrowser
45
from contextlib import contextmanager
56
from io import StringIO, TextIOWrapper
67
from pathlib import Path
78
from typing import IO, ContextManager, Text, Tuple, Union
9+
from urllib.parse import unquote as urlunquote
810

911
import requests
10-
from fs import base, copy, open_fs
11-
from fs import path as fspath
1212

1313
"""Utilities for working with files"""
1414

@@ -94,14 +94,6 @@ def load_from_source(source: DataInput) -> ContextManager[Tuple[IO[Text], Text]]
9494
yield f, path
9595

9696

97-
def proxy(funcname):
98-
def func(self, *args, **kwargs):
99-
real_func = getattr(self.fs, funcname)
100-
return real_func(self.filename, *args, **kwargs)
101-
102-
return func
103-
104-
10597
def view_file(path):
10698
"""Open the given file in a webbrowser or whatever
10799
@@ -115,7 +107,7 @@ def view_file(path):
115107

116108

117109
class FSResource:
118-
"""Generalization of pathlib.Path to support S3, FTP, etc
110+
"""A pathlib.Path-based resource abstraction for local filesystem operations.
119111
120112
Create them through the open_fs_resource module function or static
121113
function which will create a context manager that generates an FSResource.
@@ -130,100 +122,68 @@ def __init__(self):
130122
def new(
131123
cls,
132124
resource_url_or_path: Union[str, Path, "FSResource"],
133-
filesystem: base.FS = None,
134125
):
135126
"""Directly create a new FSResource from a URL or path (absolute or relative)
136127
137128
You can call this to bypass the context manager in contexts where closing isn't
138129
important (e.g. interactive repl experiments)."""
139130
self = cls.__new__(cls)
140131

141-
if isinstance(resource_url_or_path, str) and "://" in resource_url_or_path:
142-
path_type = "url"
143-
elif isinstance(resource_url_or_path, FSResource):
144-
path_type = "resource"
132+
if isinstance(resource_url_or_path, FSResource):
133+
self._path = resource_url_or_path._path
134+
elif isinstance(resource_url_or_path, str) and "://" in resource_url_or_path:
135+
url_str = resource_url_or_path
136+
# Strip the scheme prefix to get the path portion
137+
_, path_part = url_str.split("://", 1)
138+
decoded = urlunquote(path_part)
139+
self._path = Path(decoded).absolute()
145140
else:
146-
resource_url_or_path = Path(resource_url_or_path)
147-
path_type = "path"
148-
149-
if filesystem:
150-
assert path_type != "resource"
151-
fs = filesystem
152-
filename = str(resource_url_or_path)
153-
elif path_type == "resource": # clone a resource reference
154-
fs = resource_url_or_path.fs
155-
filename = resource_url_or_path.filename
156-
elif path_type == "path":
157-
if resource_url_or_path.is_absolute():
158-
if resource_url_or_path.drive:
159-
root = resource_url_or_path.drive + "/"
160-
else:
161-
root = resource_url_or_path.root
162-
filename = resource_url_or_path.relative_to(root).as_posix()
163-
else:
164-
root = Path("/").absolute()
165-
filename = (
166-
(Path(".") / resource_url_or_path)
167-
.absolute()
168-
.relative_to(root)
169-
.as_posix()
170-
)
171-
fs = open_fs(str(root))
172-
elif path_type == "url":
173-
path, filename = resource_url_or_path.replace("\\", "/").rsplit("/", 1)
174-
fs = open_fs(path)
175-
176-
self.fs = fs
177-
self.filename = filename
141+
self._path = Path(resource_url_or_path).absolute()
142+
178143
return self
179144

180-
exists = proxy("exists")
181-
open = proxy("open")
182-
unlink = proxy("remove")
183-
rmdir = proxy("removedir")
184-
removetree = proxy("removetree")
185-
geturl = proxy("geturl")
145+
def exists(self):
146+
return os.path.exists(self._path)
186147

187-
def getsyspath(self):
188-
return Path(os.fsdecode(self.fs.getsyspath(self.filename)))
148+
def open(self, mode="r", **kw):
149+
return self._path.open(mode, **kw)
189150

190-
def joinpath(self, other):
191-
"""Create a new FSResource based on an existing one
151+
def unlink(self):
152+
self._path.unlink()
192153

193-
Note that calling .close() on either one (or exiting the
194-
context of the original) will close the filesystem that both use.
154+
def rmdir(self):
155+
self._path.rmdir()
195156

196-
In practice, if you use the new one within the open context
197-
of the old one, you'll be fine.
198-
"""
199-
path = fspath.join(self.filename, other)
200-
return FSResource.new(self.fs.geturl(path))
157+
def removetree(self):
158+
shutil.rmtree(self._path)
201159

202-
def copy_to(self, other):
203-
"""Create a new FSResource by copying the underlying resource
160+
def getsyspath(self):
161+
return self._path
204162

205-
Note that calling .close() on either one (or exiting the
206-
context of the original) will close the filesystem that both use.
163+
def geturl(self):
164+
return f"file://{urllib.request.pathname2url(str(self._path))}"
207165

208-
In practice, if you use the new one within the open context
209-
of the old one, you'll be fine.
210-
"""
166+
def joinpath(self, other):
167+
return FSResource.new(self._path / other)
168+
169+
def copy_to(self, other):
211170
if isinstance(other, (str, Path)):
212171
other = FSResource.new(other)
213-
copy.copy_file(self.fs, self.filename, other.fs, other.filename)
172+
shutil.copy2(self._path, other._path)
214173

215174
def mkdir(self, *, parents=False, exist_ok=False):
216-
if parents:
217-
self.fs.makedirs(self.filename, recreate=exist_ok)
218-
else:
219-
self.fs.makedir(self.filename, recreate=exist_ok)
175+
try:
176+
self._path.mkdir(parents=parents, exist_ok=exist_ok)
177+
except FileExistsError:
178+
if not exist_ok:
179+
raise
220180

221181
def __contains__(self, other):
222-
return other in str(self.geturl())
182+
return other in str(self._path)
223183

224184
@property
225185
def suffix(self):
226-
return Path(self).suffix
186+
return self._path.suffix
227187

228188
def __truediv__(self, other):
229189
return self.joinpath(other)
@@ -232,31 +192,24 @@ def __repr__(self):
232192
return f"<FSResource {self.geturl()}>"
233193

234194
def __str__(self):
235-
rc = self.geturl()
236-
if rc.startswith("file://"):
237-
return rc[6:]
195+
return str(self._path)
238196

239197
def __fspath__(self):
240-
return self.fs.getsyspath(self.filename)
198+
return str(self._path)
241199

242200
def close(self):
243-
self.fs.close()
201+
pass # no-op: no filesystem to close
244202

245203
@staticmethod
246204
@contextmanager
247205
def open_fs_resource(
248-
resource_url_or_path: Union[str, Path, "FSResource"], filesystem: base.FS = None
206+
resource_url_or_path: Union[str, Path, "FSResource"],
249207
):
250208
"""Create a context-managed FSResource
251209
252210
Input is a URL, path (absolute or relative) or FSResource
253211
254-
The function should be used in a context manager. The
255-
resource's underlying filesystem will be closed automatically
256-
when the context ends and the data will be saved back to the
257-
filesystem (local, remote, zipfile, etc.)
258-
259-
Think of it as a way of "mounting" a filesystem, directory or file.
212+
The function should be used in a context manager.
260213
261214
For example:
262215
@@ -278,13 +231,8 @@ def open_fs_resource(
278231
# yam
279232
280233
"""
281-
resource = FSResource.new(resource_url_or_path, filesystem)
282-
if not filesystem:
283-
filesystem = resource
284-
try:
285-
yield resource
286-
finally:
287-
filesystem.close()
234+
resource = FSResource.new(resource_url_or_path)
235+
yield resource
288236

289237

290238
open_fs_resource = FSResource.open_fs_resource

cumulusci/utils/tests/test_fileutils.py

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010

1111
import pytest
1212
import responses
13-
from fs import errors, open_fs
1413

1514
import cumulusci
1615
from cumulusci.utils import fileutils, temporary_dir, update_tree
@@ -149,12 +148,6 @@ def test_clone_fsresource(self):
149148
with open_fs_resource(resource) as resource2:
150149
assert abspath in str(resource2)
151150

152-
def test_load_from_file_system(self):
153-
abspath = os.path.abspath(self.file)
154-
fs = open_fs("/")
155-
with open_fs_resource(abspath, fs) as f:
156-
assert abspath in str(f)
157-
158151
def test_windows_path(self):
159152
abspath = "c:\\foo\\bar"
160153
with open_fs_resource(abspath) as f:
@@ -234,7 +227,7 @@ def test_mkdir_rmdir(self):
234227
f.mkdir(parents=False, exist_ok=True)
235228
assert abspath.exists()
236229

237-
with pytest.raises(errors.DirectoryExists):
230+
with pytest.raises(FileExistsError):
238231
f.mkdir(parents=False, exist_ok=False)
239232
f.rmdir()
240233

pyproject.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@ dependencies = [
3030
"cryptography",
3131
"python-dateutil",
3232
"Faker",
33-
"fs",
3433
"github3.py",
3534
"jinja2",
3635
"keyring<=23.0.1",

uv.lock

Lines changed: 0 additions & 25 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)