Skip to content

Commit a539ba6

Browse files
authored
DEVOPS-810 fix: remove pkg_resources/PyFilesystem2 dependency (#20)
* 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. * DEVOPS-810 fix: address PR review comments for FSResource - Add URL scheme validation to reject non-file:// URLs - Use urlparse + url2pathname for proper URL parsing - Handle non-string inputs in __contains__ gracefully - Remove unused urlunquote import - Simplify mkdir by using pathlib's native exist_ok handling
1 parent 5ce0ace commit a539ba6

5 files changed

Lines changed: 54 additions & 140 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: 49 additions & 97 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 urlparse
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,72 @@ 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"
145-
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()
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+
parsed = urlparse(resource_url_or_path)
136+
if parsed.scheme != "file":
137+
raise ValueError(
138+
f"Only file:// URLs are supported, got {parsed.scheme}://"
170139
)
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)
140+
# For relative file URLs like file://relative/path, urlparse puts
141+
# "relative" in netloc and "/path" in path. Concatenate them.
142+
if parsed.netloc and parsed.netloc.lower() != "localhost":
143+
path_str = parsed.netloc + parsed.path
144+
else:
145+
path_str = parsed.path
146+
decoded = urllib.request.url2pathname(path_str)
147+
self._path = Path(decoded).absolute()
148+
else:
149+
self._path = Path(resource_url_or_path).absolute()
175150

176-
self.fs = fs
177-
self.filename = filename
178151
return self
179152

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

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

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

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

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))
165+
def removetree(self):
166+
shutil.rmtree(self._path)
201167

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

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

208-
In practice, if you use the new one within the open context
209-
of the old one, you'll be fine.
210-
"""
174+
def joinpath(self, other):
175+
return FSResource.new(self._path / other)
176+
177+
def copy_to(self, other):
211178
if isinstance(other, (str, Path)):
212179
other = FSResource.new(other)
213-
copy.copy_file(self.fs, self.filename, other.fs, other.filename)
180+
shutil.copy2(self._path, other._path)
214181

215182
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)
183+
self._path.mkdir(parents=parents, exist_ok=exist_ok)
220184

221185
def __contains__(self, other):
222-
return other in str(self.geturl())
186+
return str(other) in str(self._path)
223187

224188
@property
225189
def suffix(self):
226-
return Path(self).suffix
190+
return self._path.suffix
227191

228192
def __truediv__(self, other):
229193
return self.joinpath(other)
@@ -232,31 +196,24 @@ def __repr__(self):
232196
return f"<FSResource {self.geturl()}>"
233197

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

239201
def __fspath__(self):
240-
return self.fs.getsyspath(self.filename)
202+
return str(self._path)
241203

242204
def close(self):
243-
self.fs.close()
205+
pass # no-op: no filesystem to close
244206

245207
@staticmethod
246208
@contextmanager
247209
def open_fs_resource(
248-
resource_url_or_path: Union[str, Path, "FSResource"], filesystem: base.FS = None
210+
resource_url_or_path: Union[str, Path, "FSResource"],
249211
):
250212
"""Create a context-managed FSResource
251213
252214
Input is a URL, path (absolute or relative) or FSResource
253215
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.
216+
The function should be used in a context manager.
260217
261218
For example:
262219
@@ -278,13 +235,8 @@ def open_fs_resource(
278235
# yam
279236
280237
"""
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()
238+
resource = FSResource.new(resource_url_or_path)
239+
yield resource
288240

289241

290242
open_fs_resource = FSResource.open_fs_resource

cumulusci/utils/tests/test_fileutils.py

Lines changed: 5 additions & 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

@@ -255,6 +248,10 @@ def test_fs_resource_init_error(self):
255248
with pytest.raises(NotImplementedError):
256249
FSResource()
257250

251+
def test_non_file_url_rejected(self):
252+
with pytest.raises(ValueError, match="Only file:// URLs are supported"):
253+
FSResource.new("https://example.com/path")
254+
258255

259256
def test_update_tree(tmpdir):
260257
source_dir = Path(tmpdir.mkdir("source"))

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)