Blog

  • Test a Python automation before it changes files

    A short automation script can be correct on its happy path and still select the wrong files. A dry-run function gives you a small boundary to test: it should return the planned changes and leave the directory alone.

    The example below removes the draft_ prefix from matching files. The function returns old and new names; it does not rename anything. The example uses only Python’s standard library.

    """Test a file automation rule without changing the input directory."""
    
    from pathlib import Path
    import tempfile
    import unittest
    
    
    def plan_renames(directory, prefix="draft_"):
        directory = Path(directory)
        return [(path.name, path.name.removeprefix(prefix))
                for path in sorted(directory.iterdir())
                if path.is_file() and path.name.startswith(prefix)]
    
    
    class PlanRenameTests(unittest.TestCase):
        def setUp(self):
            self.temp = tempfile.TemporaryDirectory()
            self.addCleanup(self.temp.cleanup)
            self.root = Path(self.temp.name)
    
        def test_selects_matching_files_in_order(self):
            for filename in ("draft_two.txt", "draft_one.txt", "keep.txt"):
                (self.root / filename).write_text(filename, encoding="utf-8")
            self.assertEqual(plan_renames(self.root), [
                ("draft_one.txt", "one.txt"),
                ("draft_two.txt", "two.txt"),
            ])
    
        def test_plan_does_not_write_destinations(self):
            (self.root / "draft_one.txt").write_text("one", encoding="utf-8")
            self.assertEqual(plan_renames(self.root), [("draft_one.txt", "one.txt")])
            self.assertTrue((self.root / "draft_one.txt").exists())
            self.assertFalse((self.root / "one.txt").exists())
    
    
    def main():
        suite = unittest.defaultTestLoader.loadTestsFromTestCase(PlanRenameTests)
        result = unittest.TextTestRunner(verbosity=0).run(suite)
        failures = len(result.failures) + len(result.errors)
        passed = result.testsRun - failures - len(getattr(result, "skipped", []))
        print(f"passed={passed} failures={failures}")
        if failures or not result.wasSuccessful():
            raise SystemExit(1)
    
    
    if __name__ == "__main__":
        main()
    

    The test runner also prints its summary to stderr. The final stdout line is:

    passed=2 failures=0
    

    The tests verify that the two matching files appear in sorted order. keep.txt is ignored, and the source files remain in place because the function only builds a plan.

    Turn the behavior into a test

    The two tests use temporary directories. One writes two matching files and one unrelated file, then checks selection and sorted order. The other checks that the source still exists and the destination was not created. A separate repository test also compares filenames and file bytes before and after. These checks guard the preview boundary: a preview function should not quietly become an apply function.

    Save the complete listing as python_automation_testing.py, then run it directly:

    python3 python_automation_testing.py
    

    This test checks selection, ordering and no-write behavior. It does not prove that a later rename operation is collision-safe, atomic or appropriate for a shared directory. Those are separate behaviors and need separate decisions before adding an apply step.

    If an assertion fails, unittest prints the failing test and traceback, and the script exits with status 1. The command therefore works in a shell or CI check: a red test cannot be mistaken for a successful run. The repository also checks that changing the selection rule makes the reader command fail, so the test is not merely exercising its own expected output.

    The demonstration was executed on Python 3.10.2 on macOS. It uses the string method str.removeprefix(), available in Python 3.9 and later. No external package or GUI was required.

    What this does not establish

    A passing test demonstrates this rule under the inputs in the test. It does not establish that the rule is right for every filename, nested folder, symbolic link or existing destination. Add cases for the policies that matter to your folder before connecting the plan to a write operation.

    The Python standard library documents unittest as a test framework and runner. The unittest documentation covers test cases, fixtures and runners; str.removeprefix() documents the string operation used by the example.

    For the next step, compare this plan-first check with previewing a batch rename in Python, which explains the separate collision check before applying changes.

  • Rename files with Python: preview a batch first

    Renaming one file is easy. Renaming a folder full of files is where a
    small mistake gets expensive. Start by printing the old and new names.
    Check the list, then try the changes on a copy of the folder.

    Python’s Path.rename() changes a file’s name or
    location. For a batch, build the list of changes before applying them.
    The example below removes an old_ prefix from two fictional
    files, then demonstrates what happens when a target name already
    exists.

    Run the example
    without touching your files

    Save the following as rename_walkthrough.py and run
    python3 rename_walkthrough.py (on Windows, use
    py rename_walkthrough.py if that is your Python command).
    It uses the standard library and creates its own temporary folder. It
    does not accept a folder path from you. The temporary files disappear
    when the example finishes.

    """Preview and apply a prefix-removal rename in a disposable directory."""
    
    from pathlib import Path
    import tempfile
    
    
    def plan_renames(directory, prefix="old_"):
        directory = Path(directory)
        return [(path, path.with_name(path.name[len(prefix):]))
                for path in sorted(directory.iterdir())
                if path.is_file() and path.name.startswith(prefix)]
    
    
    def apply_renames(plan):
        """Rename after a collision precheck; this precheck is not atomic."""
        collisions = [target for source, target in plan if target.exists() and target != source]
        if collisions:
            raise FileExistsError(f"refusing existing target: {collisions[0].name}")
        # ponytail: sequential rename is enough for this isolated demo; use an OS-level
        # transactional strategy if concurrent writers or production paths are needed.
        for source, target in plan:
            source.rename(target)
    
    
    def demo():
        with tempfile.TemporaryDirectory(prefix="rename-walkthrough-") as name:
            directory = Path(name)
            files = {"old_alpha.txt": "alpha\n", "old_beta.txt": "beta\n"}
            for filename, contents in files.items():
                (directory / filename).write_text(contents, encoding="utf-8")
            plan = plan_renames(directory)
            print("Preview:")
            for source, target in plan:
                print(f"{source.name} -> {target.name}")
            apply_renames(plan)
            print("After rename:")
            for path in sorted(directory.iterdir()):
                print(f"{path.name}: {path.read_text(encoding='utf-8').rstrip()}")
            collision_source = directory / "old_gamma.txt"
            collision_target = directory / "gamma.txt"
            collision_source.write_text("new source\n", encoding="utf-8")
            collision_target.write_text("existing target\n", encoding="utf-8")
            try:
                apply_renames(plan_renames(directory))
            except FileExistsError as error:
                print(f"Collision refused: {error}")
    
    
    if __name__ == "__main__":
        demo()

    Expected output:

    Preview:
    old_alpha.txt -> alpha.txt
    old_beta.txt -> beta.txt
    After rename:
    alpha.txt: alpha
    beta.txt: beta
    Collision refused: refusing existing target: gamma.txt

    What the preview does

    plan_renames() collects old and new paths. It changes
    nothing on disk. old_alpha.txt becomes
    alpha.txt; the .txt extension stays intact
    because only the leading prefix is removed. Sorting the files makes the
    preview consistent. This example handles one folder, not nested
    directories.

    apply_renames() first checks every planned target. If a
    target already exists, it raises an error before starting the batch. In
    the final example, both old_gamma.txt and
    gamma.txt stay in place.

    Why check for existing
    filenames?

    Renaming has an important platform difference: an existing
    destination file can be replaced on Unix, while Windows raises
    FileExistsError. Python documents that behavior for os.rename().
    Path.rename()
    uses the same underlying behavior.

    The precheck is useful in this isolated demonstration. It is not a
    guarantee against another program creating a target between the check
    and the rename. The batch is also not a transaction: an unexpected error
    during a later rename does not undo earlier ones. Do not adapt this
    directly to a shared or actively changing folder and assume it is
    safe.

    Before adapting it to
    your own folder

    Make a separate working copy and keep the originals. Inspect the full
    old-to-new list before applying it. Decide what should happen to
    subfolders, symbolic links and filenames that already have the desired
    prefix removed. This example does not implement those policies for
    arbitrary folders.

    For a one-off task, your file manager’s rename tools may already be
    enough. A script becomes useful when you need a repeatable naming rule
    and want to test that rule before applying it.