# Local source control

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


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
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/changelog.txt", "w+") as changelog:

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)

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

if __name__ == "__main__":
main()


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

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()