diff --git a/src/python/jw/pkg/lib/util.py b/src/python/jw/pkg/lib/util.py index d5cc0fd5..3c3d8c41 100644 --- a/src/python/jw/pkg/lib/util.py +++ b/src/python/jw/pkg/lib/util.py @@ -8,6 +8,7 @@ from enum import Enum, auto from typing import TYPE_CHECKING, Any, Iterable, TypeVar, cast from .base import Input, InputMode, Result +from .FileContext import FileContext from .log import DEBUG, ERR, log from .Uri import Uri @@ -15,7 +16,6 @@ if TYPE_CHECKING: from argparse import Namespace from .ExecContext import ExecContext - from .FileContext import FileContext from .ProcFilter import ProcFilter, ProcPipeline T = TypeVar('T') @@ -169,7 +169,7 @@ async def get( return await run_pipeline(await ctx.get(uri.path, *args, **kwargs), content_filter) async def copy( - src_uri: str | Iterable[str], + src: str | FileContext | Iterable[str], dst: str | FileContext, owner: str | None = None, group: str | None = None, @@ -180,9 +180,9 @@ async def copy( # Destination address / addresses ret: str | list[str] - if not isinstance(src_uri, str): + if not isinstance(src, (str, FileContext)): ret = [] - for uri in src_uri: # TODO: Group identical netlocs into one CopyContext + for uri in src: # TODO: Group identical netlocs into one CopyContext rr = await copy(uri, dst, owner, group, mode, throw, recursive) if isinstance(rr, Exception): return rr @@ -223,12 +223,13 @@ async def copy( from .CopyContext import CopyContext - async with CopyContext(src_uri, dst) as ctx: + async with CopyContext(src, dst) as ctx: ret = '' if ctx.dst.chroot else ctx.dst.root if ret: # Land the source's basename within existing destination directories - basename = os.path.basename(Uri(src_uri).path.rstrip('/')) + src_uri = src.uri if isinstance(src, FileContext) else Uri(src) + basename = os.path.basename(src_uri.path.rstrip('/')) if basename and await ctx.dst.is_dir(ret): ret += '/' + basename src_root = '' if ctx.src.chroot else ctx.src.root @@ -251,7 +252,7 @@ async def copy( except Exception as e: if throw: raise - log(ERR, f'Failed to copy {src_uri} -> {dst} ({str(e)})') + log(ERR, f'Failed to copy {src} -> {dst} ({str(e)})') return e async def get_username( # export diff --git a/test/unit/python/jw/pkg/lib/util/test.py b/test/unit/python/jw/pkg/lib/util/test.py index 001b9987..e207eed8 100644 --- a/test/unit/python/jw/pkg/lib/util/test.py +++ b/test/unit/python/jw/pkg/lib/util/test.py @@ -3,6 +3,7 @@ import os import stat import tempfile +from jw.pkg.lib.ec.Local import Local from jw.pkg.lib.util import copy def uri(path: str) -> str: @@ -99,6 +100,29 @@ async def run_tests() -> None: st = os.stat(os.path.join(dst_mode, 'sub/subsub/f3')) assert stat.S_IMODE(st.st_mode) == 0o640, st + # -- A chrooted context source: the empty address is the + # context's root, and landing into an existing directory + # takes the root's basename + os.makedirs(os.path.join(tmp, 'dst/chroot')) + src_fc = Local(uri(os.path.join(tmp, 'src/a')), chroot = True) + ret = await copy(src_fc, uri(os.path.join(tmp, 'dst/chroot')), recursive = True) + assert os.path.isfile(os.path.join(tmp, 'dst/chroot/a/f1')), ret + assert os.path.isfile(os.path.join(tmp, 'dst/chroot/a/sub/f2')), ret + + # -- A trailing slash does not change the source's name + os.makedirs(os.path.join(tmp, 'dst/slash')) + ret = await copy( + uri(os.path.join(tmp, 'src/a/')), + uri(os.path.join(tmp, 'dst/slash')), + recursive = True, + ) + assert isinstance(ret, list), ret + assert os.path.join(tmp, 'dst/slash/a/f1') in ret, ret + src_fc = Local(uri(os.path.join(tmp, 'src/a/')), chroot = True) + ret = await copy(src_fc, uri(os.path.join(tmp, 'dst/slash')), recursive = True) + assert isinstance(ret, list), ret + assert os.path.join(tmp, 'dst/slash/a/f1') in ret, ret + # -- A missing source fails loudly try: await copy(