Skip to content

fix: preserve members order in ExtractorProcessor.__call__()#537

Open
terminalchai wants to merge 1 commit into
fatiando:mainfrom
terminalchai:fix/extractor-preserve-members-order
Open

fix: preserve members order in ExtractorProcessor.__call__()#537
terminalchai wants to merge 1 commit into
fatiando:mainfrom
terminalchai:fix/extractor-preserve-members-order

Conversation

@terminalchai

Copy link
Copy Markdown

Description

Closes #457.

When \members\ is supplied to \Unzip\ or \Untar, the returned list of file paths now follows the *same order as \members* instead of the arbitrary order produced by \os.walk().

Root cause

The previous implementation iterated \os.walk(extract_dir)\ and appended a file path whenever any member prefix matched. The walk order is filesystem- and OS-dependent, making the resulting list order unpredictable and unrelated to the caller-specified \members.

Fix

Replaced the single \os.walk\ loop with two code paths:

Case Behaviour
\members is None\ walk and collect all files — unchanged
\members\ provided walk once into \dict[member → full_path], then return [d[m] for m in self.members]\ to guarantee caller order

Walking the directory only once preserves the existing O(N) complexity (N = number of extracted files), as suggested by @santisoler in the issue.

Changes

  • *\pooch/processors.py* — \ExtractorProcessor.call(): refactored file-collection loop.
  • *\pooch/tests/test_processors.py* — added \ est_unpacking_members_order_preserved\ for both \Unzip\ and \Untar.

Test

34 passed, 7 deselected in 25.30s

Both \Unzip\ and \Untar\ variants of the new test confirm that requesting members in forward then reverse order produces correspondingly ordered results.

When the 'members' argument is supplied to Unzip or Untar, the returned list
of extracted file paths now follows the same order as 'members' instead of
the arbitrary order produced by os.walk().

Previously the code walked the extract directory and appended files whenever
any member prefix matched, meaning the order of results was determined by
the filesystem rather than the caller.

The fix replaces the single os.walk() loop with two code paths:
- members is None  -> walk and collect all files (behaviour unchanged)
- members provided -> walk once into a dict {member: full_path}, then
  return [dict[m] for m in self.members] to guarantee caller-specified order.
  Walking only once preserves O(N) complexity.

Add test_unpacking_members_order_preserved for both Unzip and Untar.

Closes fatiando#457
@gaoflow

gaoflow commented Jun 19, 2026

Copy link
Copy Markdown

I traced the two failing checks jobs to pooch/processors.py.

From the CI logs:

  • make check-format: Black would reformat pooch/processors.py.
  • make check-style lint: pylint reports C0103 invalid-name for loop variable m and R0912 too-many-branches for ExtractorProcessor.__call__.

A small way to fix both without changing behavior is to move the two extracted-file listing paths into private helpers and rename m to member:

diff --git a/pooch/processors.py b/pooch/processors.py
@@
     def _extract_file(self, fname, extract_dir):
         """
         This method receives an argument for the archive to extract and the
         destination path.
         MUST BE IMPLEMENTED BY CHILD CLASSES.
         """
 
+    def _all_extracted_filenames(self):
+        fnames = []
+        for path, _, files in os.walk(self.extract_dir):
+            for filename in files:
+                fnames.append(os.path.join(path, filename))
+        return fnames
+
+    def _filter_extracted_members(self):
+        extracted = {}
+        for path, _, files in os.walk(self.extract_dir):
+            for filename in files:
+                relpath = os.path.normpath(
+                    os.path.join(os.path.relpath(path, self.extract_dir), filename)
+                )
+                for member in self.members:
+                    if relpath.startswith(os.path.normpath(member)):
+                        extracted[member] = os.path.join(path, filename)
+        return [extracted[member] for member in self.members if member in extracted]
+
     def __call__(self, fname, action, pooch):
@@
         # of unzipped files, filtered by the given members list
         if self.members is None:
             # No filter: collect all extracted files in walk order
-            fnames = []
-            for path, _, files in os.walk(self.extract_dir):
-                for filename in files:
-                    fnames.append(os.path.join(path, filename))
+            fnames = self._all_extracted_filenames()
         else:
             # Build a mapping from each requested member to its extracted path.
             # Walking the directory only once keeps this O(N) in the number of
             # extracted files regardless of how many members were requested.
-            extracted = {}
-            for path, _, files in os.walk(self.extract_dir):
-                for filename in files:
-                    relpath = os.path.normpath(
-                        os.path.join(
-                            os.path.relpath(path, self.extract_dir), filename
-                        )
-                    )
-                    for m in self.members:
-                        if relpath.startswith(os.path.normpath(m)):
-                            extracted[m] = os.path.join(path, filename)
             # Return files in the same order as self.members so callers can
             # rely on the position of each file in the returned list.
-            fnames = [extracted[m] for m in self.members if m in extracted]
+            fnames = self._filter_extracted_members()

I verified this locally with:

PYTHONPATH=/tmp/pooch-style python -m black --check pooch doc
PYTHONPATH=/tmp/pooch-style python -m flake8 pooch doc
PYTHONPATH=/tmp/pooch-style python -m pylint pooch/processors.py
PATH=/tmp/pooch-style/bin:$PATH PYTHONPATH=/tmp/pooch-style burocrata --check --extension=py pooch doc
PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 PYTHONPATH=. pytest pooch/tests/test_processors.py

The processor tests pass: 41 passed.

One note: resolving the unpinned style deps today gives pylint 4.0.6, and a full-package pylint run also reports existing missing-timeout warnings in pooch/downloaders.py. I left those out because they are unrelated to this PR; the original failing CI run used pylint 4.0.5 and only reported pooch/processors.py.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ExtractorProcessor.__call__() does not retain the order given by the members filter

2 participants