9
\$\begingroup\$

Powershell has the Split-Path cmdlet which allows you to extract the Parent and Leaf of a file. In PS version 6, you can extract the extension, with the -Extension switch. Why this wasn't a feature of PS version 4 and greater I don't know, so I decided to write my own version in Python.

PathUtilities:

# PathUtilities.py

def parse_file_path(file_path : str):

    """
    The function a user must call if they want to parse a file path.

    It accepts, validates, and returns a tuple containing 
    the elements (parent, leaf, and extension)
    of the path.

    The path doesn't haven't exist.

    Args:
        file_path : str - A string representing a file path.

    Returns:
        A tuple containing all the parsed elements.
    """

    _determine_if_the_drive_letter_is_valid(file_path=file_path)
    _determine_if_second_element_is_valid(file_path=file_path)
    _determine_if_the_string_contains_forbidden_characters(string_to_validate = file_path[2:])
    return _construct_path_tuple(file_path=file_path) 


def _determine_if_the_drive_letter_is_valid(file_path : str):

    """
    Determines if the drive letter is a letter.

    Raises a TypeError if the first letter is invalid.

    Args:
        file_path - see parse_file_path function.

    Returns:
        None
    """

    drive_letter = file_path[:1]
    if not drive_letter.isalpha():
        raise TypeError("Drive Letter is invalid.")


def _determine_if_second_element_is_valid(file_path : str):

    """
    Determine if the second element in the path is a :

    Raises a ValueError if the second element is invalid.

    Args:
        file_path - see parse_file_path function.

    Returns:
        None
    """

    valid_element = ":"
    second_element = file_path[1]
    if second_element != valid_element:
        raise ValueError("Invalid characters in path.")

def _determine_if_the_string_contains_forbidden_characters(*additional_characters, string_to_validate : str):

    """
    Determine if the string contains forbidden elements.

    Raises a ValueError if any forbidden character is found.

    Args:
        string_to_validate : str - The string we're going to test.
        *additional_characters - To make the function reusable we accept additional elements to be tested
                             in addition to the list of forbidden characters.
    """

    # Contains all the forbidden characters in Windows paths.
    # Note the 4 slashes, because paths use \\ to seperate folders and drives, we have to validate
    # if the user entered additional slashes, two slashes is fine, but more are forbidden.
    # Example: C:\\Users\\Sveta\\\\\emp_list.txt - invalid.
    forbidden_characters = ["<", ">", ":", "/", '"', "|", "?", "*"," ", "\\\\"]

    for forbidden_character in forbidden_characters:
        if forbidden_character in string_to_validate:
            raise ValueError("Invalid characters in path.")

    if additional_characters:
        for character in additional_characters:
            if character in string_to_validate:
                raise ValueError("Invalid characters in path.")

def _parse_extension(string_to_parse : str):

    """
    Split the file path on the period, validate, and return the extension with the period.

    Raises a ValueError if the extension contains forbidden characters.

    Args:
        file_path - see parse_file_path function

    Returns:
        the extension of the file or None, if no file extension is present. 
    """

    # file and folder names are allowed to have periods, so we split on that,
    # but we're only interested in the last element, the extension.
    split_extension = string_to_parse.split(".")[-1]

    # if the string doesn't have a file extension, we return None. 
    if len(split_extension) == len(string_to_parse):
        return None

    _determine_if_the_string_contains_forbidden_characters("\\", string_to_validate = split_extension)
    return ".{0}".format(split_extension)


def _parse_leaf(file_path : str):

    """
    Split the file path using \\ and returns the last element.

    Args:
        file_path - see parse_file_path function.

    Returns:
        the last element of the file path, in this case the filename with extension.
    """

    delimiter = "\\"
    return file_path.split(delimiter)[-1]

def _parse_parent(file_path : str,  leaf: str):

    """
    Take the leaf, and use it as a delimiter to extract the parent directory, without the trailing slash. 

    Args:
        file_path - see parse_file_path function.
        leaf : str - the leaf of our file path.

    Returns:
        The first element in our list, our parent directory. 

    """

    delimiter = "\\{0}".format(leaf)
    return file_path.split(delimiter)[0]


def _construct_path_tuple(file_path : str):

    """
    Constructs a tuple representing the elements of the file path.

    Args:
        file_path - see parse_file_path function.

    Returns:
        A tuple that contains the file elements. 
    """

    leaf = _parse_leaf(file_path = file_path)
    extension = _parse_extension(string_to_parse = leaf)
    parent = _parse_parent(file_path = file_path, leaf=leaf)
    return (parent, leaf, extension)

Main:

import pathutilities


def main():

    try:
        path = pathutilities.parse_file_path("C:\\Users\\Sveta\\Employee\\emp_list.txt")
    except (TypeError, ValueError) as error:
        print(error)
    else:
        parent, leaf, extension = path
        print(parent, leaf, extension)
        # Output:
        # C:\Users\Sveta\Employee emp_list.txt .txt
if __name__ == '__main__':
    main()
\$\endgroup\$
  • 1
    \$\begingroup\$ Are you aware of the os.path module? \$\endgroup\$ – 200_success May 6 at 17:15
  • 3
    \$\begingroup\$ @200_success Since this is Python 3, using pathlib can result in a cleaner interface. \$\endgroup\$ – Peilonrayz May 6 at 18:04
  • \$\begingroup\$ @200_success - I was aware of it, but I was unaware of the features that allow you to access the parent, leaf, and suffix. Meh, I consider this a learning exercise in writing python code. \$\endgroup\$ – user200189 May 7 at 22:29
  • \$\begingroup\$ The second to last line in _parse_extension is invalid Python code. When trying to source the file, Python yields a SyntaxError in line 155. \$\endgroup\$ – Konrad Rudolph May 8 at 9:33
  • \$\begingroup\$ @KonradRudolph - This is strange, I'm unable to reproduce this error. My code runs and the correct output is produced. \$\endgroup\$ – user200189 May 8 at 14:12
10
\$\begingroup\$
  1. PurePath.drive allows UNC drives, but yours doesn't.
  2. pathlib implements everything you need. Which is available from Python 3.4.

Note: This code doesn't require the drive to be set. That can be achieved by checking PurePath.drive if it is needed.

from pathlib import PurePath


def parse_file_path(path):
    path = PurePath(path)
    return str(path.parent), path.name, path.suffix or None

If you're not running 3.4+ then the equivalent in os.path, is pretty simple too. But IMO is pretty unsightly.

You can also check the drive through os.path.splitdrive.

import os.path


def parse_file_path(path):
    name = os.path.basename(path)
    return (
        os.path.dirname(path),
        name,
        os.path.splitext(name)[1] or None
    )
\$\endgroup\$
  • 2
    \$\begingroup\$ So far so good for a better alternative. But it's not much of a code review. And OP could definitely benefit from a code review. \$\endgroup\$ – Konrad Rudolph May 6 at 22:25
  • \$\begingroup\$ @KonradRudolph What do you feel is lacking in my answer? I may be able to provide it if I know what 'it' is. \$\endgroup\$ – Peilonrayz May 6 at 22:26
  • \$\begingroup\$ Well it’s not a code review. As in, you provide no feedback for OP’s code. \$\endgroup\$ – Konrad Rudolph May 7 at 10:11
  • 1
    \$\begingroup\$ Clearly we have a difference of opinion of what "no feedback" is. I've clearly shown how the OP's parse_file_path can be simplified. Since it seems you're unable to explain your POV, and for some unknown reason limit your explanations to ambiguous statements such as "this isn't a code review", then I cannot help you. Please post an answer which fills in the aspects you find lacking in mine. \$\endgroup\$ – Peilonrayz May 7 at 10:24
  • \$\begingroup\$ I have to admit that I’m a bit puzzled as to where the issue is. A review gives specific feedback about the code under discussion. Your answer doesn’t do this at all. Instead, it suggests an alternative. This is of course entirely valid, but it’s not a review, and it’s not feedback. Examples of specific feedback for OP’s code would be: “Well done on using type hints for the method arguments; don’t forget to also annotate return types”, or “your method names are too verbose”; or “ _parse_extension contains invalid syntax”. \$\endgroup\$ – Konrad Rudolph May 7 at 10:58
4
\$\begingroup\$

Apart from the other alternative of using pathlib

Here are some comments on the code:

rsplit

strings have also a method [`str.rsplit`][1] which start at the right side of the string.

So extracting the parent and leaf is as simple as:

parent, leaf = path.rsplit(delimiter, maxsplit=1)

delimiter

Instead of hardcoding the delimiter in a few places, you can define it as a module constant at the top of the file:

DELIMITER = "\\"

functions

I would bundle all the checks to validate a path in 1 function. This way you can use tuple unpacking:

def validate_path(path: str):
    drive_letter, colon, *rest = path
    if not drive_letter.isalpha():
        raise TypeError("Drive Letter is invalid.")

    if not colon == ":" or contains_forbidden_characters(rest):
        raise ValueError("Invalid characters in path.")

contains_forbidden_characters

_determine_if_the_string_contains_forbidden_characters is a ridiculously long name

There is also no reason for the initial _. why would you want to stop your module users from checking for forbidden strings in a path?

This method can also be done in a different way. Instead of checking each individual forbidden character individually, you can use sets to test for containment:

def contains_forbidden_characters(
    path: str, addditional_forbidden_characters=(), forbidden_substrings=()
):
    forbidden_characters = set(r"""<>:/"|?* """)
    if set(path) & forbidden_characters.union(
        addditional_forbidden_characters
    ):
        return True

    forbidden_substrings = {r"\\"}.union(forbidden_substrings)
    for element in forbidden_substrings:
        if element in path:
            return True

In the last part, the for-loop can be replaced by using any:

return any(element in path for element in forbidden_substrings)

Here I also used sets to add the possible additional forbidden characters and substrings. I represented "\\\\" as r"\\"

split_path

My version of split_path expects a valid path string. It is up to the caller to make sure of this.

def split_path(path, delimiter=DELIMITER):
    parent, leaf = path.rsplit(delimiter, maxsplit=1)
    *filename, extension = leaf.rsplit(".", maxsplit=1)

    if not filename:
        extension = None
    return parent, leaf, extension

using rsplit twice, this method is a lot simpler and clearer.

Putting it together

def test_path(path):
    validate_path(path)
    parent, leaf, extension = split_path(path)
    if contains_forbidden_characters(
        leaf, addditional_forbidden_characters="\\"
    ):
        raise ValueError("Invalid characters in path.")
    return parent, leaf, extension
\$\endgroup\$
  • \$\begingroup\$ “why would you want to stop your module users from checking for forbidden strings in a path?” — Because it’s an implementation detail that’s not part of the module’s API. It may be there now, but it may not be in the module tomorrow. By contrast, once it’s part of the API it effectively can’t be removed without making lots of users angry. I agree with the rest of the feedback, especially the verbose method names. \$\endgroup\$ – Konrad Rudolph May 7 at 11:01
  • \$\begingroup\$ why would you want to stop your module users from checking for forbidden strings in a path? There is no guarantee that users will check any path that they want to be parsed. Rather than leave it up to the user of my API, I do it for them for free :D. I can make the method public as well as using it when I call parse_file_path. What would be a more valid method name instead of contains_forbidden_characters? I admit that the method name is my attempt at writing clean code with clear method names. \$\endgroup\$ – user200189 May 7 at 22:34
  • \$\begingroup\$ There is no guarantee that users will check any path that they want to be parsed. Rather than leave it up to the user of my API, I do it for them for free Which is why I offered the 3 methods: 1 to verify the path, one to split it, which expects a valid path, and a 3rd, which checks and splits the path. \$\endgroup\$ – Maarten Fabré May 8 at 7:52
  • \$\begingroup\$ contains_forbidden_characters is already a shortened version of _determine_if_the_string_contains_forbidden_characters. It is still long, but not ridiculously so \$\endgroup\$ – Maarten Fabré May 8 at 7:53

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy