diff --git a/blender_asset_tracer/cli/pack.py b/blender_asset_tracer/cli/pack.py old mode 100644 new mode 100755 index 6a22a14..19e713e --- a/blender_asset_tracer/cli/pack.py +++ b/blender_asset_tracer/cli/pack.py @@ -88,6 +88,15 @@ def add_parser(subparsers): help="Only pack assets that are referred to with a relative path (e.g. " "starting with `//`.", ) + parser.add_argument( + "--keep-hierarchy", + default=False, + action="store_true", + help="Preserve the full filesystem directory hierarchy in the pack. " + "All files (including the blend file) are placed at their absolute " + "path structure under the target directory. Paths in blend files are " + "rewritten to relative paths within this structure.", + ) def cli_pack(args): @@ -119,6 +128,9 @@ def create_packer( if args.relative_only: raise ValueError("S3 uploader does not support the --relative-only option") + if args.keep_hierarchy: + raise ValueError("S3 uploader does not support the --keep-hierarchy option") + packer = create_s3packer(bpath, ppath, pathlib.PurePosixPath(target)) elif ( @@ -137,6 +149,11 @@ def create_packer( "Shaman uploader does not support the --relative-only option" ) + if args.keep_hierarchy: + raise ValueError( + "Shaman uploader does not support the --keep-hierarchy option" + ) + packer = create_shamanpacker(bpath, ppath, target) elif target.lower().endswith(".zip"): @@ -146,7 +163,8 @@ def create_packer( raise ValueError("ZIP packer does not support on-the-fly compression") packer = zipped.ZipPacker( - bpath, ppath, target, noop=args.noop, relative_only=args.relative_only + bpath, ppath, target, noop=args.noop, relative_only=args.relative_only, + keep_hierarchy=args.keep_hierarchy, ) else: packer = pack.Packer( @@ -156,6 +174,7 @@ def create_packer( noop=args.noop, compress=args.compress, relative_only=args.relative_only, + keep_hierarchy=args.keep_hierarchy, ) if args.exclude: diff --git a/blender_asset_tracer/pack/__init__.py b/blender_asset_tracer/pack/__init__.py old mode 100644 new mode 100755 index 59ace62..a63d0b4 --- a/blender_asset_tracer/pack/__init__.py +++ b/blender_asset_tracer/pack/__init__.py @@ -103,6 +103,7 @@ class Packer: noop=False, compress=False, relative_only=False, + keep_hierarchy=False, ) -> None: self.blendfile = bfile self.project = project @@ -111,6 +112,7 @@ class Packer: self.noop = noop self.compress = compress self.relative_only = relative_only + self.keep_hierarchy = keep_hierarchy self._aborted = threading.Event() self._abort_lock = threading.RLock() self._abort_reason = "" @@ -241,9 +243,12 @@ class Packer: # network shares mapped to Windows drive letters back to their UNC # notation. Only resolving one but not the other (which can happen # with the abosolute() call above) can cause errors. - bfile_pp = self._target_path / bfile_path.relative_to( - bpathlib.make_absolute(self.project) - ) + if self.keep_hierarchy: + bfile_pp = self._target_path / bpathlib.strip_root(bfile_path) + else: + bfile_pp = self._target_path / bfile_path.relative_to( + bpathlib.make_absolute(self.project) + ) self._output_path = bfile_pp self._progress_cb.pack_start() @@ -335,7 +340,10 @@ class Packer: self._new_location_paths.add(asset_path) else: log.debug("%s can keep using %s", bfile_path, usage.asset_path) - asset_pp = self._target_path / asset_path.relative_to(self.project) + if self.keep_hierarchy: + asset_pp = self._target_path / bpathlib.strip_root(asset_path) + else: + asset_pp = self._target_path / asset_path.relative_to(self.project) act.new_path = asset_pp def _find_new_paths(self): @@ -346,7 +354,10 @@ class Packer: assert isinstance(act, AssetAction) relpath = bpathlib.strip_root(path) - act.new_path = pathlib.Path(self._target_path, "_outside_project", relpath) + if self.keep_hierarchy: + act.new_path = pathlib.Path(self._target_path, relpath) + else: + act.new_path = pathlib.Path(self._target_path, "_outside_project", relpath) def _group_rewrites(self) -> None: """For each blend file, collect which fields need rewriting. diff --git a/tests/test_pack.py b/tests/test_pack.py old mode 100644 new mode 100755 index e9cf5d6..a20358d --- a/tests/test_pack.py +++ b/tests/test_pack.py @@ -694,6 +694,152 @@ class ProgressTest(AbstractPackTest): ) +class KeepHierarchyPackTest(AbstractPackTest): + def hierarchy_path(self, filepath) -> Path: + """Return the keep-hierarchy path for a file: target / strip_root(abs_path).""" + return Path(self.tpath, bpathlib.strip_root(filepath)) + + def test_strategise_keep_hierarchy_no_rewrite(self): + """When all deps are in-project with relative paths, no rewriting is needed.""" + infile = self.blendfiles / "doubly_linked.blend" + + packer = pack.Packer( + infile, self.blendfiles, self.tpath, keep_hierarchy=True + ) + packer.strategise() + + packed_files = ( + "doubly_linked.blend", + "linked_cube.blend", + "basic_file.blend", + "material_textures.blend", + "textures/Bricks/brick_dotted_04-bump.jpg", + "textures/Bricks/brick_dotted_04-color.jpg", + ) + for pf in packed_files: + path = self.blendfiles / pf + act = packer._actions[path] + self.assertEqual( + pack.PathAction.KEEP_PATH, act.path_action, "for %s" % pf + ) + # In keep_hierarchy mode, paths use strip_root(abs_path) instead of + # relative_to(project). + self.assertEqual( + self.hierarchy_path(path), act.new_path, "for %s" % pf + ) + + self.assertEqual({}, self.rewrites(packer)) + self.assertEqual(len(packed_files), len(packer._actions)) + + def test_strategise_keep_hierarchy_rewrite(self): + """Deps outside the project go to target/strip_root(path), not _outside_project/.""" + ppath = self.blendfiles / "subdir" + infile = ppath / "doubly_linked_up.blend" + + packer = pack.Packer(infile, ppath, self.tpath, keep_hierarchy=True) + packer.strategise() + + # The blendfile itself should be at target / strip_root(abs_path) + act = packer._actions[infile] + self.assertEqual(pack.PathAction.KEEP_PATH, act.path_action) + self.assertEqual(self.hierarchy_path(infile), act.new_path) + + # External files should NOT be under _outside_project/ + external_files = ( + "linked_cube.blend", + "basic_file.blend", + "material_textures.blend", + "textures/Bricks/brick_dotted_04-bump.jpg", + "textures/Bricks/brick_dotted_04-color.jpg", + ) + for fn in external_files: + path = self.blendfiles / fn + act = packer._actions[path] + self.assertEqual( + pack.PathAction.FIND_NEW_LOCATION, act.path_action, "for %s" % fn + ) + # Should be at target / strip_root(abs_path), NOT target/_outside_project/... + expected = self.hierarchy_path(path) + self.assertEqual( + expected, + act.new_path, + f"\nEXPECT: {expected}\nACTUAL: {act.new_path}\nfor {fn}", + ) + + # There should be no _outside_project in any new_path + for path, action in packer._actions.items(): + self.assertNotIn( + "_outside_project", + str(action.new_path), + f"_outside_project should not appear in keep_hierarchy mode for {path}", + ) + + def test_execute_keep_hierarchy(self): + """Verify files are copied to correct hierarchy and paths are rewritten.""" + ppath = self.blendfiles / "subdir" + infile = ppath / "doubly_linked_up.blend" + + with pack.Packer(infile, ppath, self.tpath, keep_hierarchy=True) as packer: + packer.strategise() + packer.execute() + + # The blendfile should be at its hierarchy position + packed_blend = self.hierarchy_path(infile) + self.assertTrue(packed_blend.exists(), "Blendfile should be in hierarchy") + + # There should be NO _outside_project directory + self.assertFalse( + (self.tpath / "_outside_project").exists(), + "_outside_project should not exist in keep_hierarchy mode", + ) + + # Dependencies should be at their hierarchy positions + for fn in ("linked_cube.blend", "basic_file.blend", "material_textures.blend"): + dep_path = self.hierarchy_path(self.blendfiles / fn) + self.assertTrue(dep_path.exists(), "%s should be in hierarchy" % fn) + + # Verify paths were rewritten correctly in the packed blend file. + # The rewritten paths should be relative from the packed blend to + # the packed dependencies. + bfile = blendfile.open_cached(packed_blend, assert_cached=False) + libs = sorted(bfile.code_index[b"LI"]) + + # Since keep_hierarchy preserves relative positions, the relative paths + # from subdir/doubly_linked_up.blend to the parent blendfiles should + # be the same as the originals (//../linked_cube.blend etc.) + self.assertEqual(b"LILib", libs[0].id_name) + self.assertEqual(b"//../linked_cube.blend", libs[0][b"name"]) + self.assertEqual(b"LILib.002", libs[1].id_name) + self.assertEqual(b"//../material_textures.blend", libs[1][b"name"]) + + def test_execute_keep_hierarchy_no_touch_origs(self): + """Original files should not be modified.""" + ppath = self.blendfiles / "subdir" + infile = ppath / "doubly_linked_up.blend" + + with pack.Packer(infile, ppath, self.tpath, keep_hierarchy=True) as packer: + packer.strategise() + packer.execute() + + # The original file shouldn't be touched. + bfile = blendfile.open_cached(infile, assert_cached=False) + libs = sorted(bfile.code_index[b"LI"]) + self.assertEqual(b"LILib", libs[0].id_name) + self.assertEqual(b"//../linked_cube.blend", libs[0][b"name"]) + self.assertEqual(b"LILib.002", libs[1].id_name) + self.assertEqual(b"//../material_textures.blend", libs[1][b"name"]) + + def test_keep_hierarchy_output_path(self): + """output_path should use the full hierarchy path.""" + infile = self.blendfiles / "basic_file.blend" + packer = pack.Packer( + infile, self.blendfiles, self.tpath, keep_hierarchy=True + ) + packer.strategise() + + self.assertEqual(self.hierarchy_path(infile), packer.output_path) + + class AbortTest(AbstractPackTest): def test_abort_strategise(self): infile = self.blendfiles / "subdir/doubly_linked_up.blend"