11
\$\begingroup\$

I've written my own local "source control". Rather than using a commit-based system, when you're ready to release a version, you can run a command which will create a .zip copy of your source code, and it's saved to a versions folder.


How does it work?

Each "project" written with this has a specific file structure that looks like the below. A file structure like this is required in order for the source control to work.

/[Project Name]
    /source
        ...

    /versions
        ...

    /info
        changelog.txt
        readme.txt

There are also three commands that are used. new, push, and changedir. Each command argument is double colon, ::, separated, and look something like the following:

  • new::[project Name]::[project info] - Create a new project.
  • push::[version name]::[version info] - Push a new version to the versions folder as a .zip file.
  • changedir::[directory] - Change to a new directory.

Concerns

There are a few things I'm concerned about here.

  • Have I designed this in a clear intuitive way? The current design right now feels clunky and hard to use.
  • Am I over-documenting things? While I love documentation, if this is too much, tips on documentation would be appreciated.
  • Am I correctly handling errors?

import os
import re
import shutil


def command_new_project(project_name: str, project_description: str):
    """Generate a new project.

    This function generates a new project. A
    project structure looks like this:

        /[project name]
            /source
                ...

            /versions
                ...

            /info
                readme.txt
                changelog.txt

    Keyword arguments:
    project_name        -- The name of the project.
    project_description -- A brief description of the project.
    """
    os.makedirs("./{0}".format(project_name))
    os.chdir("./{0}".format(project_name))

    os.makedirs("./source")
    os.makedirs("./versions")
    os.makedirs("./info")

    with \
        open("./info/readme.txt", "w+") as readme, \
        open("./info/changelog.txt", "w+") as changelog:
        readme.write(project_description)


def command_push_version(version_number: str, version_description: str):
    """Push a new version.

    This function pushes a the contents of the
    ./[project name]/source folder to a new folder
    in ./[project name]/versions named with the
    version number.

    Keyword arguments:
    version_number      -- The version number. Must contain valid characters for folder names.
    version_description -- A brief description of the changes in the version to be written to the changelog.
    """
    if re.match("[a-zA-Z0-9_\-\s\.]+", version_number):
        with open("./info/changelog.txt", "w") as changelog:
            shutil.make_archive("./versions/{0}".format(version_number), format="zip", root_dir="./source")
            changelog.write("\nVersion {0}".format(version_number))
            changelog.write(version_description)


def command_change_directory(directory: str):
    """Change to a new directory.

    This function allows the user to navigate to
    another directory.

    Keyword arguments:
    directory -- The directory to navigate to.
    """
    try:
        os.chdir(directory)
    except FileNotFoundError:
        pass


def validate_user_input(valid_command, valid_arguments_length, command, arguments):
    """Validate user input.

    This function checks to make sure that the
    format that a user enters a command in is
    correct.

    Keyword arguments:
    valid_command          -- The valid command to check against.
    valid_arguments_length -- The valid length of the list of inputted arguments.
    command                -- The command.
    arguments              -- The arguments of the command.
    """
    if len(arguments) == valid_arguments_length:
        if command == valid_command:
            return True
        else:
            return False
    else:
        return False


def execute_user_input(tokenized_user_input: list):
    """Execute tokenized user input.

    This function executes user input, like the
    command "new::Awesome Project::A really awesome project."
    after it's been tokenized.

    Keyword arguments:
    tokenized_user_input -- The tokenized form of the user's input.
    """
    command = tokenized_user_input[0]
    arguments = tokenized_user_input[1:]

    if validate_user_input("new", 2, command, arguments):
        command_new_project(arguments[0], arguments[1])

    if validate_user_input("push", 2, command, arguments):
        command_push_version(arguments[0], arguments[1])

    if validate_user_input("changedir", 1, command, [arguments]):
        command_change_directory(arguments[0])


def tokenize_user_input(user_input):
    """Tokenize user input into a list.

    This function tokenizes the user's input into a
    form that the execute_user_input function can
    correctly interpret.

    Keyword arguments:
    user_input -- The user's input.
    """
    tokenized_user_input = re.split("\s*::\s*", user_input)
    return tokenized_user_input


def main():
    while True:
        user_input = input("lsc> ")
        execute_user_input(tokenize_user_input(user_input))


if __name__ == "__main__":
    main()

If you're curious about this project, you can find it on Github, here.

\$\endgroup\$
  • 1
    \$\begingroup\$ <CommentReview> Pretty cool, but sorely missing a way to get a version back into the working directory. <CommentReview /> \$\endgroup\$ – RubberDuck Jul 29 '15 at 1:22
  • \$\begingroup\$ @RubberDuck Feel free to open an issue on the repo! :) \$\endgroup\$ – Ethan Bierlein Jul 29 '15 at 2:19
  • 1
    \$\begingroup\$ Follow-up question \$\endgroup\$ – 200_success Aug 2 '15 at 8:13
4
\$\begingroup\$

Obviously, the error handling needs work: you are swallowing FileNotFoundError, discarding invalid input, and generally ignoring all kinds of failures. (In my experience, adding good error handling easily doubles the programming effort required.) There is no quit or exit command, nor do you handle EOFError gracefully.

:: isn't exactly the most user-friendly token separator.

The docstrings are too verbose for my personal taste, and get in the way of the code.

The command dispatch mechanism could be smarter. To add a new command, you have to define a function and add a condition to execute_user_input(). Furthermore, you need to specify the number of expected arguments, repeating yourself. The linear search for a matching command is also inelegant by Python standards.

The solution for all of that, I believe, introspection. I've also used variable-length argument lists for clarity and to avoid the awkward command = tokenized_user_input[0]; arguments = tokenized_user_input[1:] assignments.

import inspect
import os
import re
import shutil

class Commands:
    @staticmethod
    def new(project_name, project_description):
        ...

    @staticmethod
    def push(version_number, version_description):
        ...

    @staticmethod
    def changedir(directory):
        """Change to a new directory."""
        try:
            os.chdir(directory)
        except FileNotFoundError:
            pass    # TODO: error handling


def execute_user_input(command, *arguments):
    """
    Execute command, if the command exists and the correct number of arguments
    have been given.
    """
    function = getattr(Commands, command, None)
    if function is None or command.startswith('_'):
        return  # TODO: error handling
    argspec = inspect.getargspec(function)
    if ( len(arguments) == len(argspec.args) or
         len(arguments) > len(argspec.args) and argspec.varargs ):
        function(*arguments)

def tokenize_user_input(user_input):
    """Tokenize user input into a list."""
    return re.split("\s*::\s*", user_input)


def main():
    while True:
        user_input = input("lsc> ")
        execute_user_input(*tokenize_user_input(user_input))


if __name__ == "__main__":
    main()
\$\endgroup\$

Your Answer

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

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