Skip to content
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

Closed
wants to merge 51 commits into from
Closed
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
ca5e40c
Add Config Parser
ankurdubey521 Sep 9, 2019
a338501
Add Command Line Runner
ankurdubey521 Sep 9, 2019
42557d3
Implement Builder
ankurdubey521 Sep 9, 2019
1f28c4f
Add tests for Builder
ankurdubey521 Sep 9, 2019
f33e3f7
Fix tests and PEP8 violations
ankurdubey521 Sep 9, 2019
6270d50
Fix test and add docstrings
ankurdubey521 Sep 9, 2019
f0be0a3
General Syntax and Naming Fixes
ankurdubey521 Sep 10, 2019
1e5bd0d
Implement Command Class and add basic validation
ankurdubey521 Sep 10, 2019
cd94bc8
Fix Main.py
ankurdubey521 Sep 10, 2019
9bcb8cb
Add support for Relative Paths
ankurdubey521 Sep 10, 2019
c633b29
Add support for Root Referenced Commands
ankurdubey521 Sep 11, 2019
ac947b8
Implement Check for Circular Dependencies
ankurdubey521 Sep 11, 2019
d71d9c4
Move main function to it's own class
ankurdubey521 Sep 11, 2019
277b7d6
Pre-Generate all Command Objects while initializing BuildConfig Object
ankurdubey521 Oct 3, 2019
f1c42c3
Remove _hasFiles and _hasDependencies from BuildConfig
ankurdubey521 Oct 3, 2019
5f26c51
Print Logs when doing a dry run
ankurdubey521 Oct 3, 2019
22580e3
Move Builder Class to it's own file and Save root_dir_abs
ankurdubey521 Oct 3, 2019
a1b25bd
Move command runner to Builder Class
ankurdubey521 Oct 3, 2019
c0da0bc
Use unittest setup method instead of @classMethod
ankurdubey521 Oct 3, 2019
166c5db
Catch any exceptions that might be thrown in main.py by Builder
ankurdubey521 Oct 3, 2019
923ead1
Fixes in Builder Tests
ankurdubey521 Oct 3, 2019
5da1c4c
Add type hints
ankurdubey521 Oct 3, 2019
980e9e1
Switch to Argparse
ankurdubey521 Oct 3, 2019
c421aa6
Implement class for listening changes to files
ankurdubey521 Oct 3, 2019
eabddd1
Remove redundant for loop from file_watcher
ankurdubey521 Oct 3, 2019
7df0d4a
Integrate File watching in Build System
ankurdubey521 Oct 3, 2019
fc505ed
Make Root Directory Configurable
ankurdubey521 Oct 3, 2019
9c83a4b
Code Quality Fixes
ankurdubey521 Oct 5, 2019
4ba29bc
Add Topological Sort Code
ankurdubey521 Oct 7, 2019
0cf3e73
Implement execution of dependency rules in parallel
ankurdubey521 Oct 7, 2019
54a3e62
Cleanup
ankurdubey521 Oct 7, 2019
31226cc
Include file of dependencies when listening for changes. Also provide…
ankurdubey521 Oct 8, 2019
28a1c63
Document ParallelBuilder
ankurdubey521 Oct 8, 2019
60c414e
Document TopologicalSort
ankurdubey521 Oct 8, 2019
4e1cadf
Document buildconfig
ankurdubey521 Oct 8, 2019
86c116c
Add global constants
ankurdubey521 Oct 8, 2019
cfae026
Remove Redundant Exceptions
ankurdubey521 Oct 8, 2019
b77e20b
Run tests from Tempdir instead of running them from source
ankurdubey521 Oct 8, 2019
3658eb8
Remove Redundant path correction function from TestFileWatcher
ankurdubey521 Oct 8, 2019
ce7515e
Refactoring
ankurdubey521 Oct 9, 2019
2ec874f
Implement Logging
ankurdubey521 Oct 9, 2019
64f9272
Stop spawning more threads if build fails for dependency
ankurdubey521 Oct 9, 2019
18f8219
Move Test file contents to Testing Scripts
ankurdubey521 Oct 9, 2019
13b446d
Use os.path.join instead of manually joining paths
ankurdubey521 Oct 9, 2019
baf6e64
Optimize imports and use os.path.join in tests
ankurdubey521 Oct 10, 2019
f574672
Fix Logging
ankurdubey521 Oct 30, 2019
4f9bdfe
Refactoring
ankurdubey521 Oct 30, 2019
d9f55eb
Implement JSON Caching
ankurdubey521 Oct 30, 2019
1176bfa
Fix Tests
ankurdubey521 Oct 30, 2019
1f9d18b
Remove redundant paramaters from _execute_build_rule
ankurdubey521 Oct 30, 2019
f516ae0
Implement parallel execution using Popen polling
ankurdubey521 Oct 30, 2019
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions solutions/ankurdubey521/Build Automation Tool/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/venv/
.idea
Copy link
Owner

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.

Empty file.
Empty file.
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:
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given the non-intuitive distinction between Command and command_string, call the class BuildRule?

self._name = name
self._command_string = command_string
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))
Copy link
Owner

Choose a reason for hiding this comment

The 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.

Copy link
Owner

Choose a reason for hiding this comment

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

self._name_to_command_object = {}
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']
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))

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):
Copy link
Owner

Choose a reason for hiding this comment

The 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]
Copy link
Owner

Choose a reason for hiding this comment

The 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:
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Slightly more descriptive variable name:

for file_path in file_list:

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))
Copy link
Owner

Choose a reason for hiding this comment

The 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)
Copy link
Owner

Choose a reason for hiding this comment

The 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.
Ie - if you re-explore the build-rule-graph each time, you can detect and appropriately handle changes to the build.json files too. Yes, it is costlier (so I guess you can make it configurable) but might be more useful too, right?

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...")
Copy link
Owner

Choose a reason for hiding this comment

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


48 changes: 48 additions & 0 deletions solutions/ankurdubey521/Build Automation Tool/Builder/main.py
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__':
Copy link
Owner

Choose a reason for hiding this comment

The 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.
I'd also recommend catching your exceptions and only showing the message, instead of the whole stack trace.

# 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()
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alternatively,

current_path = os.getcwd()
root_dir = None
while current_path != root:
  if os.path.exists(current_path + os.path.sep + ".buildconfig"):
    root_dir = current_path
  current_path = parent_of(current_path)
if root_dir is None:
  raise Exception("could not find project root")


if root_dir.endswith('/'):
root_dir = root_dir[:-1]
Copy link
Owner

Choose a reason for hiding this comment

The 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()
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So now you know why the example json included information about files :)
However, should we be including the files for dependencies of the specified rule too?

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

Empty file.
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'
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This assumes that you're in a very specific directory.
Maybe something like https://stackoverflow.com/questions/5137497/find-current-directory-and-files-directory makes it possible to run a test from any location?

Copy link
Owner

Choose a reason for hiding this comment

The 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())
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"]
}
]
Loading