diff --git a/src/python/jw/pkg/lib/util.py b/src/python/jw/pkg/lib/util.py index 3c3d8c41..04690684 100644 --- a/src/python/jw/pkg/lib/util.py +++ b/src/python/jw/pkg/lib/util.py @@ -176,6 +176,7 @@ async def copy( mode: int | None = None, throw: bool = True, recursive: bool = False, + parents: bool = False, ) -> Exception | str | list[str]: # Destination address / addresses @@ -183,7 +184,7 @@ async def copy( if not isinstance(src, (str, FileContext)): ret = [] for uri in src: # TODO: Group identical netlocs into one CopyContext - rr = await copy(uri, dst, owner, group, mode, throw, recursive) + rr = await copy(uri, dst, owner, group, mode, throw, recursive, parents) if isinstance(rr, Exception): return rr if isinstance(rr, list): @@ -225,10 +226,32 @@ async def copy( async with CopyContext(src, dst) as ctx: + src_uri = src.uri if isinstance(src, FileContext) else Uri(src) ret = '' if ctx.dst.chroot else ctx.dst.root - if ret: + if parents: + # -- cp --parents: the source lands at its source path + # below the destination root, each parent level + # typed by the source-side node it mirrors + parts = [p for p in src_uri.path.split('/') if p] + if parts: + src_path = '/'.join(parts) + base = ret + ret = f'{base}/{src_path}' if base else src_path + s_cur = '' + for part in parts[:-1]: + s_cur = f'{s_cur}/{part}' if s_cur else part + d_cur = f'{base}/{s_cur}' if base else s_cur + if await ctx.dst.is_dir(d_cur): + continue + level_ctx = FileContext.create( + f'{ctx.src.uri.scheme_plus_authority}/{s_cur}', + chroot = True, + ) + async with level_ctx: + ftype = (await level_ctx.stat('')).ftype + await ctx.dst.mkdir(d_cur, ftype = ftype) + elif ret: # Land the source's basename within existing destination directories - 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 diff --git a/test/unit/python/jw/pkg/lib/util/test.py b/test/unit/python/jw/pkg/lib/util/test.py index e207eed8..0520ee3b 100644 --- a/test/unit/python/jw/pkg/lib/util/test.py +++ b/test/unit/python/jw/pkg/lib/util/test.py @@ -123,6 +123,48 @@ async def run_tests() -> None: assert isinstance(ret, list), ret assert os.path.join(tmp, 'dst/slash/a/f1') in ret, ret + # -- parents lands the object at its source path below the + # destination root, like cp --parents + dst_parents = os.path.join(tmp, 'dst/parents') + os.makedirs(dst_parents) + src_single = os.path.join(tmp, 'src/single') + ret = await copy(uri(src_single), uri(dst_parents), parents = True) + assert ret == dst_parents + src_single, ret + with open(dst_parents + src_single) as f: + assert f.read() == 'solo\n' + + # -- parents on a tree lands the tree at its source path + dst_ptree = os.path.join(tmp, 'dst/ptree') + os.makedirs(dst_ptree) + src_tree = os.path.join(tmp, 'src/a') + ret = await copy( + uri(src_tree), uri(dst_ptree), recursive = True, parents = True + ) + assert isinstance(ret, list), ret + expected = dst_ptree + src_tree + assert sorted(ret) == sorted([ + os.path.join(expected, 'f1'), + os.path.join(expected, 'sub/f2'), + os.path.join(expected, 'sub/subsub/f3'), + ]), ret + assert os.path.isdir(os.path.join(expected, 'empty')), ret + + # -- a chrooted context source lands at its URI path + dst_pchroot = os.path.join(tmp, 'dst/pchroot') + os.makedirs(dst_pchroot) + src_fc = Local(uri(src_tree), chroot = True) + ret = await copy(src_fc, uri(dst_pchroot), recursive = True, parents = True) + assert isinstance(ret, list), ret + assert os.path.isfile(os.path.join(dst_pchroot + src_tree, 'f1')), ret + + # -- re-copying with parents replaces content, not layout + with open(dst_parents + src_single, 'w') as f: + f.write('replaced\n') + ret = await copy(uri(src_single), uri(dst_parents), parents = True) + assert ret == dst_parents + src_single, ret + with open(dst_parents + src_single) as f: + assert f.read() == 'solo\n' + # -- A missing source fails loudly try: await copy(