-
Notifications
You must be signed in to change notification settings - Fork 21
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Assignment 2.1: Build Automation Tool #60
Changes from 27 commits
ca5e40c
a338501
42557d3
1f28c4f
f33e3f7
6270d50
f0be0a3
1e5bd0d
cd94bc8
9bcb8cb
c633b29
ac947b8
d71d9c4
277b7d6
f1c42c3
5f26c51
22580e3
a1b25bd
c0da0bc
166c5db
923ead1
5da1c4c
980e9e1
c421aa6
eabddd1
7df0d4a
fc505ed
9c83a4b
4ba29bc
0cf3e73
54a3e62
31226cc
28a1c63
60c414e
4e1cadf
86c116c
cfae026
b77e20b
3658eb8
ce7515e
2ec874f
64f9272
18f8219
13b446d
baf6e64
f574672
4f9bdfe
d9f55eb
1176bfa
1f9d18b
f516ae0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
/venv/ | ||
.idea | ||
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,68 @@ | ||
import json | ||
from typing import List | ||
|
||
|
||
class Command: | ||
"""Stores Command Information""" | ||
def __init__(self, *, name: str, command_string: str, deps: List[str] = None, files: List[str] = None) -> None: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Given the non-intuitive distinction between |
||
self._name = name | ||
self._command_string = command_string | ||
kaustubh-karkare marked this conversation as resolved.
Show resolved
Hide resolved
|
||
self._files = files | ||
self._dependencies = deps | ||
|
||
def get_name(self) -> str: | ||
return self._name | ||
|
||
def get_files(self) -> List[str]: | ||
if self._files is not None: | ||
return self._files | ||
raise Command.NoFilesException("No Files for {}".format(self._name)) | ||
|
||
def get_command_string(self) -> str: | ||
return self._command_string | ||
|
||
def get_dependencies(self) -> List[str]: | ||
if self._dependencies is not None: | ||
return self._dependencies | ||
raise Command.NoDependenciesException("No Dependencies for {}".format(self._name)) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't even think you need to raise exceptions like this. Ie - if deps aren't specified, the default meaning is that there are no deps, so you can just return an empty list. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ^ What do you think of this? |
||
|
||
class NoDependenciesException(Exception): | ||
pass | ||
|
||
class NoFilesException(Exception): | ||
pass | ||
|
||
|
||
class BuildConfig: | ||
"""Parses and Stores build.config""" | ||
def __init__(self, json_containing_folder: str) -> None: | ||
# Parse JSON | ||
json_path = json_containing_folder + "/build.json" | ||
with open(json_path) as file_handle: | ||
self._raw_json = json.load(file_handle) | ||
kaustubh-karkare marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
self._name_to_command_object = {} | ||
kaustubh-karkare marked this conversation as resolved.
Show resolved
Hide resolved
|
||
for command_entry in self._raw_json: | ||
name = command_entry['name'] | ||
command_string = command_entry['command'] | ||
deps = None | ||
if 'deps' in command_entry: | ||
deps = command_entry['deps'] | ||
kaustubh-karkare marked this conversation as resolved.
Show resolved
Hide resolved
|
||
files = None | ||
if 'files' in command_entry: | ||
files = command_entry['files'] | ||
self._name_to_command_object[name] =\ | ||
Command(name=name, command_string=command_string, deps=deps, files=files) | ||
|
||
def get_command(self, command_name: str) -> Command: | ||
if command_name in self._name_to_command_object: | ||
return self._name_to_command_object[command_name] | ||
else: | ||
raise BuildConfig.UnknownCommandException('No such command "{}" found.'.format(command_name)) | ||
kaustubh-karkare marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
class UnknownCommandException(Exception): | ||
pass | ||
|
||
|
||
if __name__ == '__main__': | ||
pass |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,89 @@ | ||
from Builder.lib.buildconfig import BuildConfig, Command | ||
import subprocess | ||
|
||
|
||
class Builder: | ||
def __init__(self): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The way you're using this, the constructor must be followed by a call to execute_build_rule. That kind of expectation is unexpected, and while in theory, you could explain it in documentation, there is a better way: Make execute_build_rule a @classmethod, which creates the object, and then calls _build_rule_handler too. That way, you only need to call one method from outside this file. |
||
self._root_dir_abs = "" | ||
self.unresolved_commands = set() | ||
|
||
def _build_rule_handler(self, command_name: str, command_dir_abs: str, dry_run: bool = False) -> None: | ||
"""Parses JSON, Resolves Dependencies and Executes Command/Do a dry run""" | ||
|
||
# Remove Trailing '/' from paths if present | ||
if command_dir_abs.endswith('/'): | ||
command_dir_abs = command_dir_abs[:-1] | ||
if self._root_dir_abs.endswith('/'): | ||
self._root_dir_abs = self._root_dir_abs[:-1] | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I usually prefer to do this validation once at the beginning, instead of during every call to this method. |
||
|
||
# Mark the command's dependencies as unresolved | ||
self.unresolved_commands.add((command_name, command_dir_abs)) | ||
|
||
# Parse build.json in current directory | ||
config = BuildConfig(command_dir_abs) | ||
command = config.get_command(command_name) | ||
|
||
# Process Dependencies | ||
try: | ||
deps = command.get_dependencies() | ||
if not dry_run: | ||
print("Executing {} dependencies for {} in {}...".format(len(deps), command_name, command_dir_abs)) | ||
for dep in deps: | ||
dep_dir_abs = '' | ||
dep_name = '' | ||
if dep.startswith('//'): | ||
# Command path referenced from root directory | ||
dep_dir_abs = self._root_dir_abs + '/' + dep[2:] | ||
dep_dir_abs, dep_name = dep_dir_abs.rsplit('/', 1) | ||
elif '/' in dep: | ||
# Command in child directory | ||
dep_dir_abs, dep_name = dep.rsplit('/', 1) | ||
dep_dir_abs = command_dir_abs + "/" + dep_dir_abs | ||
else: | ||
# Command in same directory | ||
dep_name = dep | ||
dep_dir_abs = command_dir_abs | ||
|
||
# Check for circular dependencies | ||
if (dep_name, dep_dir_abs) in self.unresolved_commands: | ||
raise self.CircularDependencyException( | ||
"Detected a Circular Dependency between {}:{} and {}:{}" | ||
.format(dep_dir_abs, dep_name, command_dir_abs, command_name)) | ||
|
||
self._build_rule_handler(dep_name, dep_dir_abs, dry_run) | ||
|
||
except Command.NoDependenciesException: | ||
print("No dependencies found for {} in {}...".format(command_name, command_dir_abs)) | ||
|
||
# Mark the command's dependencies as resolved | ||
self.unresolved_commands.remove((command_name, command_dir_abs)) | ||
# Execute Command after processing dependencies | ||
print("Executing {} in {}".format(command_name, command_dir_abs)) | ||
|
||
if not dry_run: | ||
return_value = self.run_shell(command.get_command_string(), command_dir_abs, print_command=True) | ||
|
||
# Stop Execution if Command Fails | ||
if return_value != 0: | ||
exit(-1) | ||
|
||
def execute_build_rule(self, command_name: str, command_dir_abs: str, root_dir_abs: str) -> None: | ||
# Save root_dir_abs | ||
self._root_dir_abs = root_dir_abs | ||
|
||
# Do a dry run and check for syntax, do validation and check for circular dependencies | ||
print("Performing a dry run....") | ||
self._build_rule_handler(command_name, command_dir_abs, dry_run=True) | ||
|
||
# If everything is fine do an actual run | ||
print("\nDry run Succeeded. Performing actual run...") | ||
self._build_rule_handler(command_name, command_dir_abs) | ||
|
||
class CircularDependencyException(Exception): | ||
pass | ||
|
||
def run_shell(self, command_string: str, cwd: str = '/', print_command: bool = False) -> int: | ||
"""Run Command and Return Exit Code. Optionally Print the command itself""" | ||
if print_command: | ||
print(command_string) | ||
return subprocess.run(command_string, shell=True, cwd=cwd).returncode |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,30 @@ | ||
from os import stat | ||
from typing import List, Callable, Dict | ||
from time import sleep | ||
|
||
|
||
class FileWatcher: | ||
FILE_WATCH_INTERVAL_SECONDS = 1 | ||
|
||
def _get_file_edit_times(self, file_list: List[str]) -> Dict[str, int]: | ||
"""Populate a Dict of last edit times of files and return it""" | ||
file_edit_times = {} | ||
for file in file_list: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Slightly more descriptive variable name:
|
||
file_edit_times[file] = stat(file).st_mtime | ||
return file_edit_times | ||
|
||
def watch_and_execute(self, file_list: List[str], function: Callable[[any], any], *args: any) -> None: | ||
"""Execute a function whenever a file from file_list changes""" | ||
print("Listening for changes on {}...".format(file_list)) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Instead of printing to stdout directly, you can send these messages to a logger object, provided in the constructor, and shared across your codebase. That way, if I import your code as a library, I can just point the logger object to write to a file or something. |
||
file_edit_times = self._get_file_edit_times(file_list) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You are making an assumption here that the file list is fixed, which IMO we can avoid. |
||
print("Executing command for the first time...") | ||
function(*args) | ||
while True: | ||
sleep(FileWatcher.FILE_WATCH_INTERVAL_SECONDS) | ||
new_file_edit_times = self._get_file_edit_times(file_list) | ||
if new_file_edit_times != file_edit_times: | ||
file_edit_times = new_file_edit_times | ||
print("Detected change of file. Executing command...") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hmm. How does this method know what we're doing ("Executing command ...") if the function is defined as generic? Access to that information broke an abstraction boundary, right? |
||
function(*args) | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,48 @@ | ||
from Builder.lib.builder import Builder | ||
from Builder.lib.buildconfig import BuildConfig | ||
from Builder.lib.file_watcher import FileWatcher | ||
import argparse | ||
import sys | ||
import os | ||
|
||
|
||
if __name__ == '__main__': | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It might still be useful to keep this main application logic in a separate file from your library. |
||
# Parse Args | ||
parser = argparse.ArgumentParser() | ||
parser.add_argument('build_rule', type=str) | ||
parser.add_argument('--watch', action="store_true") | ||
parser.add_argument('--root-dir', type=str) | ||
args = parser.parse_args(sys.argv[1:]) | ||
|
||
# Parse Command | ||
command = args.build_rule | ||
root_dir = "" | ||
if args.root_dir is not None: | ||
root_dir = args.root_dir | ||
else: | ||
root_dir = os.getcwd() | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Alternatively,
|
||
|
||
if root_dir.endswith('/'): | ||
root_dir = root_dir[:-1] | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Huh, if you already did this check here, why did you need to do it in the other file? |
||
|
||
builder = Builder() | ||
relative_path = '' | ||
if '/' in command: | ||
# Handle relative paths | ||
relative_path, command = command.rsplit('/', 1) | ||
path = root_dir + "/" + relative_path | ||
|
||
try: | ||
if not args.watch: | ||
builder.execute_build_rule(command, path, root_dir) | ||
else: | ||
# Watch files and run build rule on changes | ||
file_watcher = FileWatcher() | ||
file_list = BuildConfig(path).get_command(command).get_files() | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. So now you know why the example json included information about files :) |
||
# Convert file paths to absolute | ||
file_list = [(path + file) for file in file_list] | ||
file_watcher.watch_and_execute(file_list, builder.execute_build_rule, command, path, root_dir) | ||
|
||
except Exception as e: | ||
print(e.with_traceback()) | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,12 @@ | ||
[ | ||
{ | ||
"name": "clean", | ||
"deps": ["algorithms/clean"], | ||
"command": "rm -f test.o && rm -f test.exe" | ||
}, | ||
{ | ||
"name": "test", | ||
"files": ["test.cpp"], | ||
"command": "g++ -std=c++11 -c test.cpp" | ||
} | ||
] |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,35 @@ | ||
import unittest | ||
from Builder.lib.buildconfig import Command, BuildConfig | ||
|
||
|
||
class TestJsonParser(unittest.TestCase): | ||
|
||
def setUp(self): | ||
self.config = BuildConfig('json') | ||
|
||
def test_build_clean_parses_correctly(self): | ||
command_clean = self.config.get_command('clean') | ||
self.assertEqual(command_clean.get_name(), 'clean') | ||
self.assertEqual(command_clean.get_command_string(), 'rm -f test.o && rm -f test.exe') | ||
self.assertEqual(command_clean.get_dependencies(), ["algorithms/clean"]) | ||
|
||
def test_build_test_parses_correctly(self): | ||
command_test = self.config.get_command('test') | ||
self.assertEqual(command_test.get_name(), 'test') | ||
self.assertEqual(command_test.get_command_string(), 'g++ -std=c++11 -c test.cpp') | ||
self.assertEqual(command_test.get_files(), ["test.cpp"]) | ||
|
||
def test_no_deps_throws_correct_exception(self): | ||
command_test = self.config.get_command('test') | ||
self.assertRaises(Command.NoDependenciesException, command_test.get_dependencies) | ||
|
||
def test_no_files_throws_correct_exception(self): | ||
command_clean = self.config.get_command('clean') | ||
self.assertRaises(Command.NoFilesException, command_clean.get_files) | ||
|
||
def test_unknown_command_name_raises_correct_exception(self): | ||
self.assertRaises(BuildConfig.UnknownCommandException, self.config.get_command, 'YOLO') | ||
|
||
|
||
if __name__ == '__main__': | ||
unittest.main() |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,68 @@ | ||
import unittest | ||
import subprocess | ||
import os | ||
from Builder.main import Builder | ||
|
||
|
||
class TestBuilder(unittest.TestCase): | ||
# TODO: Implement running tests from /tmp/ | ||
|
||
# The tests should work for any path inside the project | ||
def setUp(self): | ||
while os.path.basename(os.getcwd()) != 'Build Automation Tool': | ||
os.chdir('..') | ||
os.chdir('Builder/tests') | ||
|
||
def test_basic_shell_command(self): | ||
command = "echo 'Hello World!'" | ||
exit_code = Builder().run_shell(command, cwd='/') | ||
self.assertEqual(0, exit_code) | ||
|
||
def test_nonzero_exit_code_for_shell_command(self): | ||
command = "exit 1" | ||
exit_code = Builder().run_shell(command, cwd='/') | ||
self.assertEqual(1, exit_code) | ||
|
||
def test_compilation_basic(self): | ||
builder = Builder() | ||
path = os.getcwd() + '/test_builder_files/test_compilation_basic' | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This assumes that you're in a very specific directory. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Interesting, you addressed this concern, but in a way that I didn't expect. |
||
builder.execute_build_rule('run', path, path) | ||
exec_path = '"' + path + '/test.out' + '"' | ||
result = subprocess.run(exec_path, shell=True, capture_output=True, text=True) | ||
self.assertEqual('1 2 3 4 5 \n1 2 3 4 5 \n1 2 3 4 5 \n', result.stdout) | ||
builder.execute_build_rule('clean', path, os.getcwd()) | ||
kaustubh-karkare marked this conversation as resolved.
Show resolved
Hide resolved
|
||
self.assertFalse(os.path.isfile(path + "/test.out")) | ||
|
||
def test_commands_referenced_from_root(self): | ||
builder = Builder() | ||
path = os.getcwd() + '/test_builder_files/test_commands_referenced_from_root' | ||
builder.execute_build_rule('run', path, path) | ||
output_file_path = path + '/output' | ||
result = '' | ||
with open(output_file_path) as file_handle: | ||
result = file_handle.readable() | ||
builder.execute_build_rule('clean', path, os.getcwd() + '/test_builder_files/test_commands_referenced_from_root') | ||
self.assertEqual(True, result) | ||
|
||
def test_dry_run_does_not_write_files(self): | ||
builder = Builder() | ||
path = os.getcwd() + '/test_builder_files/test_commands_referenced_from_root' | ||
# Perhaps there is a better method of doing this ? | ||
builder._root_dir_abs = path | ||
builder._build_rule_handler('run', path, dry_run=True) | ||
output_file_path = path + '/output' | ||
self.assertFalse(os.path.isfile(output_file_path)) | ||
|
||
def test_basic_circular_dependency_throws_exception(self): | ||
builder = Builder() | ||
path = os.getcwd() + '/test_builder_files/test_basic_circular_dependency_throws_exception' | ||
self.assertRaises(Builder.CircularDependencyException, builder.execute_build_rule, 'A', path, path) | ||
|
||
def test_basic_circular_dependency2_throws_exception(self): | ||
builder = Builder() | ||
path = os.getcwd() + '/test_builder_files/test_basic_circular_dependency2_throws_exception' | ||
self.assertRaises(Builder.CircularDependencyException, builder.execute_build_rule, 'A', path, path) | ||
|
||
|
||
if __name__ == '__main__': | ||
unittest.main() |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
[ | ||
{ | ||
"name": "A", | ||
"command": "echo \"A\"", | ||
"deps": ["//A"] | ||
} | ||
] |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
[ | ||
{ | ||
"name": "A", | ||
"command": "echo \"A\"", | ||
"deps": ["X/A"] | ||
} | ||
] |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,12 @@ | ||
[ | ||
{ | ||
"name": "A", | ||
"command": "echo \"A\"", | ||
"deps": ["B"] | ||
}, | ||
{ | ||
"name": "B", | ||
"command": "echo \"B\"", | ||
"deps": ["A"] | ||
} | ||
] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
btw, it is possible to add .gitignore here to exclude this file too.