3
\$\begingroup\$

This exercise comes from Automate the Boring Stuff CH 10 - second edition. The exercise is called "filling in the gaps," and the instructions are as follows:

Write a program that finds all files with a given prefix, such as spam001.txt, spam002.txt, and so on, in a single folder and locates any gaps in the numbering (such as if there is a spam001.txt and spam003.txt but no spam002.txt). Have the program rename all the later files to close this gap.

I wrote two versions of this exercise. My first version was shorter than the second version, but the problem was that all the logic was contained in a single function. So I rewrote the code to separate it into multiple functions, and the result is posted here below. I have two major concerns with the logic of this program:

  1. I believe that my logic is closer to a functional programming style, but I would like to better understand how I could approach this code from an object-oriented style
  2. I am not sure that how I chose to organize the data is the best approach. This is something that I learned from this forum the last time I posted here, and since I am somewhat of a beginner, I'm attempting to develop a better understanding of data structures and how to organize data properly.
#! python3
# fill_gaps.py -- search a single folder, find gaps in numbered files (ex. spam001.txt, spam003.txt)
# and rename later files to fill in the gaps.

import re, os, shutil
from pathlib import Path


def return_match_object(prefix: str) -> re.Match:
    # Remove numbers from prefix using a regex, then separate letters and numbers into groups
    return re.compile("(\D*)(\d*)").search(prefix)


def check_user_input(folder: str, regex_mo: re.Match) -> str | bool:
    if regex_mo is None:
        return "Invalid prefix. Prefix contains no letters or numbers."
    elif os.path.isdir(folder) == False:
        return "Invalid folder. Argument must be a valid folder path."
    else:
        return False


def create_file_list(regex_mo: re.Match) -> list:
    # Search folder for letters and add matches to a list
    letters = regex_mo.group(1)
    file_list = [i for i in os.listdir(".") if letters in i]
    return file_list


def create_number_list(file_list: list, regex_mo: re.Match) -> list:
    # search match_list for numbers and add them to a list
    number_list = []
    for file_name in file_list:
        mo = return_match_object(file_name)
        number_list.append(int(mo.group(2)))
    return number_list


def fill_gaps(
    file_list: list,
    number_list: list,
    folder: str,
    number_prefix_length: int,
    letters: str,
) -> list:
    # Rename later files to fill in gaps
    return_list = []
    for index, number in enumerate(number_list, 1):
        if index not in number_list:
            zeroes_to_add = "0" * (number_prefix_length - len(str(index)))
            suffix = Path(folder + "\\" + file_list[0]).suffix
            shutil.move(
                folder + "\\" + file_list[-1],
                folder + "\\" + letters + zeroes_to_add + str(index) + suffix,
            )
            return_list.append(
                "renamed "
                + file_list[-1]
                + " to "
                + letters
                + zeroes_to_add
                + str(index)
                + suffix
            )

    return return_list


def main() -> None:
    while True:
        folder = input("Please enter a folder to search for files\n")
        prefix = input("Please enter a prefix to search for, such as spam000\n")
        match_object = return_match_object(prefix)
        letters = match_object.group(1)
        number_prefix_length = len(match_object.group(2))
        # check for valid user input
        if type(check_user_input(folder, match_object)) == str:
            continue
        else:
            # main program execution
            os.chdir(folder)
            file_list = create_file_list(match_object)
            number_list = create_number_list(file_list, match_object)
            print(
                fill_gaps(file_list, number_list, folder, number_prefix_length, letters)
            )
            break


if __name__ == "__main__":
    main()
\$\endgroup\$
1
  • 1
    \$\begingroup\$ This is procedural programming, not functional programming \$\endgroup\$
    – Reinderien
    May 8, 2022 at 14:59

1 Answer 1

4
\$\begingroup\$

I believe that my logic is closer to a functional programming style

It isn't, and new developers often confuse this with procedural programming; yours is the latter.

Otherwise:

  • Avoid os and shutil when Path has the same functionality with more convenient representation
  • This application does not need regular expressions. Globs are more rudimentary, but fit the job. Internally they use regular expressions anyway, but it's still probably a good idea to use the more constrained interface.
  • I don't think that you should exclude prefixes that have no numbers. And I think that your definition of prefix is a little odd. The word "spam" alone should be your prefix.
  • check_user_input should not return string-or-bool; it should throw-or-not.
  • create_file_list would be well-represented by a generator function.
  • You can replace listdir with glob.
  • Consider separating your gap identification code from your renaming code.

Example

This example code addresses the above and demonstrates an object-oriented renaming class.

from pathlib import Path
from string import digits
from typing import Iterator, Iterable


class RenamingDir:
    SUFFIX_PAT = f'[{digits}]'*3 + '.txt'

    def __init__(self, directory: Path, prefix: str) -> None:
        self.directory = directory
        self.prefix = prefix

    def old_numbers(self) -> Iterator[int]:
        for file in self.directory.glob(self.prefix + self.SUFFIX_PAT):
            number = file.stem.removeprefix(self.prefix)
            yield int(number)

    def format_names(self, numbers: Iterable[int]) -> Iterator[Path]:
        for i in numbers:
            yield self.directory / f'{self.prefix}{i:03d}.txt'

    def __iter__(self) -> Iterable[tuple[
        Path,  # original path
        Path,  # new path
    ]]:
        numbers = sorted(self.old_numbers())
        for start, number in enumerate(numbers, start=1):
            if start != number:
                return zip(
                    self.format_names(numbers[start-1:]),
                    self.format_names(range(start, 1 + len(numbers)))
                )
        return ()

    def rename(self) -> None:
        for old, new in self:
            old.rename(new)


def main() -> None:
    while True:
        directory = Path(input("Please enter a directory to search for files: "))
        if directory.is_dir():
            break

    prefix = input("Please enter a prefix to search for, such as spam: ")
    RenamingDir(directory, prefix).rename()


if __name__ == '__main__':
    main()
\$\endgroup\$
7
  • \$\begingroup\$ Hi, and thank you for taking the time to respond. I have a couple of follow up questions for you. 1. In your code example, am I to understand that your decision to use a class is the primary thing that makes it an example of OOP? 2. when you say that check_user_input should "throw-or-not," do you mean that such a function should be built for error handling? \$\endgroup\$
    – Ramza
    May 8, 2022 at 19:43
  • 2
    \$\begingroup\$ 1. Basically, yes. It's easy to keep this as procedural-only, but you were curious about what OOP looks like, and this is one example. \$\endgroup\$
    – Reinderien
    May 8, 2022 at 20:17
  • 2
    \$\begingroup\$ 2. You can indeed build an error-handling function if you want. Inner validation functions should not print, and may return True/False but exceptions are generally a better idea. \$\endgroup\$
    – Reinderien
    May 8, 2022 at 20:19
  • \$\begingroup\$ Can you help me understand the purpose of the function rename(). It looks like it iterates through key/value pairs with the line for old, new in self:. If the latter is true, is the line old.rename(new) meant to rename the keys old with the values new? I am struggling to understand what is happening with this function, what data it takes in, and what its execution is. \$\endgroup\$
    – Ramza
    May 8, 2022 at 23:26
  • \$\begingroup\$ The class itself is iterable via __iter__. When you say for .. in self, it calls __iter__ which yields an iterable of pairs (old, new). Both elements are Path. Path.rename renames the given path to the new path. \$\endgroup\$
    – Reinderien
    May 8, 2022 at 23:32

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.