Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package python-Fabric for openSUSE:Factory checked in at 2021-08-16 10:11:18 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/python-Fabric (Old) and /work/SRC/openSUSE:Factory/.python-Fabric.new.1899 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-Fabric" Mon Aug 16 10:11:18 2021 rev:35 rq:911844 version:2.6.0 Changes: -------- --- /work/SRC/openSUSE:Factory/python-Fabric/python-Fabric.changes 2020-10-18 16:33:53.720823939 +0200 +++ /work/SRC/openSUSE:Factory/.python-Fabric.new.1899/python-Fabric.changes 2021-08-16 10:17:17.798679952 +0200 @@ -1,0 +2,21 @@ +Thu Aug 12 11:15:13 UTC 2021 - John Paul Adrian Glaubitz <adrian.glaub...@suse.com> + +- Update to 2.6.0: + * [Feature] #1999: Add sudo support to Group. Thanks to Bonnie Hardin for + the report and to Winston Nolan for an early patchset. + * [Feature] #1810: Add put/get support to Group. + * [Feature] #1868: Ported a feature from v1: interpolating the local path + argument in Transfer.get with connection and remote filepath attributes. + For example, cxn.get(remote="/var/log/foo.log", local="{host}/") is now + feasible for storing a file in per-host-named directories or files, and + in fact Group.get does this by default. + * [Feature]: When the local path argument to Transfer.get contains nonexistent + directories, they are now created instead of raising an error. + Warning: This change introduces a new runtime dependency: pathlib2. + * [Bug]: Fix a handful of issues in the handling and mocking of SFTP local paths + and os.path members within fabric.testing; this should remove some occasional + ???useless Mocks??? as well as hewing closer to the real behavior of things like + os.path.abspath re: path normalization. +- Update Requires from setup.py + +------------------------------------------------------------------- Old: ---- fabric-2.5.0.tar.gz New: ---- fabric-2.6.0.tar.gz ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ python-Fabric.spec ++++++ --- /var/tmp/diff_new_pack.Aja2Zb/_old 2021-08-16 10:17:18.250679408 +0200 +++ /var/tmp/diff_new_pack.Aja2Zb/_new 2021-08-16 10:17:18.254679403 +0200 @@ -1,7 +1,7 @@ # # spec file for package python-Fabric # -# Copyright (c) 2020 SUSE LLC +# Copyright (c) 2021 SUSE LLC # # All modifications and additions to the file contributed by third parties # remain the property of their copyright owners, unless otherwise agreed @@ -18,7 +18,7 @@ %{?!python_module:%define python_module() python-%{**} python3-%{**}} Name: python-Fabric -Version: 2.5.0 +Version: 2.6.0 Release: 0 Summary: A Pythonic tool for remote execution and deployment License: BSD-2-Clause @@ -43,6 +43,7 @@ Requires: python-decorator Requires: python-invoke >= 1.3 Requires: python-paramiko >= 2.4 +Requires: python-pathlib2 Requires: python-setuptools Requires: python-six Conflicts: python-Fabric3 ++++++ fabric-2.5.0.tar.gz -> fabric-2.6.0.tar.gz ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/fabric-2.5.0/LICENSE new/fabric-2.6.0/LICENSE --- old/fabric-2.5.0/LICENSE 2019-07-19 18:30:31.000000000 +0200 +++ new/fabric-2.6.0/LICENSE 2020-01-10 22:21:14.000000000 +0100 @@ -1,4 +1,4 @@ -Copyright (c) 2019 Jeff Forcier. +Copyright (c) 2020 Jeff Forcier. All rights reserved. Redistribution and use in source and binary forms, with or without diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/fabric-2.5.0/PKG-INFO new/fabric-2.6.0/PKG-INFO --- old/fabric-2.5.0/PKG-INFO 2019-08-07 00:57:39.000000000 +0200 +++ new/fabric-2.6.0/PKG-INFO 2021-01-19 02:09:55.000000000 +0100 @@ -1,6 +1,6 @@ Metadata-Version: 2.1 Name: fabric -Version: 2.5.0 +Version: 2.6.0 Summary: High level SSH command execution Home-page: http://fabfile.org Author: Jeff Forcier @@ -50,5 +50,5 @@ Classifier: Topic :: System :: Clustering Classifier: Topic :: System :: Software Distribution Classifier: Topic :: System :: Systems Administration -Provides-Extra: pytest Provides-Extra: testing +Provides-Extra: pytest diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/fabric-2.5.0/dev-requirements.txt new/fabric-2.6.0/dev-requirements.txt --- old/fabric-2.5.0/dev-requirements.txt 2019-08-06 20:33:19.000000000 +0200 +++ new/fabric-2.6.0/dev-requirements.txt 2020-12-31 17:19:09.000000000 +0100 @@ -14,8 +14,8 @@ # Linting! flake8==3.6.0 # Coverage! -coverage==3.7.1 -codecov==1.6.3 +coverage==5.3.1 +codecov==2.1.11 # Documentation tools sphinx>=1.4,<1.7 alabaster==0.7.12 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/fabric-2.5.0/fabric/_version.py new/fabric-2.6.0/fabric/_version.py --- old/fabric-2.5.0/fabric/_version.py 2019-08-07 00:57:28.000000000 +0200 +++ new/fabric-2.6.0/fabric/_version.py 2021-01-19 01:49:26.000000000 +0100 @@ -1,2 +1,2 @@ -__version_info__ = (2, 5, 0) +__version_info__ = (2, 6, 0) __version__ = ".".join(map(str, __version_info__)) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/fabric-2.5.0/fabric/connection.py new/fabric-2.6.0/fabric/connection.py --- old/fabric-2.5.0/fabric/connection.py 2019-07-19 18:29:16.000000000 +0200 +++ new/fabric-2.6.0/fabric/connection.py 2021-01-19 01:49:26.000000000 +0100 @@ -300,9 +300,11 @@ Default: ``config.timeouts.connect``. - .. _connect_kwargs-arg: :param dict connect_kwargs: + + .. _connect_kwargs-arg: + Keyword arguments handed verbatim to `SSHClient.connect <paramiko.client.SSHClient.connect>` (when `.open` is called). diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/fabric-2.5.0/fabric/group.py new/fabric-2.6.0/fabric/group.py --- old/fabric-2.5.0/fabric/group.py 2019-07-19 18:29:16.000000000 +0200 +++ new/fabric-2.6.0/fabric/group.py 2021-01-19 01:49:26.000000000 +0100 @@ -18,9 +18,9 @@ concrete subclasses (such as `.SerialGroup` or `.ThreadingGroup`) or you'll get ``NotImplementedError`` on most of the methods. - Most methods in this class mirror those of `.Connection`, taking the same - arguments; however their return values and exception-raising behavior - differs: + Most methods in this class wrap those of `.Connection` and will accept the + same arguments; however their return values and exception-raising behavior + differ: - Return values are dict-like objects (`.GroupResult`) mapping `.Connection` objects to the return value for the respective connections: @@ -99,6 +99,11 @@ group.extend(connections) return group + def _do(self, method, *args, **kwargs): + # TODO: rename this something public & commit to an API for user + # subclasses + raise NotImplementedError + def run(self, *args, **kwargs): """ Executes `.Connection.run` on all member `Connections <.Connection>`. @@ -107,25 +112,22 @@ .. versionadded:: 2.0 """ - # TODO: probably best to suck it up & match actual run() sig? # TODO: how to change method of execution across contents? subclass, # kwargs, additional methods, inject an executor? Doing subclass for # now, but not 100% sure it's the best route. # TODO: also need way to deal with duplicate connections (see THOUGHTS) - # TODO: and errors - probably FailureSet? How to handle other, - # regular, non Failure, exceptions though? Still need an aggregate - # exception type either way, whether it is FailureSet or what... - # TODO: OTOH, users may well want to be able to operate on the hosts - # that did not fail (esp if failure % is low) so we really _do_ want - # something like a result object mixing success and failure, or maybe a - # golang style two-tuple of successes and failures? - # TODO: or keep going w/ a "return or except", but the object is - # largely similar (if not identical) in both situations, with the - # exception just being the signal that Shit Broke? - raise NotImplementedError + return self._do("run", *args, **kwargs) - # TODO: how to handle sudo? Probably just an inner worker method that takes - # the method name to actually call (run, sudo, etc)? + def sudo(self, *args, **kwargs): + """ + Executes `.Connection.sudo` on all member `Connections <.Connection>`. + + :returns: a `.GroupResult`. + + .. versionadded:: 2.6 + """ + # TODO: see run() TODOs + return self._do("sudo", *args, **kwargs) # TODO: this all needs to mesh well with similar strategies applied to # entire tasks - so that may still end up factored out into Executors or @@ -133,19 +135,59 @@ # TODO: local? Invoke wants ability to do that on its own though, which # would be distinct from Group. (May want to switch Group to use that, - # though, whatever it ends up being?) + # though, whatever it ends up being? Eg many cases where you do want to do + # some local thing either N times identically, or parameterized by remote + # cxn values) - def get(self, *args, **kwargs): + def put(self, *args, **kwargs): """ - Executes `.Connection.get` on all member `Connections <.Connection>`. + Executes `.Connection.put` on all member `Connections <.Connection>`. - :returns: a `.GroupResult`. + This is a straightforward application: aside from whatever the concrete + group subclass does for concurrency or lack thereof, the effective + result is like running a loop over the connections and calling their + ``put`` method. - .. versionadded:: 2.0 + :returns: + a `.GroupResult` whose values are `.transfer.Result` instances. + + .. versionadded:: 2.6 """ - # TODO: probably best to suck it up & match actual get() sig? - # TODO: actually implement on subclasses - raise NotImplementedError + return self._do("put", *args, **kwargs) + + def get(self, *args, **kwargs): + """ + Executes `.Connection.get` on all member `Connections <.Connection>`. + + .. note:: + This method changes some behaviors over e.g. directly calling + `.Connection.get` on a ``for`` loop of connections; the biggest is + that the implied default value for the ``local`` parameter is + ``"{host}/"``, which triggers use of local path parameterization + based on each connection's target hostname. + + Thus, unless you override ``local`` yourself, a copy of the + downloaded file will be stored in (relative) directories named + after each host in the group. + + .. warning:: + Using file-like objects as the ``local`` argument is not currently + supported, as it would be equivalent to supplying that same object + to a series of individual ``get()`` calls. + + :returns: + a `.GroupResult` whose values are `.transfer.Result` instances. + + .. versionadded:: 2.6 + """ + # TODO: consider a backwards incompat change after we drop Py2 that + # just makes a lot of these kwarg-only methods? then below could become + # kwargs.setdefault() if desired. + # TODO: do we care enough to handle explicitly given, yet falsey, + # values? it's a lot more complexity for a corner case. + if len(args) < 2 and "local" not in kwargs: + kwargs["local"] = "{host}/" + return self._do("get", *args, **kwargs) def close(self): """ @@ -170,12 +212,12 @@ .. versionadded:: 2.0 """ - def run(self, *args, **kwargs): + def _do(self, method, *args, **kwargs): results = GroupResult() excepted = False for cxn in self: try: - results[cxn] = cxn.run(*args, **kwargs) + results[cxn] = getattr(cxn, method)(*args, **kwargs) except Exception as e: results[cxn] = e excepted = True @@ -184,8 +226,8 @@ return results -def thread_worker(cxn, queue, args, kwargs): - result = cxn.run(*args, **kwargs) +def thread_worker(cxn, queue, method, args, kwargs): + result = getattr(cxn, method)(*args, **kwargs) # TODO: namedtuple or attrs object? queue.put((cxn, result)) @@ -197,22 +239,26 @@ .. versionadded:: 2.0 """ - def run(self, *args, **kwargs): + def _do(self, method, *args, **kwargs): results = GroupResult() queue = Queue() threads = [] for cxn in self: - my_kwargs = dict(cxn=cxn, queue=queue, args=args, kwargs=kwargs) thread = ExceptionHandlingThread( - target=thread_worker, kwargs=my_kwargs + target=thread_worker, + kwargs=dict( + cxn=cxn, + queue=queue, + method=method, + args=args, + kwargs=kwargs, + ), ) threads.append(thread) for thread in threads: thread.start() for thread in threads: # TODO: configurable join timeout - # TODO: (in sudo's version) configurability around interactive - # prompting resulting in an exception instead, as in v1 thread.join() # Get non-exception results from queue while not queue.empty(): diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/fabric-2.5.0/fabric/testing/base.py new/fabric-2.6.0/fabric/testing/base.py --- old/fabric-2.5.0/fabric/testing/base.py 2019-07-19 18:29:15.000000000 +0200 +++ new/fabric-2.6.0/fabric/testing/base.py 2021-01-19 01:49:26.000000000 +0100 @@ -376,13 +376,17 @@ # Set up mocks self.os_patcher = patch("fabric.transfer.os") self.client_patcher = patch("fabric.connection.SSHClient") + self.path_patcher = patch("fabric.transfer.Path") mock_os = self.os_patcher.start() Client = self.client_patcher.start() + self.path_patcher.start() sftp = Client.return_value.open_sftp.return_value # Handle common filepath massage actions; tests will assume these. def fake_abspath(path): - return "/local/{}".format(path) + # Run normpath to avoid tests not seeing abspath wrinkles (like + # trailing slash chomping) + return "/local/{}".format(os.path.normpath(path)) mock_os.path.abspath.side_effect = fake_abspath sftp.getcwd.return_value = "/remote" @@ -392,11 +396,14 @@ sftp.stat.return_value.st_mode = fake_mode mock_os.stat.return_value.st_mode = fake_mode # Not super clear to me why the 'wraps' functionality in mock isn't - # working for this :( - mock_os.path.basename.side_effect = os.path.basename + # working for this :( reinstate a bunch of os(.path) so it still works + mock_os.sep = os.sep + for name in ("basename", "split", "join", "normpath"): + getattr(mock_os.path, name).side_effect = getattr(os.path, name) # Return the sftp and OS mocks for use by decorator use case. return sftp, mock_os def stop(self): self.os_patcher.stop() self.client_patcher.stop() + self.path_patcher.stop() diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/fabric-2.5.0/fabric/testing/fixtures.py new/fabric-2.6.0/fabric/testing/fixtures.py --- old/fabric-2.5.0/fabric/testing/fixtures.py 2019-07-19 18:29:15.000000000 +0200 +++ new/fabric-2.6.0/fabric/testing/fixtures.py 2021-01-19 01:50:41.000000000 +0100 @@ -104,6 +104,7 @@ """ mock = MockSFTP(autostart=False) client, mock_os = mock.start() + # Regular ol transfer to save some time transfer = Transfer(Connection("host")) yield transfer, client, mock_os # TODO: old mock_sftp() lacked any 'stop'...why? feels bad man diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/fabric-2.5.0/fabric/transfer.py new/fabric-2.6.0/fabric/transfer.py --- old/fabric-2.5.0/fabric/transfer.py 2019-06-08 02:41:25.000000000 +0200 +++ new/fabric-2.6.0/fabric/transfer.py 2021-01-19 01:52:12.000000000 +0100 @@ -6,6 +6,11 @@ import posixpath import stat +try: + from pathlib import Path +except ImportError: + from pathlib2 import Path + from .util import debug # TODO: actual logging! LOL # TODO: figure out best way to direct folks seeking rsync, to patchwork's rsync @@ -40,7 +45,7 @@ def get(self, remote, local=None, preserve_mode=True): """ - Download a file from the current connection to the local filesystem. + Copy a file from wrapped connection's host to the local filesystem. :param str remote: Remote file to download. @@ -60,7 +65,9 @@ **If None or another 'falsey'/empty value is given** (the default), the remote file is downloaded to the current working directory (as - seen by `os.getcwd`) using its remote filename. + seen by `os.getcwd`) using its remote filename. (This is equivalent + to giving ``"{basename}"``; see the below subsection on + interpolation.) **If a string is given**, it should be a path to a local directory or file and is subject to similar behavior as that seen by common @@ -71,11 +78,23 @@ '/tmp/')`` would result in creation or overwriting of ``/tmp/file.txt``). + This path will be **interpolated** with some useful parameters, + using `str.format`: + + - The `.Connection` object's ``host``, ``user`` and ``port`` + attributes. + - The ``basename`` and ``dirname`` of the ``remote`` path, as + derived by `os.path` (specifically, its ``posixpath`` flavor, so + that the resulting values are useful on remote POSIX-compatible + SFTP servers even if the local client is Windows). + - Thus, for example, ``"/some/path/{user}@{host}/{basename}"`` will + yield different local paths depending on the properties of both + the connection and the remote path. + .. note:: - When dealing with nonexistent file paths, normal Python file - handling concerns come into play - for example, a ``local`` - path containing non-leaf directories which do not exist, will - typically result in an `OSError`. + If nonexistent directories are present in this path (including + the final path component, if it ends in `os.sep`) they will be + created automatically using `os.makedirs`. **If a file-like object is given**, the contents of the remote file are simply written into it. @@ -87,12 +106,14 @@ :returns: A `.Result` object. .. versionadded:: 2.0 + .. versionchanged:: 2.6 + Added ``local`` path interpolation of connection & remote file + attributes. + .. versionchanged:: 2.6 + Create missing ``local`` directories automatically. """ # TODO: how does this API change if we want to implement # remote-to-remote file transfer? (Is that even realistic?) - # TODO: handle v1's string interpolation bits, especially the default - # one, or at least think about how that would work re: split between - # single and multiple server targets. # TODO: callback support # TODO: how best to allow changing the behavior/semantics of # remote/local (e.g. users might want 'safer' behavior that complains @@ -107,15 +128,37 @@ self.sftp.getcwd() or self.sftp.normalize("."), remote ) - # Massage local path: - # - handle file-ness - # - if path, fill with remote name if empty, & make absolute + # Massage local path orig_local = local is_file_like = hasattr(local, "write") and callable(local.write) + remote_filename = posixpath.basename(remote) if not local: - local = posixpath.basename(remote) + local = remote_filename + # Path-driven local downloads need interpolation, abspath'ing & + # directory creation if not is_file_like: + local = local.format( + host=self.connection.host, + user=self.connection.user, + port=self.connection.port, + dirname=posixpath.dirname(remote), + basename=remote_filename, + ) + # Must treat dir vs file paths differently, lest we erroneously + # mkdir what was intended as a filename, and so that non-empty + # dir-like paths still get remote filename tacked on. + if local.endswith(os.sep): + dir_path = local + local = os.path.join(local, remote_filename) + else: + dir_path, _ = os.path.split(local) local = os.path.abspath(local) + Path(dir_path).mkdir(parents=True, exist_ok=True) + # TODO: reimplement mkdir (or otherwise write a testing function) + # allowing us to track what was created so we can revert if + # transfer fails. + # TODO: Alternately, transfer to temp location and then move, but + # that's basically inverse of v1's sudo-put which gets messy # Run Paramiko-level .get() (side-effects only. womp.) # TODO: push some of the path handling into Paramiko; it should be diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/fabric-2.5.0/fabric.egg-info/PKG-INFO new/fabric-2.6.0/fabric.egg-info/PKG-INFO --- old/fabric-2.5.0/fabric.egg-info/PKG-INFO 2019-08-07 00:57:38.000000000 +0200 +++ new/fabric-2.6.0/fabric.egg-info/PKG-INFO 2021-01-19 02:09:55.000000000 +0100 @@ -1,6 +1,6 @@ Metadata-Version: 2.1 Name: fabric -Version: 2.5.0 +Version: 2.6.0 Summary: High level SSH command execution Home-page: http://fabfile.org Author: Jeff Forcier @@ -50,5 +50,5 @@ Classifier: Topic :: System :: Clustering Classifier: Topic :: System :: Software Distribution Classifier: Topic :: System :: Systems Administration -Provides-Extra: pytest Provides-Extra: testing +Provides-Extra: pytest diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/fabric-2.5.0/fabric.egg-info/requires.txt new/fabric-2.6.0/fabric.egg-info/requires.txt --- old/fabric-2.5.0/fabric.egg-info/requires.txt 2019-08-07 00:57:38.000000000 +0200 +++ new/fabric-2.6.0/fabric.egg-info/requires.txt 2021-01-19 02:09:55.000000000 +0100 @@ -1,5 +1,6 @@ invoke<2.0,>=1.3 paramiko>=2.4 +pathlib2 [pytest] mock<3.0,>=2.0.0 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/fabric-2.5.0/setup.py new/fabric-2.6.0/setup.py --- old/fabric-2.5.0/setup.py 2019-08-06 20:36:43.000000000 +0200 +++ new/fabric-2.6.0/setup.py 2021-01-19 01:49:26.000000000 +0100 @@ -67,7 +67,7 @@ author="Jeff Forcier", author_email="j...@bitprophet.org", url="http://fabfile.org", - install_requires=["invoke>=1.3,<2.0", "paramiko>=2.4"], + install_requires=["invoke>=1.3,<2.0", "paramiko>=2.4", "pathlib2"], extras_require={ "testing": testing_deps, "pytest": testing_deps + pytest_deps, diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/fabric-2.5.0/sites/docs/getting-started.rst new/fabric-2.6.0/sites/docs/getting-started.rst --- old/fabric-2.5.0/sites/docs/getting-started.rst 2019-07-19 18:29:16.000000000 +0200 +++ new/fabric-2.6.0/sites/docs/getting-started.rst 2020-01-10 22:20:59.000000000 +0100 @@ -14,7 +14,7 @@ on top; user code will most often import from the ``fabric`` package, but you'll sometimes import directly from ``invoke`` or ``paramiko`` too: -- `Invoke <https://pyinvoke.org>`_ implements CLI parsing, task organization, +- `Invoke <https://www.pyinvoke.org>`_ implements CLI parsing, task organization, and shell command execution (a generic framework plus specific implementation for local commands.) @@ -24,7 +24,7 @@ - Fabric users will frequently import Invoke objects, in cases where Fabric itself has no need to subclass or otherwise modify what Invoke provides. -- `Paramiko <https://paramiko.org>`_ implements low/mid level SSH +- `Paramiko <https://www.paramiko.org>`_ implements low/mid level SSH functionality - SSH and SFTP sessions, key management, etc. - Fabric mostly uses this under the hood; users will only rarely import diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/fabric-2.5.0/sites/www/changelog.rst new/fabric-2.6.0/sites/www/changelog.rst --- old/fabric-2.5.0/sites/www/changelog.rst 2019-08-07 00:57:25.000000000 +0200 +++ new/fabric-2.6.0/sites/www/changelog.rst 2021-01-19 02:09:46.000000000 +0100 @@ -5,6 +5,31 @@ .. note:: Looking for the Fabric 1.x changelog? See :doc:`/changelog-v1`. +- :release:`2.6.0 <2021-01-18>` +- :bug:`- major` Fix a handful of issues in the handling and + mocking of SFTP local paths and ``os.path`` members within + :ref:`fabric.testing <testing-subpackage>`; this should remove some + occasional "useless Mocks" as well as hewing closer to the real behavior of + things like ``os.path.abspath`` re: path normalization. +- :feature:`-` When the ``local`` path argument to + `Transfer.get <fabric.transfer.Transfer.get>` contains nonexistent + directories, they are now created instead of raising an error. + + .. warning:: + This change introduces a new runtime dependency: ``pathlib2``. + +- :feature:`1868` Ported a feature from v1: interpolating the local path + argument in `Transfer.get <fabric.transfer.Transfer.get>` with connection + and remote filepath attributes. + + For example, ``cxn.get(remote="/var/log/foo.log", local="{host}/")`` is now + feasible for storing a file in per-host-named directories or files, and in + fact `Group.get <fabric.group.Group.get>` does this by default. +- :feature:`1810` Add `put <fabric.group.Group.put>`/`get + <fabric.group.Group.get>` support to `~fabric.group.Group`. +- :feature:`1999` Add `sudo <fabric.group.Group.sudo>` support to + `~fabric.group.Group`. Thanks to Bonnie Hardin for the report and to Winston + Nolan for an early patchset. - :release:`2.5.0 <2019-08-06>` - :support:`-` Update minimum Invoke version requirement to ``>=1.3``. - :feature:`1985` Add support for explicitly closing remote subprocess' stdin diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/fabric-2.5.0/sites/www/upgrading.rst new/fabric-2.6.0/sites/www/upgrading.rst --- old/fabric-2.5.0/sites/www/upgrading.rst 2019-08-06 20:33:19.000000000 +0200 +++ new/fabric-2.6.0/sites/www/upgrading.rst 2021-01-19 01:49:26.000000000 +0100 @@ -842,7 +842,7 @@ * - ``shell`` / ``env.use_shell`` designating whether or not to wrap commands within an explicit call to e.g. ``/bin/sh -c "real command"`` - - `Pending <https://github.com/pyinvoke/invoke/issues/344>`__/Removed + - `Pending <https://github.com/pyinvoke/invoke/issues/459>`__/Removed - See the note above under ``run`` for details on shell wrapping as a general strategy; unfortunately for ``sudo``, some sort of manual wrapping is still necessary for nontrivial commands (i.e. anything @@ -1127,9 +1127,8 @@ own, so it's gone. * - Naming downloaded files after some aspect of the remote destination, to avoid overwriting during multi-server actions - - `Pending <https://github.com/fabric/fabric/issues/1868>`__ - - This falls under the `~fabric.group.Group` family, which still needs - some work in this regard. + - Ported + - Added back (to `fabric.transfer.Transfer.get`) in Fabric 2.6. .. _upgrading-configuration: diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/fabric-2.5.0/tests/_util.py new/fabric-2.6.0/tests/_util.py --- old/fabric-2.5.0/tests/_util.py 2019-07-19 18:29:16.000000000 +0200 +++ new/fabric-2.6.0/tests/_util.py 2020-01-10 22:21:00.000000000 +0100 @@ -6,9 +6,7 @@ from invoke.vendor.lexicon import Lexicon from pytest_relaxed import trap -from fabric import Connection as Connection_, Config as Config_ from fabric.main import make_program -from paramiko import SSHConfig support = os.path.join(os.path.abspath(os.path.dirname(__file__)), "_support") @@ -51,27 +49,6 @@ assert False, err.format(test) -# Locally override Connection, Config with versions that supply a dummy -# SSHConfig and thus don't load any test-running user's own ssh_config files. -# TODO: find a cleaner way to do this, though I don't really see any that isn't -# adding a ton of fixtures everywhere (and thus, opening up to forgetting it -# for new tests...) -class Config(Config_): - def __init__(self, *args, **kwargs): - wat = "You're giving ssh_config explicitly, please use Config_!" - assert "ssh_config" not in kwargs, wat - # Give ssh_config explicitly -> shorter way of turning off loading - kwargs["ssh_config"] = SSHConfig() - super(Config, self).__init__(*args, **kwargs) - - -class Connection(Connection_): - def __init__(self, *args, **kwargs): - # Make sure we're using our tweaked Config if none was given. - kwargs.setdefault("config", Config()) - super(Connection, self).__init__(*args, **kwargs) - - def faux_v1_env(): # Close enough to v1 _AttributeDict... # Contains a copy of enough of v1's defaults to prevent us having to do a diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/fabric-2.5.0/tests/conftest.py new/fabric-2.6.0/tests/conftest.py --- old/fabric-2.5.0/tests/conftest.py 2019-07-19 18:29:15.000000000 +0200 +++ new/fabric-2.6.0/tests/conftest.py 2020-01-10 22:20:59.000000000 +0100 @@ -1,2 +1,31 @@ # flake8: noqa from fabric.testing.fixtures import client, remote, sftp, sftp_objs, transfer + +from os.path import isfile, expanduser + +from pytest import fixture + +from mock import patch + + +# TODO: does this want to end up in the public fixtures module too? +@fixture(autouse=True) +def no_user_ssh_config(): + """ + Cowardly refuse to ever load what looks like user SSH config paths. + + Prevents the invoking user's real config from gumming up test results or + inflating test runtime (eg if it sets canonicalization on, which will incur + DNS lookups for nearly all of this suite's bogus names). + """ + # An ugly, but effective, hack. I am not proud. I also don't see anything + # that's >= as bulletproof and less ugly? + # TODO: ideally this should expand to cover system config paths too, but + # that's even less likely to be an issue. + def no_config_for_you(path): + if path == expanduser("~/.ssh/config"): + return False + return isfile(path) + + with patch("fabric.config.os.path.isfile", no_config_for_you): + yield diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/fabric-2.5.0/tests/connection.py new/fabric-2.6.0/tests/connection.py --- old/fabric-2.5.0/tests/connection.py 2019-07-19 18:29:16.000000000 +0200 +++ new/fabric-2.6.0/tests/connection.py 2020-12-31 20:52:19.000000000 +0100 @@ -20,11 +20,11 @@ from invoke.config import Config as InvokeConfig from invoke.exceptions import ThreadException -from fabric import Config as Config_ +from fabric import Config, Connection from fabric.exceptions import InvalidV1Env from fabric.util import get_local_user -from _util import support, Connection, Config, faux_v1_env +from _util import support, faux_v1_env # Remote is woven in as a config default, so must be patched there @@ -265,7 +265,7 @@ runtime_path = join(support, "ssh_config", confname) if overrides is None: overrides = {} - return Config_( + return Config( runtime_ssh_path=runtime_path, overrides=overrides ) @@ -274,7 +274,7 @@ return Connection("runtime", config=config) def effectively_blank_when_no_loaded_config(self): - c = Config_(ssh_config=SSHConfig()) + c = Config(ssh_config=SSHConfig()) cxn = Connection("host", config=c) # NOTE: paramiko always injects this even if you look up a host # that has no rules, even wildcard ones. @@ -306,7 +306,7 @@ path = join( support, "ssh_config", "overridden_hostname.conf" ) - config = Config_(runtime_ssh_path=path) + config = Config(runtime_ssh_path=path) cxn = Connection("aliasname", config=config) assert cxn.host == "realname" assert cxn.original_host == "aliasname" @@ -859,7 +859,7 @@ config_kwargs["overrides"] = { "connect_kwargs": {"key_filename": ["configured.key"]} } - conf = Config_(**config_kwargs) + conf = Config(**config_kwargs) connect_kwargs = {} if kwarg: # Stitch in connect_kwargs value diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/fabric-2.5.0/tests/group.py new/fabric-2.6.0/tests/group.py --- old/fabric-2.5.0/tests/group.py 2019-07-19 18:29:16.000000000 +0200 +++ new/fabric-2.6.0/tests/group.py 2021-01-19 01:49:26.000000000 +0100 @@ -1,11 +1,29 @@ from mock import Mock, patch, call -from pytest_relaxed import raises +from pytest import mark, raises from fabric import Connection, Group, SerialGroup, ThreadingGroup, GroupResult from fabric.group import thread_worker from fabric.exceptions import GroupException +RUNNER_METHODS = ("run", "sudo") +TRANSFER_METHODS = ("put", "get") +ALL_METHODS = RUNNER_METHODS + TRANSFER_METHODS +runner_args = ("command",) +runner_kwargs = dict(hide=True, warn=True) +transfer_args = tuple() +transfer_kwargs = dict(local="yokel", remote="goat") +ARGS_BY_METHOD = dict( + run=runner_args, sudo=runner_args, put=transfer_args, get=transfer_args +) +KWARGS_BY_METHOD = dict( + run=runner_kwargs, + sudo=runner_kwargs, + put=transfer_kwargs, + get=transfer_kwargs, +) + + class Group_: class init: "__init__" @@ -41,10 +59,11 @@ for c in g: assert isinstance(c, Connection) - class run: - @raises(NotImplementedError) - def not_implemented_in_base_class(self): - Group().run() + @mark.parametrize("method", ALL_METHODS) + def abstract_methods_not_implemented(self, method): + group = Group() + with raises(NotImplementedError): + getattr(group, method)() class close_and_contextmanager_behavior: def close_closes_all_member_connections(self): @@ -62,8 +81,32 @@ for c in cxns: c.close.assert_called_once_with() + class get: + class local_defaults_to_host_interpolated_path: + def when_no_arg_or_kwarg_given(self): + g = Group("host1", "host2") + g._do = Mock() + g.get(remote="whatever") + g._do.assert_called_with( + "get", remote="whatever", local="{host}/" + ) + + def not_when_arg_given(self): + g = Group("host1", "host2") + g._do = Mock() + g.get("whatever", "lol") + # No local kwarg passed. + g._do.assert_called_with("get", "whatever", "lol") + + def not_when_kwarg_given(self): + g = Group("host1", "host2") + g._do = Mock() + g.get(remote="whatever", local="lol") + # Doesn't stomp given local arg + g._do.assert_called_with("get", remote="whatever", local="lol") + -def _make_serial_tester(cxns, index, args, kwargs): +def _make_serial_tester(method, cxns, index, args, kwargs): args = args[:] kwargs = kwargs.copy() @@ -72,176 +115,187 @@ predecessors = cxns[:car] successors = cxns[cdr:] for predecessor in predecessors: - predecessor.run.assert_called_with(*args, **kwargs) + getattr(predecessor, method).assert_called_with(*args, **kwargs) for successor in successors: - assert not successor.run.called + assert not getattr(successor, method).called return tester class SerialGroup_: - class run: - def executes_arguments_on_contents_run_serially(self): - "executes arguments on contents' run() serially" - cxns = [Connection(x) for x in ("host1", "host2", "host3")] - args = ("command",) - kwargs = {"hide": True, "warn": True} - for index, cxn in enumerate(cxns): - side_effect = _make_serial_tester(cxns, index, args, kwargs) - cxn.run = Mock(side_effect=side_effect) - g = SerialGroup.from_connections(cxns) - g.run(*args, **kwargs) - # Sanity check, e.g. in case none of them were actually run - for cxn in cxns: - cxn.run.assert_called_with(*args, **kwargs) - - def errors_in_execution_capture_and_continue_til_end(self): - cxns = [Mock(name=x) for x in ("host1", "host2", "host3")] - - class OhNoz(Exception): - pass - - onoz = OhNoz() - cxns[1].run.side_effect = onoz - g = SerialGroup.from_connections(cxns) - try: - g.run("whatever", hide=True) - except GroupException as e: - result = e.result - else: - assert False, "Did not raise GroupException!" - succeeded = { - cxns[0]: cxns[0].run.return_value, - cxns[2]: cxns[2].run.return_value, - } - failed = {cxns[1]: onoz} - expected = succeeded.copy() - expected.update(failed) - assert result == expected - assert result.succeeded == succeeded - assert result.failed == failed - - def returns_results_mapping(self): - cxns = [Mock(name=x) for x in ("host1", "host2", "host3")] - g = SerialGroup.from_connections(cxns) - result = g.run("whatever", hide=True) - assert isinstance(result, GroupResult) - expected = {x: x.run.return_value for x in cxns} - assert result == expected - assert result.succeeded == expected - assert result.failed == {} + @mark.parametrize("method", ALL_METHODS) + def executes_arguments_on_contents_run_serially(self, method): + "executes arguments on contents' run() serially" + cxns = [Connection(x) for x in ("host1", "host2", "host3")] + args = ARGS_BY_METHOD[method] + kwargs = KWARGS_BY_METHOD[method] + for index, cxn in enumerate(cxns): + side_effect = _make_serial_tester( + method, cxns, index, args, kwargs + ) + setattr(cxn, method, Mock(side_effect=side_effect)) + g = SerialGroup.from_connections(cxns) + getattr(g, method)(*args, **kwargs) + # Sanity check, e.g. in case none of them were actually run + for cxn in cxns: + getattr(cxn, method).assert_called_with(*args, **kwargs) + + @mark.parametrize("method", ALL_METHODS) + def errors_in_execution_capture_and_continue_til_end(self, method): + cxns = [Mock(name=x) for x in ("host1", "host2", "host3")] + + class OhNoz(Exception): + pass + + onoz = OhNoz() + getattr(cxns[1], method).side_effect = onoz + g = SerialGroup.from_connections(cxns) + try: + getattr(g, method)("whatever", hide=True) + except GroupException as e: + result = e.result + else: + assert False, "Did not raise GroupException!" + succeeded = { + cxns[0]: getattr(cxns[0], method).return_value, + cxns[2]: getattr(cxns[2], method).return_value, + } + failed = {cxns[1]: onoz} + expected = succeeded.copy() + expected.update(failed) + assert result == expected + assert result.succeeded == succeeded + assert result.failed == failed + + @mark.parametrize("method", ALL_METHODS) + def returns_results_mapping(self, method): + cxns = [Mock(name=x) for x in ("host1", "host2", "host3")] + g = SerialGroup.from_connections(cxns) + result = getattr(g, method)("whatever", hide=True) + assert isinstance(result, GroupResult) + expected = {x: getattr(x, method).return_value for x in cxns} + assert result == expected + assert result.succeeded == expected + assert result.failed == {} class ThreadingGroup_: def setup(self): self.cxns = [Connection(x) for x in ("host1", "host2", "host3")] - self.args = ("command",) - self.kwargs = {"hide": True, "warn": True} - class run: - @patch("fabric.group.Queue") - @patch("fabric.group.ExceptionHandlingThread") - def executes_arguments_on_contents_run_via_threading( - self, Thread, Queue + @mark.parametrize("method", ALL_METHODS) + @patch("fabric.group.Queue") + @patch("fabric.group.ExceptionHandlingThread") + def executes_arguments_on_contents_run_via_threading( + self, Thread, Queue, method + ): + queue = Queue.return_value + g = ThreadingGroup.from_connections(self.cxns) + # Make sure .exception() doesn't yield truthy Mocks. Otherwise we + # end up with 'exceptions' that cause errors due to all being the + # same. + Thread.return_value.exception.return_value = None + args = ARGS_BY_METHOD[method] + kwargs = KWARGS_BY_METHOD[method] + getattr(g, method)(*args, **kwargs) + # Testing that threads were used the way we expect is mediocre but + # I honestly can't think of another good way to assert "threading + # was used & concurrency occurred"... + instantiations = [ + call( + target=thread_worker, + kwargs=dict( + cxn=cxn, + queue=queue, + method=method, + args=args, + kwargs=kwargs, + ), + ) + for cxn in self.cxns + ] + Thread.assert_has_calls(instantiations, any_order=True) + # These ought to work as by default a Mock.return_value is a + # singleton mock object + expected = len(self.cxns) + for name, got in ( + ("start", Thread.return_value.start.call_count), + ("join", Thread.return_value.join.call_count), ): - queue = Queue.return_value - g = ThreadingGroup.from_connections(self.cxns) - # Make sure .exception() doesn't yield truthy Mocks. Otherwise we - # end up with 'exceptions' that cause errors due to all being the - # same. - Thread.return_value.exception.return_value = None - g.run(*self.args, **self.kwargs) - # Testing that threads were used the way we expect is mediocre but - # I honestly can't think of another good way to assert "threading - # was used & concurrency occurred"... - instantiations = [ - call( - target=thread_worker, - kwargs=dict( - cxn=cxn, - queue=queue, - args=self.args, - kwargs=self.kwargs, - ), - ) - for cxn in self.cxns - ] - Thread.assert_has_calls(instantiations, any_order=True) - # These ought to work as by default a Mock.return_value is a - # singleton mock object - expected = len(self.cxns) - for name, got in ( - ("start", Thread.return_value.start.call_count), - ("join", Thread.return_value.join.call_count), - ): - err = ( - "Expected {} calls to ExceptionHandlingThread.{}, got {}" - ) # noqa - err = err.format(expected, name, got) - assert expected, got == err - - @patch("fabric.group.Queue") - def queue_used_to_return_results(self, Queue): - # Regular, explicit, mocks for Connections - cxns = [Mock(host=x) for x in ("host1", "host2", "host3")] - # Set up Queue with enough behavior to work / assert - queue = Queue.return_value - # Ending w/ a True will terminate a while-not-empty loop - queue.empty.side_effect = (False, False, False, True) - fakes = [(x, x.run.return_value) for x in cxns] - queue.get.side_effect = fakes[:] - # Execute & inspect results - g = ThreadingGroup.from_connections(cxns) - results = g.run(*self.args, **self.kwargs) - expected = {x: x.run.return_value for x in cxns} - assert results == expected - # Make sure queue was used as expected within worker & - # ThreadingGroup.run() - puts = [call(x) for x in fakes] - queue.put.assert_has_calls(puts, any_order=True) - assert queue.empty.called - gets = [call(block=False) for _ in cxns] - queue.get.assert_has_calls(gets) - - def bubbles_up_errors_within_threads(self): - # TODO: I feel like this is the first spot where a raw - # ThreadException might need tweaks, at least presentation-wise, - # since we're no longer dealing with truly background threads (IO - # workers and tunnels), but "middle-ground" threads the user is - # kind of expecting (and which they might expect to encounter - # failures). - cxns = [Mock(host=x) for x in ("host1", "host2", "host3")] - - class OhNoz(Exception): - pass - - onoz = OhNoz() - cxns[1].run.side_effect = onoz - g = ThreadingGroup.from_connections(cxns) - try: - g.run(*self.args, **self.kwargs) - except GroupException as e: - result = e.result - else: - assert False, "Did not raise GroupException!" - succeeded = { - cxns[0]: cxns[0].run.return_value, - cxns[2]: cxns[2].run.return_value, - } - failed = {cxns[1]: onoz} - expected = succeeded.copy() - expected.update(failed) - assert result == expected - assert result.succeeded == succeeded - assert result.failed == failed - - def returns_results_mapping(self): - # TODO: update if/when we implement ResultSet - cxns = [Mock(name=x) for x in ("host1", "host2", "host3")] - g = ThreadingGroup.from_connections(cxns) - result = g.run("whatever", hide=True) - assert isinstance(result, GroupResult) - expected = {x: x.run.return_value for x in cxns} - assert result == expected - assert result.succeeded == expected - assert result.failed == {} + err = ( + "Expected {} calls to ExceptionHandlingThread.{}, got {}" + ) # noqa + err = err.format(expected, name, got) + assert expected, got == err + + @mark.parametrize("method", ALL_METHODS) + @patch("fabric.group.Queue") + def queue_used_to_return_results(self, Queue, method): + # Regular, explicit, mocks for Connections + cxns = [Mock(host=x) for x in ("host1", "host2", "host3")] + # Set up Queue with enough behavior to work / assert + queue = Queue.return_value + # Ending w/ a True will terminate a while-not-empty loop + queue.empty.side_effect = (False, False, False, True) + fakes = [(x, getattr(x, method).return_value) for x in cxns] + queue.get.side_effect = fakes[:] + # Execute & inspect results + g = ThreadingGroup.from_connections(cxns) + results = getattr(g, method)( + *ARGS_BY_METHOD[method], **KWARGS_BY_METHOD[method] + ) + expected = {x: getattr(x, method).return_value for x in cxns} + assert results == expected + # Make sure queue was used as expected within worker & + # ThreadingGroup.run() + puts = [call(x) for x in fakes] + queue.put.assert_has_calls(puts, any_order=True) + assert queue.empty.called + gets = [call(block=False) for _ in cxns] + queue.get.assert_has_calls(gets) + + @mark.parametrize("method", ALL_METHODS) + def bubbles_up_errors_within_threads(self, method): + # TODO: I feel like this is the first spot where a raw + # ThreadException might need tweaks, at least presentation-wise, + # since we're no longer dealing with truly background threads (IO + # workers and tunnels), but "middle-ground" threads the user is + # kind of expecting (and which they might expect to encounter + # failures). + cxns = [Mock(host=x) for x in ("host1", "host2", "host3")] + + class OhNoz(Exception): + pass + + onoz = OhNoz() + getattr(cxns[1], method).side_effect = onoz + g = ThreadingGroup.from_connections(cxns) + try: + getattr(g, method)( + *ARGS_BY_METHOD[method], **KWARGS_BY_METHOD[method] + ) + except GroupException as e: + result = e.result + else: + assert False, "Did not raise GroupException!" + succeeded = { + cxns[0]: getattr(cxns[0], method).return_value, + cxns[2]: getattr(cxns[2], method).return_value, + } + failed = {cxns[1]: onoz} + expected = succeeded.copy() + expected.update(failed) + assert result == expected + assert result.succeeded == succeeded + assert result.failed == failed + + @mark.parametrize("method", ALL_METHODS) + def returns_results_mapping(self, method): + cxns = [Mock(name=x) for x in ("host1", "host2", "host3")] + g = ThreadingGroup.from_connections(cxns) + result = getattr(g, method)("whatever", hide=True) + assert isinstance(result, GroupResult) + expected = {x: getattr(x, method).return_value for x in cxns} + assert result == expected + assert result.succeeded == expected + assert result.failed == {} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/fabric-2.5.0/tests/transfer.py new/fabric-2.6.0/tests/transfer.py --- old/fabric-2.5.0/tests/transfer.py 2019-06-08 02:41:25.000000000 +0200 +++ new/fabric-2.6.0/tests/transfer.py 2021-01-19 01:50:32.000000000 +0100 @@ -3,7 +3,7 @@ except ImportError: from six import StringIO -from mock import Mock, call +from mock import Mock, call, patch from pytest_relaxed import raises from pytest import skip # noqa from paramiko import SFTPAttributes @@ -92,6 +92,24 @@ def remote_arg_cannot_be_empty_string(self, transfer): transfer.get("") + class local_arg_interpolation: + def connection_params(self, transfer): + result = transfer.get("somefile", "{user}@{host}-{port}") + expected = "/local/{}@host-22".format(transfer.connection.user) + assert result.local == expected + + def connection_params_as_dir(self, transfer): + result = transfer.get("somefile", "{host}/") + assert result.local == "/local/host/somefile" + + def remote_path_posixpath_bits(self, transfer): + result = transfer.get( + "parent/mid/leaf", "foo/{dirname}/bar/{basename}" + ) + # Recall that test harness sets remote apparent cwd as + # /remote/, thus dirname is /remote/parent/mid + assert result.local == "/local/foo/remote/parent/mid/bar/leaf" + class file_like_local_paths: "file-like local paths" @@ -133,6 +151,35 @@ transfer.get("file", local="meh", preserve_mode=False) assert not mock_os.chmod.called + class local_directory_creation: + @patch("fabric.transfer.Path") + def without_trailing_slash_means_leaf_file(self, Path, sftp_objs): + transfer, client = sftp_objs + transfer.get(remote="file", local="top/middle/leaf") + client.get.assert_called_with( + localpath="/local/top/middle/leaf", + remotepath="/remote/file", + ) + Path.assert_called_with("top/middle") + Path.return_value.mkdir.assert_called_with( + parents=True, exist_ok=True + ) + + @patch("fabric.transfer.Path") + def with_trailing_slash_means_mkdir_entire_arg( + self, Path, sftp_objs + ): + transfer, client = sftp_objs + transfer.get(remote="file", local="top/middle/leaf/") + client.get.assert_called_with( + localpath="/local/top/middle/leaf/file", + remotepath="/remote/file", + ) + Path.assert_called_with("top/middle/leaf/") + Path.return_value.mkdir.assert_called_with( + parents=True, exist_ok=True + ) + class put: class basics: def accepts_single_local_path_posarg(self, sftp_objs):