From 062910b7ed249ab5190d259dcb8f44010d340360 Mon Sep 17 00:00:00 2001 From: Robin Ekman Date: Fri, 29 Oct 2021 18:46:36 +0100 Subject: [PATCH 1/7] Add code to link remote and local paths. --- setup.py | 1 + stig/commands/base/config.py | 49 +++++++++++++++++++++++++++++ stig/commands/base/torrent.py | 2 ++ stig/commands/cli/config.py | 3 ++ stig/commands/tui/config.py | 3 ++ stig/objects.py | 2 ++ stig/utils/_pathtranslator.py | 59 +++++++++++++++++++++++++++++++++++ 7 files changed, 119 insertions(+) create mode 100644 stig/utils/_pathtranslator.py diff --git a/setup.py b/setup.py index 01032ef9..ba852867 100644 --- a/setup.py +++ b/setup.py @@ -50,6 +50,7 @@ 'pyxdg', 'blinker', 'natsort', + 'bidict', ], extras_require = { 'setproctitle': ['setproctitle'], diff --git a/stig/commands/base/config.py b/stig/commands/base/config.py index 335662c0..eef2385e 100644 --- a/stig/commands/base/config.py +++ b/stig/commands/base/config.py @@ -696,3 +696,52 @@ def completion_candidates_posargs(cls, args): return candidates.Candidates(cands, label='Direction', curarg_seps=(',',)) elif posargs.curarg_index >= 3: return candidates.torrent_filter(args.curarg) + +class LinkPathCmd(metaclass = CommandMeta): + name = 'link' + aliases = () + category = 'configuration' + provides = set() + description = textwrap.dedent( + """ + Links remote and local paths. Call with no arguments to list existing links. + + Example use case: a remote daemon downloads to + /var/transmission/downloads/, but this is mounted on the local client + as /mnt/nfs/seedbox/. Moving torrents with the move requires the remote + path, but tab completion works with the local filesystem. Path links + provide a transparent translation layer. + + Links must be bijective; as a consequence the --force flag must be + passed to ensure that clobbering is intentional. + """) + usage = ('link [] REMOTE LOCAL',) + examples = ('link /var/transmission/downloads/ /mnt/nfs/seedbox/', + 'link') + argspecs = ( + {'names': ('--force', '-f'), + 'description': 'Overwrite existing links involving REMOTE or LOCAL.'}, + {'names': ('REMOTE',), + 'nargs': '?', + 'description': 'Remote path to link'}, + {'names': ('LOCAL',), + 'nargs': '?', + 'description': 'Local path to link'} + ) + + DUMP_WIDTH = 79 + + async def run(self, REMOTE, LOCAL, force): + log.debug("%s %s %s" % (REMOTE, LOCAL, force) ) + if not (REMOTE or LOCAL): + log.debug('Listing links') + for link in objects.pathtranslator.links.items(): + self.info('%s <-> %s' % link) + return + elif not (REMOTE and LOCAL): + self.error("Must specify both REMOTE and LOCAL paths.") + return + try: + objects.pathtranslator.link(REMOTE, LOCAL, force) + except Exception as e: + raise CmdError(e) diff --git a/stig/commands/base/torrent.py b/stig/commands/base/torrent.py index 85954509..56bc2d06 100644 --- a/stig/commands/base/torrent.py +++ b/stig/commands/base/torrent.py @@ -11,6 +11,7 @@ import asyncio import os +from pathlib import Path from . import _mixin as mixin from .. import CmdError, CommandMeta @@ -250,6 +251,7 @@ async def run(self, TORRENT_FILTER, PATH): except ValueError as e: raise CmdError(e) else: + PATH = objects.pathtranslator.to_remote(Path(PATH)).as_posix() response = await self.make_request(objects.srvapi.torrent.move(tfilter, PATH), polling_frenzy=True) if not response.success: diff --git a/stig/commands/cli/config.py b/stig/commands/cli/config.py index c0ac6a71..754d769c 100644 --- a/stig/commands/cli/config.py +++ b/stig/commands/cli/config.py @@ -65,3 +65,6 @@ async def _show_limits(self, TORRENT_FILTER, directions): def _output(self, msg): print(msg) + +class LinkPathCmd(base.LinkPathCmd): + provides = {'cli'} diff --git a/stig/commands/tui/config.py b/stig/commands/tui/config.py index 916613b3..2156f9cc 100644 --- a/stig/commands/tui/config.py +++ b/stig/commands/tui/config.py @@ -74,3 +74,6 @@ async def _show_limits(self, TORRENT_FILTER, directions): def _output(self, msg): self.info(msg) + +class LinkPathCmd(base.LinkPathCmd): + provides = {'tui'} diff --git a/stig/objects.py b/stig/objects.py index 9dcfca66..ace3781a 100644 --- a/stig/objects.py +++ b/stig/objects.py @@ -18,6 +18,7 @@ from .client import API from .commands import CommandManager from .helpmgr import HelpManager +from .utils._pathtranslator import PathTranslator log = logging.make_logger() @@ -26,6 +27,7 @@ localcfg = settings.Settings() settings.init_defaults(localcfg) +pathtranslator = PathTranslator() srvapi = API(interval=localcfg['tui.poll']) diff --git a/stig/utils/_pathtranslator.py b/stig/utils/_pathtranslator.py new file mode 100644 index 00000000..f28e316b --- /dev/null +++ b/stig/utils/_pathtranslator.py @@ -0,0 +1,59 @@ +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details +# http://www.gnu.org/licenses/gpl-3.0.txt + +from .. import __appname__, __version__, objects +from pathlib import Path +from bidict import bidict, ValueDuplicationError + +from ..logging import make_logger # isort:skip + +log = make_logger(__name__) + + +class PathTranslator: + def __init__(self): + # links: a dict of (remote_root, local_root) pairs + self.links = bidict() + + def link(self, remote, local, force=False): + remote = Path(remote) + local = Path(local) + if not local.exists(): + raise ValueError("Local path %s does not exist." % local) + if force: + self.links.forceput(remote, local) + if remote in self.links.keys(): + raise ValueError( + "Remote path %s is already linked to local path %s." + % (remote, self.links[remote]) + ) + try: + self.links[remote] = local + except ValueDuplicationError: + raise ValueError( + "Local path %s is already linked to remote path %s." + % (local, self.links[remote]) + ) + log.debug("Linked remote path %s to local path %s" % (remote, local)) + + def _translate_path(self, path, links): + for roots in links.items(): + try: + return roots[1] / path.relative_to(roots[0]) + except ValueError: + pass + return path + + def to_local(self, p_rem): + return self._translate_path(p_rem, self.links) + + def to_remote(self, p_loc): + return self._translate_path(p_loc, self.links.inv) From 3bd5dba7d86f3a8a64e76349d70f536322ba01b8 Mon Sep 17 00:00:00 2001 From: Robin Ekman Date: Fri, 29 Oct 2021 18:56:32 +0100 Subject: [PATCH 2/7] links: only allow absolute paths --- stig/utils/_pathtranslator.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/stig/utils/_pathtranslator.py b/stig/utils/_pathtranslator.py index f28e316b..bd24aeed 100644 --- a/stig/utils/_pathtranslator.py +++ b/stig/utils/_pathtranslator.py @@ -28,6 +28,10 @@ def link(self, remote, local, force=False): local = Path(local) if not local.exists(): raise ValueError("Local path %s does not exist." % local) + if not local.is_absolute(): + raise ValueError("Local path %s must be absolute." % local) + if not remote.is_absolute(): + raise ValueError("Local path %s must be absolute." % remote) if force: self.links.forceput(remote, local) if remote in self.links.keys(): From f32b60ee9991038efbe111004e497e628fef304e Mon Sep 17 00:00:00 2001 From: Robin Ekman Date: Tue, 2 Nov 2021 11:50:03 +0000 Subject: [PATCH 3/7] links: fix a typo in an error message --- stig/utils/_pathtranslator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stig/utils/_pathtranslator.py b/stig/utils/_pathtranslator.py index bd24aeed..7f4259eb 100644 --- a/stig/utils/_pathtranslator.py +++ b/stig/utils/_pathtranslator.py @@ -31,7 +31,7 @@ def link(self, remote, local, force=False): if not local.is_absolute(): raise ValueError("Local path %s must be absolute." % local) if not remote.is_absolute(): - raise ValueError("Local path %s must be absolute." % remote) + raise ValueError("Remote path %s must be absolute." % remote) if force: self.links.forceput(remote, local) if remote in self.links.keys(): From dc5962c43af86565b9d8a8efdd6c3140384f580d Mon Sep 17 00:00:00 2001 From: Robin Ekman Date: Tue, 2 Nov 2021 11:48:10 +0000 Subject: [PATCH 4/7] links: convert path argument to translation to pathlib.Path raise ValueError if not possible --- stig/utils/_pathtranslator.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/stig/utils/_pathtranslator.py b/stig/utils/_pathtranslator.py index 7f4259eb..70b2c3f2 100644 --- a/stig/utils/_pathtranslator.py +++ b/stig/utils/_pathtranslator.py @@ -49,6 +49,10 @@ def link(self, remote, local, force=False): log.debug("Linked remote path %s to local path %s" % (remote, local)) def _translate_path(self, path, links): + try: + path = Path(path) + except Exception: + raise ValueError("Could not convert %s to Path" % path) for roots in links.items(): try: return roots[1] / path.relative_to(roots[0]) From c1f6312af75c905b3c32bfc0665d81d8863908ff Mon Sep 17 00:00:00 2001 From: Robin Ekman Date: Sat, 9 Apr 2022 15:43:25 +0200 Subject: [PATCH 5/7] links: use links when adding torrents from cli When adding a torrent like 'stig add my.torrent --path /path/to/directory' links were not respected. That is, stig would always interpret /path/to/directory as a local path, even if it should be translated to a remote path before being sent to transmission. --- stig/commands/base/torrent.py | 1 + 1 file changed, 1 insertion(+) diff --git a/stig/commands/base/torrent.py b/stig/commands/base/torrent.py index 56bc2d06..21e6dc1c 100644 --- a/stig/commands/base/torrent.py +++ b/stig/commands/base/torrent.py @@ -49,6 +49,7 @@ class AddTorrentsCmdbase(metaclass=CommandMeta): async def run(self, TORRENT, stopped, path): success = True force_torrentlist_update = False + path = objects.pathtranslator.to_remote(path) for source in TORRENT: source_abs_path = self.make_path_absolute(source) response = await self.make_request(objects.srvapi.torrent.add(source_abs_path, From a1660b964e518c761c428092213d4e2a6aed5b2c Mon Sep 17 00:00:00 2001 From: Robin Ekman Date: Thu, 21 Apr 2022 02:34:55 +0200 Subject: [PATCH 6/7] add: check if path is not None before translating --- stig/commands/base/torrent.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/stig/commands/base/torrent.py b/stig/commands/base/torrent.py index 21e6dc1c..e06065aa 100644 --- a/stig/commands/base/torrent.py +++ b/stig/commands/base/torrent.py @@ -49,7 +49,8 @@ class AddTorrentsCmdbase(metaclass=CommandMeta): async def run(self, TORRENT, stopped, path): success = True force_torrentlist_update = False - path = objects.pathtranslator.to_remote(path) + if path: + path = objects.pathtranslator.to_remote(path) for source in TORRENT: source_abs_path = self.make_path_absolute(source) response = await self.make_request(objects.srvapi.torrent.add(source_abs_path, From 6dad97498b2e6f25fc7b97a9d1b7286d5cd98b94 Mon Sep 17 00:00:00 2001 From: Robin Ekman Date: Thu, 21 Apr 2022 20:54:53 +0200 Subject: [PATCH 7/7] links: linting --- stig/commands/base/config.py | 11 +++++------ stig/utils/_pathtranslator.py | 3 +-- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/stig/commands/base/config.py b/stig/commands/base/config.py index eef2385e..827fcb28 100644 --- a/stig/commands/base/config.py +++ b/stig/commands/base/config.py @@ -697,14 +697,13 @@ def completion_candidates_posargs(cls, args): elif posargs.curarg_index >= 3: return candidates.torrent_filter(args.curarg) -class LinkPathCmd(metaclass = CommandMeta): +class LinkPathCmd(metaclass=CommandMeta): name = 'link' aliases = () category = 'configuration' provides = set() description = textwrap.dedent( - """ - Links remote and local paths. Call with no arguments to list existing links. + """Links remote and local paths. Call with no arguments to list existing links. Example use case: a remote daemon downloads to /var/transmission/downloads/, but this is mounted on the local client @@ -714,10 +713,10 @@ class LinkPathCmd(metaclass = CommandMeta): Links must be bijective; as a consequence the --force flag must be passed to ensure that clobbering is intentional. - """) + """) usage = ('link [] REMOTE LOCAL',) examples = ('link /var/transmission/downloads/ /mnt/nfs/seedbox/', - 'link') + 'link') argspecs = ( {'names': ('--force', '-f'), 'description': 'Overwrite existing links involving REMOTE or LOCAL.'}, @@ -732,7 +731,7 @@ class LinkPathCmd(metaclass = CommandMeta): DUMP_WIDTH = 79 async def run(self, REMOTE, LOCAL, force): - log.debug("%s %s %s" % (REMOTE, LOCAL, force) ) + log.debug("%s %s %s" % (REMOTE, LOCAL, force)) if not (REMOTE or LOCAL): log.debug('Listing links') for link in objects.pathtranslator.links.items(): diff --git a/stig/utils/_pathtranslator.py b/stig/utils/_pathtranslator.py index 70b2c3f2..9417ed56 100644 --- a/stig/utils/_pathtranslator.py +++ b/stig/utils/_pathtranslator.py @@ -9,7 +9,6 @@ # GNU General Public License for more details # http://www.gnu.org/licenses/gpl-3.0.txt -from .. import __appname__, __version__, objects from pathlib import Path from bidict import bidict, ValueDuplicationError @@ -25,7 +24,7 @@ def __init__(self): def link(self, remote, local, force=False): remote = Path(remote) - local = Path(local) + local = Path(local) if not local.exists(): raise ValueError("Local path %s does not exist." % local) if not local.is_absolute():