Repository: jupyter/nbgrader

Issue:
Allow nbgrader apps to use -f and --force
Currently only --force is supported, which means you have to do:

```
nbgrader autograde ps1 --force
```

rather than

```
nbgrader autograde ps1 -f
```

Both should be supported flags.


Discussion:

Patch:
diff --git a/nbgrader/apps/assignapp.py b/nbgrader/apps/assignapp.py
index 85fcf0e8..ebac9e3b 100644
--- a/nbgrader/apps/assignapp.py
+++ b/nbgrader/apps/assignapp.py
@@ -40,6 +40,10 @@ flags.update({
         {'BaseConverter': {'force': True}},
         "Overwrite an assignment/submission if it already exists."
     ),
+    'f': (
+        {'BaseConverter': {'force': True}},
+        "Overwrite an assignment/submission if it already exists."
+    ),
 })
 
 
diff --git a/nbgrader/apps/autogradeapp.py b/nbgrader/apps/autogradeapp.py
index 64ef3320..187df53b 100644
--- a/nbgrader/apps/autogradeapp.py
+++ b/nbgrader/apps/autogradeapp.py
@@ -30,6 +30,10 @@ flags.update({
         {'BaseConverter': {'force': True}},
         "Overwrite an assignment/submission if it already exists."
     ),
+    'f': (
+        {'BaseConverter': {'force': True}},
+        "Overwrite an assignment/submission if it already exists."
+    ),
 })
 
 
diff --git a/nbgrader/apps/dbapp.py b/nbgrader/apps/dbapp.py
index fa0e2c50..0ac5e83c 100644
--- a/nbgrader/apps/dbapp.py
+++ b/nbgrader/apps/dbapp.py
@@ -78,6 +78,10 @@ student_remove_flags.update({
         {'DbStudentRemoveApp': {'force': True}},
         "Complete the operation, even if it means grades will be deleted."
     ),
+    'f': (
+        {'DbStudentRemoveApp': {'force': True}},
+        "Complete the operation, even if it means grades will be deleted."
+    ),
 })
 
 class DbStudentRemoveApp(NbGrader):
@@ -314,6 +318,10 @@ assignment_remove_flags.update({
         {'DbAssignmentRemoveApp': {'force': True}},
         "Complete the operation, even if it means grades will be deleted."
     ),
+    'f': (
+        {'DbAssignmentRemoveApp': {'force': True}},
+        "Complete the operation, even if it means grades will be deleted."
+    ),
 })
 
 class DbAssignmentRemoveApp(NbGrader):
diff --git a/nbgrader/apps/feedbackapp.py b/nbgrader/apps/feedbackapp.py
index f4bde288..b25a9578 100644
--- a/nbgrader/apps/feedbackapp.py
+++ b/nbgrader/apps/feedbackapp.py
@@ -19,6 +19,10 @@ flags.update({
         {'BaseConverter': {'force': True}},
         "Overwrite an assignment/submission if it already exists."
     ),
+    'f': (
+        {'BaseConverter': {'force': True}},
+        "Overwrite an assignment/submission if it already exists."
+    ),
 })
 
 class FeedbackApp(NbGrader):
diff --git a/nbgrader/apps/quickstartapp.py b/nbgrader/apps/quickstartapp.py
index 77154df3..462e1cd7 100644
--- a/nbgrader/apps/quickstartapp.py
+++ b/nbgrader/apps/quickstartapp.py
@@ -26,6 +26,20 @@ flags = {
             """
         )
     ),
+    'f': (
+        {'QuickStartApp': {'force': True}},
+        dedent(
+            """
+            Overwrite existing files if they already exist. WARNING: this is
+            equivalent to doing:
+
+                rm -r <course_id>
+                nbgrader quickstart <course_id>
+
+            So be careful when using this flag!
+            """
+        )
+    ),
 }
 
 class QuickStartApp(NbGrader):
diff --git a/nbgrader/apps/releaseapp.py b/nbgrader/apps/releaseapp.py
index 0968ef4b..c44270cd 100644
--- a/nbgrader/apps/releaseapp.py
+++ b/nbgrader/apps/releaseapp.py
@@ -20,6 +20,10 @@ flags.update({
         {'ExchangeRelease' : {'force' : True}},
         "Force overwrite of existing files in the exchange."
     ),
+    'f': (
+        {'ExchangeRelease' : {'force' : True}},
+        "Force overwrite of existing files in the exchange."
+    ),
 })
 
 class ReleaseApp(NbGrader):
diff --git a/nbgrader/apps/zipcollectapp.py b/nbgrader/apps/zipcollectapp.py
index 1183667f..2cac325e 100644
--- a/nbgrader/apps/zipcollectapp.py
+++ b/nbgrader/apps/zipcollectapp.py
@@ -35,6 +35,13 @@ flags = {
         },
         "Force overwrite of existing files."
     ),
+    'f': (
+        {
+            'ZipCollectApp': {'force': True},
+            'ExtractorPlugin': {'force': True}
+        },
+        "Force overwrite of existing files."
+    ),
     'strict': (
         {'ZipCollectApp': {'strict': True}},
         "Skip submitted notebooks with invalid names."


Test patch:
diff --git a/nbgrader/tests/apps/test_nbgrader_assign.py b/nbgrader/tests/apps/test_nbgrader_assign.py
index c39d91db..0af3eb6a 100644
--- a/nbgrader/tests/apps/test_nbgrader_assign.py
+++ b/nbgrader/tests/apps/test_nbgrader_assign.py
@@ -126,6 +126,38 @@ class TestNbGraderAssign(BaseTestApp):
         assert not os.path.isfile(join(course_dir, "release", "ps1", "foo.txt"))
         assert not os.path.isfile(join(course_dir, "release", "ps1", "blah.pyc"))
 
+    def test_force_f(self, course_dir):
+        """Ensure the force option works properly"""
+        self._copy_file(join('files', 'test.ipynb'), join(course_dir, 'source', 'ps1', 'test.ipynb'))
+        self._make_file(join(course_dir, 'source', 'ps1', 'foo.txt'), "foo")
+        self._make_file(join(course_dir, 'source', 'ps1', 'data', 'bar.txt'), "bar")
+        self._make_file(join(course_dir, 'source', 'ps1', 'blah.pyc'), "asdf")
+        with open("nbgrader_config.py", "a") as fh:
+            fh.write("""c.CourseDirectory.db_assignments = [dict(name="ps1")]\n""")
+
+        run_nbgrader(["assign", "ps1"])
+        assert os.path.isfile(join(course_dir, 'release', 'ps1', 'test.ipynb'))
+        assert os.path.isfile(join(course_dir, 'release', 'ps1', 'foo.txt'))
+        assert os.path.isfile(join(course_dir, 'release', 'ps1', 'data', 'bar.txt'))
+        assert not os.path.isfile(join(course_dir, 'release', 'ps1', 'blah.pyc'))
+
+        # check that it skips the existing directory
+        os.remove(join(course_dir, 'release', 'ps1', 'foo.txt'))
+        run_nbgrader(["assign", "ps1"])
+        assert not os.path.isfile(join(course_dir, 'release', 'ps1', 'foo.txt'))
+
+        # force overwrite the supplemental files
+        run_nbgrader(["assign", "ps1", "-f"])
+        assert os.path.isfile(join(course_dir, 'release', 'ps1', 'foo.txt'))
+
+        # force overwrite
+        os.remove(join(course_dir, 'source', 'ps1', 'foo.txt'))
+        run_nbgrader(["assign", "ps1", "-f"])
+        assert os.path.isfile(join(course_dir, "release", "ps1", "test.ipynb"))
+        assert os.path.isfile(join(course_dir, "release", "ps1", "data", "bar.txt"))
+        assert not os.path.isfile(join(course_dir, "release", "ps1", "foo.txt"))
+        assert not os.path.isfile(join(course_dir, "release", "ps1", "blah.pyc"))
+
     def test_permissions(self, course_dir):
         """Are permissions properly set?"""
         self._empty_notebook(join(course_dir, 'source', 'ps1', 'foo.ipynb'))
diff --git a/nbgrader/tests/apps/test_nbgrader_autograde.py b/nbgrader/tests/apps/test_nbgrader_autograde.py
index ba44d44b..02cfbcbd 100644
--- a/nbgrader/tests/apps/test_nbgrader_autograde.py
+++ b/nbgrader/tests/apps/test_nbgrader_autograde.py
@@ -335,6 +335,46 @@ class TestNbGraderAutograde(BaseTestApp):
         assert os.path.isfile(join(course_dir, "autograded", "foo", "ps1", "data", "bar.txt"))
         assert not os.path.isfile(join(course_dir, "autograded", "foo", "ps1", "blah.pyc"))
 
+    def test_force_f(self, db, course_dir):
+        """Ensure the force option works properly"""
+        with open("nbgrader_config.py", "a") as fh:
+            fh.write("""c.CourseDirectory.db_assignments = [dict(name='ps1', duedate='2015-02-02 14:58:23.948203 PST')]\n""")
+            fh.write("""c.CourseDirectory.db_students = [dict(id="foo"), dict(id="bar")]""")
+
+        self._copy_file(join("files", "submitted-unchanged.ipynb"), join(course_dir, "source", "ps1", "p1.ipynb"))
+        self._make_file(join(course_dir, "source", "ps1", "foo.txt"), "foo")
+        self._make_file(join(course_dir, "source", "ps1", "data", "bar.txt"), "bar")
+        run_nbgrader(["assign", "ps1", "--db", db])
+
+        self._copy_file(join("files", "submitted-unchanged.ipynb"), join(course_dir, "submitted", "foo", "ps1", "p1.ipynb"))
+        self._make_file(join(course_dir, "submitted", "foo", "ps1", "foo.txt"), "foo")
+        self._make_file(join(course_dir, "submitted", "foo", "ps1", "data", "bar.txt"), "bar")
+        self._make_file(join(course_dir, "submitted", "foo", "ps1", "blah.pyc"), "asdf")
+        run_nbgrader(["autograde", "ps1", "--db", db])
+
+        assert os.path.isfile(join(course_dir, "autograded", "foo", "ps1", "p1.ipynb"))
+        assert os.path.isfile(join(course_dir, "autograded", "foo", "ps1", "foo.txt"))
+        assert os.path.isfile(join(course_dir, "autograded", "foo", "ps1", "data", "bar.txt"))
+        assert not os.path.isfile(join(course_dir, "autograded", "foo", "ps1", "blah.pyc"))
+
+        # check that it skips the existing directory
+        remove(join(course_dir, "autograded", "foo", "ps1", "foo.txt"))
+        run_nbgrader(["autograde", "ps1", "--db", db])
+        assert not os.path.isfile(join(course_dir, "autograded", "foo", "ps1", "foo.txt"))
+
+        # force overwrite the supplemental files
+        run_nbgrader(["autograde", "ps1", "--db", db, "-f"])
+        assert os.path.isfile(join(course_dir, "autograded", "foo", "ps1", "foo.txt"))
+
+        # force overwrite
+        remove(join(course_dir, "source", "ps1", "foo.txt"))
+        remove(join(course_dir, "submitted", "foo", "ps1", "foo.txt"))
+        run_nbgrader(["autograde", "ps1", "--db", db, "-f"])
+        assert os.path.isfile(join(course_dir, "autograded", "foo", "ps1", "p1.ipynb"))
+        assert not os.path.isfile(join(course_dir, "autograded", "foo", "ps1", "foo.txt"))
+        assert os.path.isfile(join(course_dir, "autograded", "foo", "ps1", "data", "bar.txt"))
+        assert not os.path.isfile(join(course_dir, "autograded", "foo", "ps1", "blah.pyc"))
+
     def test_filter_notebook(self, db, course_dir):
         """Does autograding filter by notebook properly?"""
         with open("nbgrader_config.py", "a") as fh:
diff --git a/nbgrader/tests/apps/test_nbgrader_db.py b/nbgrader/tests/apps/test_nbgrader_db.py
index 5b7789da..9576ecae 100644
--- a/nbgrader/tests/apps/test_nbgrader_db.py
+++ b/nbgrader/tests/apps/test_nbgrader_db.py
@@ -105,7 +105,33 @@ class TestNbGraderDb(BaseTestApp):
         # now force it to complete
         run_nbgrader(["db", "student", "remove", "foo", "--force", "--db", db])
 
-            # student should be gone
+        # student should be gone
+        with Gradebook(db) as gb:
+            with pytest.raises(MissingEntry):
+                gb.find_student("foo")
+
+    def test_student_remove_with_submissions_f(self, db, course_dir):
+        run_nbgrader(["db", "student", "add", "foo", "--db", db])
+        run_nbgrader(["db", "assignment", "add", "ps1", "--db", db])
+        self._copy_file(join("files", "submitted-unchanged.ipynb"), join(course_dir, "source", "ps1", "p1.ipynb"))
+        run_nbgrader(["assign", "ps1", "--db", db])
+        self._copy_file(join("files", "submitted-unchanged.ipynb"), join(course_dir, "submitted", "foo", "ps1", "p1.ipynb"))
+        run_nbgrader(["autograde", "ps1", "--db", db])
+
+        with Gradebook(db) as gb:
+            gb.find_student("foo")
+
+        # it should fail if we don't run with --force
+        run_nbgrader(["db", "student", "remove", "foo", "--db", db], retcode=1)
+
+        # make sure we can still find the student
+        with Gradebook(db) as gb:
+            gb.find_student("foo")
+
+        # now force it to complete
+        run_nbgrader(["db", "student", "remove", "foo", "-f", "--db", db])
+
+        # student should be gone
         with Gradebook(db) as gb:
             with pytest.raises(MissingEntry):
                 gb.find_student("foo")
@@ -249,6 +275,32 @@ class TestNbGraderDb(BaseTestApp):
             with pytest.raises(MissingEntry):
                 gb.find_assignment("ps1")
 
+    def test_assignment_remove_with_submissions_f(self, db, course_dir):
+        run_nbgrader(["db", "student", "add", "foo", "--db", db])
+        run_nbgrader(["db", "assignment", "add", "ps1", "--db", db])
+        self._copy_file(join("files", "submitted-unchanged.ipynb"), join(course_dir, "source", "ps1", "p1.ipynb"))
+        run_nbgrader(["assign", "ps1", "--db", db])
+        self._copy_file(join("files", "submitted-unchanged.ipynb"), join(course_dir, "submitted", "foo", "ps1", "p1.ipynb"))
+        run_nbgrader(["autograde", "ps1", "--db", db])
+
+        with Gradebook(db) as gb:
+            gb.find_assignment("ps1")
+
+        # it should fail if we don't run with --force
+        run_nbgrader(["db", "assignment", "remove", "ps1", "--db", db], retcode=1)
+
+        # make sure we can still find the assignment
+        with Gradebook(db) as gb:
+            gb.find_assignment("ps1")
+
+        # now force it to complete
+        run_nbgrader(["db", "assignment", "remove", "ps1", "-f", "--db", db])
+
+            # assignment should be gone
+        with Gradebook(db) as gb:
+            with pytest.raises(MissingEntry):
+                gb.find_assignment("ps1")
+
     def test_assignment_list(self, db):
         run_nbgrader(["db", "assignment", "add", "foo", '--duedate="Sun Jan 8 2017 4:31:22 PM"', "--db", db])
         run_nbgrader(["db", "assignment", "add", "bar", "--db", db])
diff --git a/nbgrader/tests/apps/test_nbgrader_feedback.py b/nbgrader/tests/apps/test_nbgrader_feedback.py
index 637f11d7..20fb7a75 100644
--- a/nbgrader/tests/apps/test_nbgrader_feedback.py
+++ b/nbgrader/tests/apps/test_nbgrader_feedback.py
@@ -67,6 +67,46 @@ class TestNbGraderFeedback(BaseTestApp):
         assert isfile(join(course_dir, "feedback", "foo", "ps1", "data", "bar.txt"))
         assert not isfile(join(course_dir, "feedback", "foo", "ps1", "blah.pyc"))
 
+    def test_force_f(self, db, course_dir):
+        """Ensure the force option works properly"""
+        with open("nbgrader_config.py", "a") as fh:
+            fh.write("""c.CourseDirectory.db_assignments = [dict(name="ps1")]\n""")
+            fh.write("""c.CourseDirectory.db_students = [dict(id="foo")]\n""")
+        self._copy_file(join("files", "submitted-unchanged.ipynb"), join(course_dir, "source", "ps1", "p1.ipynb"))
+        self._make_file(join(course_dir, "source", "ps1", "foo.txt"), "foo")
+        self._make_file(join(course_dir, "source", "ps1", "data", "bar.txt"), "bar")
+        run_nbgrader(["assign", "ps1", "--db", db])
+
+        self._copy_file(join("files", "submitted-unchanged.ipynb"), join(course_dir, "submitted", "foo", "ps1", "p1.ipynb"))
+        self._make_file(join(course_dir, "submitted", "foo", "ps1", "foo.txt"), "foo")
+        self._make_file(join(course_dir, "submitted", "foo", "ps1", "data", "bar.txt"), "bar")
+        run_nbgrader(["autograde", "ps1", "--db", db])
+
+        self._make_file(join(course_dir, "autograded", "foo", "ps1", "blah.pyc"), "asdf")
+        run_nbgrader(["feedback", "ps1", "--db", db])
+
+        assert isfile(join(course_dir, "feedback", "foo", "ps1", "p1.html"))
+        assert isfile(join(course_dir, "feedback", "foo", "ps1", "foo.txt"))
+        assert isfile(join(course_dir, "feedback", "foo", "ps1", "data", "bar.txt"))
+        assert not isfile(join(course_dir, "feedback", "foo", "ps1", "blah.pyc"))
+
+        # check that it skips the existing directory
+        remove(join(course_dir, "feedback", "foo", "ps1", "foo.txt"))
+        run_nbgrader(["feedback", "ps1", "--db", db])
+        assert not isfile(join(course_dir, "feedback", "foo", "ps1", "foo.txt"))
+
+        # force overwrite the supplemental files
+        run_nbgrader(["feedback", "ps1", "--db", db, "-f"])
+        assert isfile(join(course_dir, "feedback", "foo", "ps1", "foo.txt"))
+
+        # force overwrite
+        remove(join(course_dir, "autograded", "foo", "ps1", "foo.txt"))
+        run_nbgrader(["feedback", "ps1", "--db", db, "--force"])
+        assert isfile(join(course_dir, "feedback", "foo", "ps1", "p1.html"))
+        assert not isfile(join(course_dir, "feedback", "foo", "ps1", "foo.txt"))
+        assert isfile(join(course_dir, "feedback", "foo", "ps1", "data", "bar.txt"))
+        assert not isfile(join(course_dir, "feedback", "foo", "ps1", "blah.pyc"))
+
     def test_filter_notebook(self, db, course_dir):
         """Does feedback filter by notebook properly?"""
         with open("nbgrader_config.py", "a") as fh:
diff --git a/nbgrader/tests/apps/test_nbgrader_quickstart.py b/nbgrader/tests/apps/test_nbgrader_quickstart.py
index 1189933c..d9a9f705 100644
--- a/nbgrader/tests/apps/test_nbgrader_quickstart.py
+++ b/nbgrader/tests/apps/test_nbgrader_quickstart.py
@@ -39,3 +39,28 @@ class TestNbGraderQuickStart(BaseTestApp):
         # nbgrader assign should work
         run_nbgrader(["assign", "ps1"])
 
+    def test_quickstart_f(self):
+        """Is the quickstart example properly generated?"""
+
+        run_nbgrader(["quickstart", "example"])
+
+        # it should fail if it already exists
+        run_nbgrader(["quickstart", "example"], retcode=1)
+
+        # it should succeed if --force is given
+        os.remove(os.path.join("example", "nbgrader_config.py"))
+        run_nbgrader(["quickstart", "example", "-f"])
+        assert os.path.exists(os.path.join("example", "nbgrader_config.py"))
+
+        # nbgrader validate should work
+        os.chdir("example")
+        for nb in os.listdir(os.path.join("source", "ps1")):
+            if not nb.endswith(".ipynb"):
+                continue
+            output = run_nbgrader(["validate", os.path.join("source", "ps1", nb)], stdout=True)
+            assert output.strip() == "Success! Your notebook passes all the tests."
+
+        # nbgrader assign should work
+        run_nbgrader(["assign", "ps1"])
+
+
diff --git a/nbgrader/tests/apps/test_nbgrader_release.py b/nbgrader/tests/apps/test_nbgrader_release.py
index 0d8bf2dc..830f5955 100644
--- a/nbgrader/tests/apps/test_nbgrader_release.py
+++ b/nbgrader/tests/apps/test_nbgrader_release.py
@@ -53,6 +53,19 @@ class TestNbGraderRelease(BaseTestApp):
         self._release("ps1", exchange, flags=["--force"])
         assert os.path.isfile(join(exchange, "abc101", "outbound", "ps1", "p1.ipynb"))
 
+    def test_force_release_f(self, exchange, course_dir):
+        self._copy_file(join("files", "test.ipynb"), join(course_dir, "release", "ps1", "p1.ipynb"))
+        self._release("ps1", exchange)
+        assert os.path.isfile(join(exchange, "abc101", "outbound", "ps1", "p1.ipynb"))
+
+        self._release("ps1", exchange, retcode=1)
+
+        os.remove(join(exchange, join("abc101", "outbound", "ps1", "p1.ipynb")))
+        self._release("ps1", exchange, retcode=1)
+
+        self._release("ps1", exchange, flags=["-f"])
+        assert os.path.isfile(join(exchange, "abc101", "outbound", "ps1", "p1.ipynb"))
+
     def test_release_with_assignment_flag(self, exchange, course_dir):
         self._copy_file(join("files", "test.ipynb"), join(course_dir, "release", "ps1", "p1.ipynb"))
         self._release("--assignment=ps1", exchange)
diff --git a/nbgrader/tests/apps/test_nbgrader_zip_collect.py b/nbgrader/tests/apps/test_nbgrader_zip_collect.py
index 58343a55..9f5dca14 100644
--- a/nbgrader/tests/apps/test_nbgrader_zip_collect.py
+++ b/nbgrader/tests/apps/test_nbgrader_zip_collect.py
@@ -72,6 +72,25 @@ class TestNbGraderZipCollect(BaseTestApp):
         assert os.path.isdir(extracted_dir)
         assert len(os.listdir(extracted_dir)) == 1
 
+    def test_extract_single_notebook_f(self, course_dir, archive_dir):
+        extracted_dir = join(archive_dir, "..", "extracted")
+        self._make_notebook(archive_dir,
+            'ps1', 'hacker', '2016-01-30-15-30-10', 'problem1')
+
+        run_nbgrader(["zip_collect", "ps1"])
+        assert os.path.isdir(extracted_dir)
+        assert len(os.listdir(extracted_dir)) == 1
+
+        # Run again should fail
+        run_nbgrader(["zip_collect", "ps1"], retcode=1)
+        assert os.path.isdir(extracted_dir)
+        assert len(os.listdir(extracted_dir)) == 1
+
+        # Run again with --force flag should pass
+        run_nbgrader(["zip_collect", "-f", "ps1"])
+        assert os.path.isdir(extracted_dir)
+        assert len(os.listdir(extracted_dir)) == 1
+
     def test_extract_sub_dir_single_notebook(self, course_dir, archive_dir):
         extracted_dir = join(archive_dir, "..", "extracted")
         self._make_notebook(join(archive_dir, 'hacker'),