ygrader 2.5.2__tar.gz → 2.6.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {ygrader-2.5.2/ygrader.egg-info → ygrader-2.6.0}/PKG-INFO +1 -1
- {ygrader-2.5.2 → ygrader-2.6.0}/setup.py +1 -1
- {ygrader-2.5.2 → ygrader-2.6.0}/ygrader/__init__.py +1 -0
- {ygrader-2.5.2 → ygrader-2.6.0}/ygrader/grader.py +266 -28
- ygrader-2.6.0/ygrader/remote.py +257 -0
- ygrader-2.6.0/ygrader/student_repos.py +239 -0
- {ygrader-2.5.2 → ygrader-2.6.0/ygrader.egg-info}/PKG-INFO +1 -1
- {ygrader-2.5.2 → ygrader-2.6.0}/ygrader.egg-info/SOURCES.txt +1 -0
- ygrader-2.5.2/ygrader/student_repos.py +0 -138
- {ygrader-2.5.2 → ygrader-2.6.0}/LICENSE +0 -0
- {ygrader-2.5.2 → ygrader-2.6.0}/setup.cfg +0 -0
- {ygrader-2.5.2 → ygrader-2.6.0}/test/test_interactive.py +0 -0
- {ygrader-2.5.2 → ygrader-2.6.0}/test/test_unittest.py +0 -0
- {ygrader-2.5.2 → ygrader-2.6.0}/ygrader/deductions.py +0 -0
- {ygrader-2.5.2 → ygrader-2.6.0}/ygrader/feedback.py +0 -0
- {ygrader-2.5.2 → ygrader-2.6.0}/ygrader/grades_csv.py +0 -0
- {ygrader-2.5.2 → ygrader-2.6.0}/ygrader/grading_item.py +0 -0
- {ygrader-2.5.2 → ygrader-2.6.0}/ygrader/grading_item_config.py +0 -0
- {ygrader-2.5.2 → ygrader-2.6.0}/ygrader/score_input.py +0 -0
- {ygrader-2.5.2 → ygrader-2.6.0}/ygrader/send_ctrl_backtick.ahk +0 -0
- {ygrader-2.5.2 → ygrader-2.6.0}/ygrader/upstream_merger.py +0 -0
- {ygrader-2.5.2 → ygrader-2.6.0}/ygrader/utils.py +0 -0
- {ygrader-2.5.2 → ygrader-2.6.0}/ygrader.egg-info/dependency_links.txt +0 -0
- {ygrader-2.5.2 → ygrader-2.6.0}/ygrader.egg-info/requires.txt +0 -0
- {ygrader-2.5.2 → ygrader-2.6.0}/ygrader.egg-info/top_level.txt +0 -0
|
@@ -4,7 +4,7 @@ setup(
|
|
|
4
4
|
name="ygrader",
|
|
5
5
|
packages=["ygrader"],
|
|
6
6
|
package_data={"ygrader": ["*.ahk"]},
|
|
7
|
-
version="2.
|
|
7
|
+
version="2.6.0",
|
|
8
8
|
description="Grading scripts used in BYU's Electrical and Computer Engineering Department",
|
|
9
9
|
author="Jeff Goeders",
|
|
10
10
|
author_email="jeff.goeders@gmail.com",
|
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
"""Main ygrader module"""
|
|
2
2
|
|
|
3
|
+
# pylint: disable=too-many-lines
|
|
4
|
+
|
|
3
5
|
import enum
|
|
4
6
|
import inspect
|
|
5
7
|
import os
|
|
6
8
|
import pathlib
|
|
7
9
|
import re
|
|
8
10
|
import shutil
|
|
11
|
+
import sys
|
|
12
|
+
import tempfile
|
|
13
|
+
import threading
|
|
9
14
|
import time
|
|
10
15
|
import zipfile
|
|
11
16
|
from collections import defaultdict
|
|
17
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
12
18
|
from typing import Callable
|
|
13
19
|
|
|
14
20
|
import pandas
|
|
@@ -173,7 +179,10 @@ class Grader:
|
|
|
173
179
|
fcn_args_dict=grading_fcn_args_dict,
|
|
174
180
|
)
|
|
175
181
|
_verify_callback_fcn(
|
|
176
|
-
grading_fcn,
|
|
182
|
+
grading_fcn,
|
|
183
|
+
item,
|
|
184
|
+
fcn_extra_args_dict=grading_fcn_args_dict,
|
|
185
|
+
github_configured=(self.code_source == CodeSource.GITHUB),
|
|
177
186
|
)
|
|
178
187
|
self.items.append(item)
|
|
179
188
|
|
|
@@ -332,6 +341,7 @@ class Grader:
|
|
|
332
341
|
dry_run_first=False,
|
|
333
342
|
dry_run_all=False,
|
|
334
343
|
workflow_hash=None,
|
|
344
|
+
parallel_workers=None,
|
|
335
345
|
):
|
|
336
346
|
"""
|
|
337
347
|
This can be used to set other options for the grader.
|
|
@@ -371,6 +381,10 @@ class Grader:
|
|
|
371
381
|
(Optional) Expected hash of the GitHub workflow file. If provided, the workflow file will be verified
|
|
372
382
|
before grading each student. If the hash doesn't match, a warning will be displayed indicating
|
|
373
383
|
the student may have modified the workflow system.
|
|
384
|
+
parallel_workers: int or None
|
|
385
|
+
If set to an integer, process students in parallel using that many workers.
|
|
386
|
+
Only works with build_only mode since interactive grading cannot be parallelized.
|
|
387
|
+
Default is None (sequential processing).
|
|
374
388
|
"""
|
|
375
389
|
self.format_code = format_code
|
|
376
390
|
self.build_only = build_only
|
|
@@ -378,11 +392,16 @@ class Grader:
|
|
|
378
392
|
self.allow_rebuild = allow_rebuild
|
|
379
393
|
self.allow_rerun = allow_rerun
|
|
380
394
|
self.workflow_hash = workflow_hash
|
|
395
|
+
self.parallel_workers = parallel_workers
|
|
381
396
|
if prep_fcn and not isinstance(prep_fcn, Callable):
|
|
382
397
|
error("The 'prep_fcn' argument must provide a callable function pointer")
|
|
383
398
|
self.prep_fcn = prep_fcn
|
|
384
399
|
if prep_fcn:
|
|
385
|
-
_verify_callback_fcn(
|
|
400
|
+
_verify_callback_fcn(
|
|
401
|
+
prep_fcn,
|
|
402
|
+
item=None,
|
|
403
|
+
github_configured=(self.code_source == CodeSource.GITHUB),
|
|
404
|
+
)
|
|
386
405
|
|
|
387
406
|
if not (self.allow_rebuild or self.allow_rerun):
|
|
388
407
|
error("At least one of allow_rebuild and allow_rerun needs to be True.")
|
|
@@ -392,6 +411,9 @@ class Grader:
|
|
|
392
411
|
self.dry_run_first = dry_run_first
|
|
393
412
|
self.dry_run_all = dry_run_all
|
|
394
413
|
|
|
414
|
+
if parallel_workers is not None and not build_only:
|
|
415
|
+
error("parallel_workers is only supported when build_only=True")
|
|
416
|
+
|
|
395
417
|
def _validate_config(self):
|
|
396
418
|
"""Check that everything has been configured before running"""
|
|
397
419
|
# Check that callback function has been set up
|
|
@@ -422,30 +444,32 @@ class Grader:
|
|
|
422
444
|
# Print starting message
|
|
423
445
|
print_color(TermColors.BLUE, "Running grader for", self.lab_name)
|
|
424
446
|
|
|
425
|
-
# Read in CSV and validate.
|
|
447
|
+
# Read in CSV and validate.
|
|
426
448
|
student_grades_df = grades_csv.parse_and_check(
|
|
427
449
|
self.class_list_csv_path, self._get_all_csv_cols_to_grade()
|
|
428
450
|
)
|
|
429
451
|
|
|
430
|
-
# Filter by students who need a grade
|
|
431
|
-
grades_needed_df = grades_csv.filter_need_grade(
|
|
432
|
-
student_grades_df, self._get_all_csv_cols_to_grade()
|
|
433
|
-
)
|
|
434
|
-
print_color(
|
|
435
|
-
TermColors.BLUE,
|
|
436
|
-
str(
|
|
437
|
-
grades_csv.filter_need_grade(
|
|
438
|
-
grades_needed_df, self._get_all_csv_cols_to_grade()
|
|
439
|
-
).shape[0]
|
|
440
|
-
),
|
|
441
|
-
"students need to be graded.",
|
|
442
|
-
)
|
|
443
|
-
|
|
444
452
|
# Add column for group name to DataFrame.
|
|
445
453
|
# For github, students are grouped by their Github repo URL.
|
|
446
454
|
# For learning suite, if set_groups() was never called, then students are placed in groups by Net ID (so every student in their own group)
|
|
447
455
|
grouped_df = self._group_students(student_grades_df)
|
|
448
456
|
|
|
457
|
+
# Count students who need grading based on deductions file
|
|
458
|
+
students_need_grading = 0
|
|
459
|
+
for _, row in grouped_df.iterrows():
|
|
460
|
+
net_ids = grades_csv.get_net_ids(row)
|
|
461
|
+
num_need_grade = sum(
|
|
462
|
+
item.num_grades_needed_deductions(net_ids) for item in self.items
|
|
463
|
+
)
|
|
464
|
+
if num_need_grade > 0:
|
|
465
|
+
students_need_grading += 1
|
|
466
|
+
|
|
467
|
+
print_color(
|
|
468
|
+
TermColors.BLUE,
|
|
469
|
+
str(students_need_grading),
|
|
470
|
+
"students need to be graded.",
|
|
471
|
+
)
|
|
472
|
+
|
|
449
473
|
# Create working path directory
|
|
450
474
|
self._create_work_path()
|
|
451
475
|
|
|
@@ -456,9 +480,19 @@ class Grader:
|
|
|
456
480
|
# sys.exit(0)
|
|
457
481
|
# grouped_df = self._add_submitted_zip_path_column(grouped_df)
|
|
458
482
|
|
|
459
|
-
self.
|
|
483
|
+
if self.parallel_workers is not None:
|
|
484
|
+
self._run_grading_parallel(student_grades_df, grouped_df)
|
|
485
|
+
else:
|
|
486
|
+
self._run_grading_sequential(student_grades_df, grouped_df)
|
|
460
487
|
|
|
461
488
|
def _run_grading(self, student_grades_df, grouped_df):
|
|
489
|
+
"""Alias for backwards compatibility."""
|
|
490
|
+
if self.parallel_workers is not None:
|
|
491
|
+
self._run_grading_parallel(student_grades_df, grouped_df)
|
|
492
|
+
else:
|
|
493
|
+
self._run_grading_sequential(student_grades_df, grouped_df)
|
|
494
|
+
|
|
495
|
+
def _run_grading_sequential(self, student_grades_df, grouped_df):
|
|
462
496
|
# Sort by last name for consistent grading order
|
|
463
497
|
# After groupby().agg(list), "Last Name" is a list, so we sort by the first element
|
|
464
498
|
sorted_df = grouped_df.sort_values(
|
|
@@ -519,6 +553,10 @@ class Grader:
|
|
|
519
553
|
callback_args["first_names"] = first_names
|
|
520
554
|
callback_args["last_names"] = last_names
|
|
521
555
|
callback_args["net_ids"] = net_ids
|
|
556
|
+
callback_args["output"] = sys.stdout # Default to stdout in sequential mode
|
|
557
|
+
if self.code_source == CodeSource.GITHUB:
|
|
558
|
+
callback_args["repo_url"] = row["github_url"]
|
|
559
|
+
callback_args["tag"] = self.github_tag
|
|
522
560
|
if "Section Number" in row:
|
|
523
561
|
callback_args["section"] = row["Section Number"]
|
|
524
562
|
if "Course Homework ID" in row:
|
|
@@ -561,6 +599,193 @@ class Grader:
|
|
|
561
599
|
prev_idx = idx
|
|
562
600
|
idx += 1
|
|
563
601
|
|
|
602
|
+
def _process_single_student_build(self, row):
|
|
603
|
+
"""Process a single student for parallel build mode. Returns (net_ids, success, message, log_path)."""
|
|
604
|
+
first_names = grades_csv.get_first_names(row)
|
|
605
|
+
last_names = grades_csv.get_last_names(row)
|
|
606
|
+
net_ids = grades_csv.get_net_ids(row)
|
|
607
|
+
|
|
608
|
+
# Check if student/group needs grading
|
|
609
|
+
num_group_members_need_grade_per_item = [
|
|
610
|
+
item.num_grades_needed_deductions(net_ids) for item in self.items
|
|
611
|
+
]
|
|
612
|
+
|
|
613
|
+
if sum(num_group_members_need_grade_per_item) == 0:
|
|
614
|
+
return (net_ids, None, "Already graded", None) # None indicates skip
|
|
615
|
+
|
|
616
|
+
# Create a temp file to capture output early so all messages go to it
|
|
617
|
+
# pylint: disable-next=consider-using-with
|
|
618
|
+
log_file = tempfile.NamedTemporaryFile(
|
|
619
|
+
mode="w", delete=False, suffix=".log", prefix=f"ygrader_{net_ids[0]}_"
|
|
620
|
+
)
|
|
621
|
+
log_path = log_file.name
|
|
622
|
+
|
|
623
|
+
student_work_path = self.work_path / utils.names_to_dir(
|
|
624
|
+
first_names, last_names, net_ids
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
# Build student info dict to pass to helper
|
|
628
|
+
student_info = {
|
|
629
|
+
"row": row,
|
|
630
|
+
"net_ids": net_ids,
|
|
631
|
+
"first_names": first_names,
|
|
632
|
+
"last_names": last_names,
|
|
633
|
+
"student_work_path": student_work_path,
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
# Run the build steps and capture any failure
|
|
637
|
+
success, message = self._run_build_steps(student_info, log_file)
|
|
638
|
+
|
|
639
|
+
log_file.close()
|
|
640
|
+
return (net_ids, success, message, log_path)
|
|
641
|
+
|
|
642
|
+
def _run_build_steps( # pylint: disable=too-many-return-statements
|
|
643
|
+
self, student_info, log_file
|
|
644
|
+
):
|
|
645
|
+
"""Run the build steps for a single student. Returns (success, message)."""
|
|
646
|
+
row = student_info["row"]
|
|
647
|
+
student_work_path = student_info["student_work_path"]
|
|
648
|
+
|
|
649
|
+
# Get student code
|
|
650
|
+
try:
|
|
651
|
+
success = self._get_student_code(row, student_work_path, output=log_file)
|
|
652
|
+
if not success:
|
|
653
|
+
return (False, "Failed to get student code")
|
|
654
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
655
|
+
return (False, f"Error getting code: {e}")
|
|
656
|
+
|
|
657
|
+
# Format student code
|
|
658
|
+
if self.format_code:
|
|
659
|
+
try:
|
|
660
|
+
utils.clang_format_code(student_work_path)
|
|
661
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
662
|
+
return (False, f"Error formatting code: {e}")
|
|
663
|
+
|
|
664
|
+
callback_args = {}
|
|
665
|
+
callback_args["lab_name"] = self.lab_name
|
|
666
|
+
callback_args["student_code_path"] = student_work_path
|
|
667
|
+
callback_args["run"] = False # build_only mode
|
|
668
|
+
callback_args["first_names"] = student_info["first_names"]
|
|
669
|
+
callback_args["last_names"] = student_info["last_names"]
|
|
670
|
+
callback_args["net_ids"] = student_info["net_ids"]
|
|
671
|
+
if self.code_source == CodeSource.GITHUB:
|
|
672
|
+
callback_args["repo_url"] = row["github_url"]
|
|
673
|
+
callback_args["tag"] = self.github_tag
|
|
674
|
+
if "Section Number" in row:
|
|
675
|
+
callback_args["section"] = row["Section Number"]
|
|
676
|
+
if "Course Homework ID" in row:
|
|
677
|
+
callback_args["homework_id"] = row["Course Homework ID"]
|
|
678
|
+
|
|
679
|
+
if self.prep_fcn is not None:
|
|
680
|
+
try:
|
|
681
|
+
self.prep_fcn(
|
|
682
|
+
**callback_args,
|
|
683
|
+
build=True,
|
|
684
|
+
)
|
|
685
|
+
except CallbackFailed as e:
|
|
686
|
+
return (False, f"Prep failed: {e}")
|
|
687
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
688
|
+
return (False, f"Prep error: {e}")
|
|
689
|
+
|
|
690
|
+
# Call the grading callback for each item (with build=True, run=False for build_only mode)
|
|
691
|
+
for item in self.items:
|
|
692
|
+
# Add item-specific args
|
|
693
|
+
item_callback_args = callback_args.copy()
|
|
694
|
+
item_callback_args["item_name"] = item.item_name
|
|
695
|
+
if item.fcn_args_dict:
|
|
696
|
+
item_callback_args.update(item.fcn_args_dict)
|
|
697
|
+
|
|
698
|
+
try:
|
|
699
|
+
item.fcn(
|
|
700
|
+
**item_callback_args,
|
|
701
|
+
points=item.max_points,
|
|
702
|
+
build=True,
|
|
703
|
+
output=log_file, # Redirect output to temp file
|
|
704
|
+
)
|
|
705
|
+
except CallbackFailed as e:
|
|
706
|
+
return (False, f"Grading callback failed: {e}")
|
|
707
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
708
|
+
return (False, f"Grading callback error: {e}")
|
|
709
|
+
|
|
710
|
+
return (True, "Success")
|
|
711
|
+
|
|
712
|
+
def _run_grading_parallel(self, _student_grades_df, grouped_df):
|
|
713
|
+
"""Run grading in parallel for build_only mode."""
|
|
714
|
+
# Sort by last name for consistent ordering
|
|
715
|
+
sorted_df = grouped_df.sort_values(
|
|
716
|
+
by="Last Name", key=lambda x: x.apply(lambda names: names[0].lower())
|
|
717
|
+
)
|
|
718
|
+
|
|
719
|
+
rows_list = list(sorted_df.iterrows())
|
|
720
|
+
total = len(rows_list)
|
|
721
|
+
|
|
722
|
+
print_color(
|
|
723
|
+
TermColors.BLUE,
|
|
724
|
+
f"Running parallel build with {self.parallel_workers} workers for {total} students...\n",
|
|
725
|
+
)
|
|
726
|
+
|
|
727
|
+
success_count = 0
|
|
728
|
+
fail_count = 0
|
|
729
|
+
skip_count = 0
|
|
730
|
+
|
|
731
|
+
# Use a lock for thread-safe printing
|
|
732
|
+
print_lock = threading.Lock()
|
|
733
|
+
|
|
734
|
+
def process_and_report(row):
|
|
735
|
+
nonlocal success_count, fail_count, skip_count
|
|
736
|
+
net_ids, success, message, log_path = self._process_single_student_build(
|
|
737
|
+
row
|
|
738
|
+
)
|
|
739
|
+
net_id_str = (
|
|
740
|
+
", ".join(net_ids) if isinstance(net_ids, list) else str(net_ids)
|
|
741
|
+
)
|
|
742
|
+
|
|
743
|
+
with print_lock:
|
|
744
|
+
if success is None:
|
|
745
|
+
skip_count += 1
|
|
746
|
+
# Don't print skipped students
|
|
747
|
+
elif success:
|
|
748
|
+
success_count += 1
|
|
749
|
+
if log_path:
|
|
750
|
+
print_color(
|
|
751
|
+
TermColors.GREEN,
|
|
752
|
+
f"[DONE] {net_id_str} - {message} (see {log_path})",
|
|
753
|
+
)
|
|
754
|
+
else:
|
|
755
|
+
print_color(
|
|
756
|
+
TermColors.GREEN, f"[DONE] {net_id_str} - {message}"
|
|
757
|
+
)
|
|
758
|
+
else:
|
|
759
|
+
fail_count += 1
|
|
760
|
+
if log_path:
|
|
761
|
+
print_color(
|
|
762
|
+
TermColors.RED,
|
|
763
|
+
f"[FAIL] {net_id_str} - {message} (see {log_path})",
|
|
764
|
+
)
|
|
765
|
+
else:
|
|
766
|
+
print_color(TermColors.RED, f"[FAIL] {net_id_str} - {message}")
|
|
767
|
+
|
|
768
|
+
with ThreadPoolExecutor(max_workers=self.parallel_workers) as executor:
|
|
769
|
+
futures = []
|
|
770
|
+
for i, (_, row) in enumerate(rows_list):
|
|
771
|
+
futures.append(executor.submit(process_and_report, row))
|
|
772
|
+
# Stagger thread starts to avoid overwhelming SSH servers
|
|
773
|
+
if i < self.parallel_workers:
|
|
774
|
+
time.sleep(0.5)
|
|
775
|
+
|
|
776
|
+
# Wait for all to complete
|
|
777
|
+
for future in as_completed(futures):
|
|
778
|
+
try:
|
|
779
|
+
future.result()
|
|
780
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
781
|
+
with print_lock:
|
|
782
|
+
print_color(TermColors.RED, f"[ERROR] Unexpected error: {e}")
|
|
783
|
+
|
|
784
|
+
print_color(
|
|
785
|
+
TermColors.BLUE,
|
|
786
|
+
f"\nCompleted: {success_count} success, {fail_count} failed, {skip_count} skipped",
|
|
787
|
+
)
|
|
788
|
+
|
|
564
789
|
def _unzip_submissions(self):
|
|
565
790
|
with zipfile.ZipFile(self.learning_suite_submissions_zip_path, "r") as f:
|
|
566
791
|
for zip_info in f.infolist():
|
|
@@ -638,31 +863,38 @@ class Grader:
|
|
|
638
863
|
# Group students into their groups
|
|
639
864
|
return df.groupby(groupby_column).agg(list).reset_index()
|
|
640
865
|
|
|
641
|
-
def _get_student_code(self, row, student_work_path):
|
|
866
|
+
def _get_student_code(self, row, student_work_path, output=None):
|
|
642
867
|
if self.code_source == CodeSource.GITHUB:
|
|
643
|
-
return self._get_student_code_github(row, student_work_path)
|
|
868
|
+
return self._get_student_code_github(row, student_work_path, output=output)
|
|
644
869
|
|
|
645
870
|
# else:
|
|
646
|
-
return self._get_student_code_learning_suite(
|
|
871
|
+
return self._get_student_code_learning_suite(
|
|
872
|
+
row, student_work_path, output=output
|
|
873
|
+
)
|
|
647
874
|
|
|
648
|
-
def _get_student_code_github(self, row, student_work_path):
|
|
875
|
+
def _get_student_code_github(self, row, student_work_path, output=None):
|
|
876
|
+
if output is None:
|
|
877
|
+
output = sys.stdout
|
|
649
878
|
student_work_path.mkdir(parents=True, exist_ok=True)
|
|
650
879
|
|
|
651
880
|
# Clone student repo
|
|
652
|
-
print("Student repo url: " + row["github_url"])
|
|
881
|
+
print("Student repo url: " + row["github_url"], file=output)
|
|
653
882
|
if not student_repos.clone_repo(
|
|
654
|
-
row["github_url"], self.github_tag, student_work_path
|
|
883
|
+
row["github_url"], self.github_tag, student_work_path, output=output
|
|
655
884
|
):
|
|
656
885
|
return False
|
|
657
886
|
return True
|
|
658
887
|
|
|
659
|
-
def _get_student_code_learning_suite(self, row, student_work_path):
|
|
888
|
+
def _get_student_code_learning_suite(self, row, student_work_path, output=None):
|
|
889
|
+
if output is None:
|
|
890
|
+
output = sys.stdout
|
|
660
891
|
print(
|
|
661
|
-
"Extracting submitted files for
|
|
892
|
+
f"Extracting submitted files for {grades_csv.get_concated_names(row)}...",
|
|
893
|
+
file=output,
|
|
662
894
|
)
|
|
663
895
|
if student_work_path.is_dir() and not directory_is_empty(student_work_path):
|
|
664
896
|
# Code already extracted from Zip, return
|
|
665
|
-
print(" Files already extracted previously.")
|
|
897
|
+
print(" Files already extracted previously.", file=output)
|
|
666
898
|
return True
|
|
667
899
|
|
|
668
900
|
student_work_path.mkdir(parents=True, exist_ok=True)
|
|
@@ -769,7 +1001,7 @@ class Grader:
|
|
|
769
1001
|
self.work_path.mkdir(exist_ok=True, parents=True)
|
|
770
1002
|
|
|
771
1003
|
|
|
772
|
-
def _verify_callback_fcn(fcn, item, fcn_extra_args_dict=None):
|
|
1004
|
+
def _verify_callback_fcn(fcn, item, fcn_extra_args_dict=None, github_configured=False):
|
|
773
1005
|
callback_args = [
|
|
774
1006
|
"lab_name",
|
|
775
1007
|
"item_name",
|
|
@@ -783,6 +1015,12 @@ def _verify_callback_fcn(fcn, item, fcn_extra_args_dict=None):
|
|
|
783
1015
|
if item:
|
|
784
1016
|
if item.max_points:
|
|
785
1017
|
callback_args.append("max_points")
|
|
1018
|
+
if github_configured:
|
|
1019
|
+
callback_args.append("repo_url")
|
|
1020
|
+
callback_args.append("tag")
|
|
1021
|
+
|
|
1022
|
+
# output is always provided (sys.stdout in sequential, temp file in parallel)
|
|
1023
|
+
callback_args.append("output")
|
|
786
1024
|
|
|
787
1025
|
callback_args_optional = [
|
|
788
1026
|
"section",
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""Module for running builds on remote machines via SSH."""
|
|
2
|
+
|
|
3
|
+
import pathlib
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from typing import List, Optional, TextIO, Tuple
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class RemoteBuildError(Exception):
|
|
10
|
+
"""Exception raised when a remote build operation fails."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, message: str, command_output: str = ""):
|
|
13
|
+
self.message = message
|
|
14
|
+
self.command_output = command_output
|
|
15
|
+
super().__init__(self.message)
|
|
16
|
+
|
|
17
|
+
def __str__(self):
|
|
18
|
+
if self.command_output:
|
|
19
|
+
return f"{self.message}\n\nCommand output:\n{self.command_output}"
|
|
20
|
+
return self.message
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def run_remote_build( # pylint: disable=too-many-positional-arguments
|
|
24
|
+
remote_host: str,
|
|
25
|
+
remote_work_path: str,
|
|
26
|
+
repo_url: str,
|
|
27
|
+
tag: str,
|
|
28
|
+
commands: List[Tuple[str, List[str]]],
|
|
29
|
+
files_to_copy: List[str],
|
|
30
|
+
student_code_path: pathlib.Path,
|
|
31
|
+
cleanup: bool = True,
|
|
32
|
+
use_username_subdir: bool = True,
|
|
33
|
+
env_setup: str = "",
|
|
34
|
+
output: Optional[TextIO] = None,
|
|
35
|
+
) -> List[str]:
|
|
36
|
+
"""Run a build on a remote machine and copy files back.
|
|
37
|
+
|
|
38
|
+
This function:
|
|
39
|
+
1. Connects to the remote host via SSH
|
|
40
|
+
2. Clones the student repo at the specified tag
|
|
41
|
+
3. Runs the provided commands in sequence
|
|
42
|
+
4. Copies the specified files back to the local machine
|
|
43
|
+
5. Optionally cleans up the remote clone
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
remote_host: SSH hostname (e.g., "server.example.com" or "user@server")
|
|
47
|
+
remote_work_path: Base path on remote machine where repo will be cloned
|
|
48
|
+
(e.g., "/tmp/grading"). By default, a subdirectory named after the
|
|
49
|
+
remote username will be created under this path.
|
|
50
|
+
repo_url: Git repository URL to clone
|
|
51
|
+
tag: Git tag to checkout after cloning
|
|
52
|
+
commands: List of (relative_path, command_list) tuples. Each command
|
|
53
|
+
will be run from the specified path relative to the repo root.
|
|
54
|
+
Example: [("lab_tools/adder", ["make", "sim"])]
|
|
55
|
+
files_to_copy: List of file paths relative to repo root to copy back
|
|
56
|
+
student_code_path: Local student code directory to copy files into
|
|
57
|
+
cleanup: If True (default), delete the remote clone after completion
|
|
58
|
+
use_username_subdir: If True (default), create a subdirectory under
|
|
59
|
+
remote_work_path named after the remote user's username. This helps
|
|
60
|
+
avoid conflicts when multiple users share the same work path.
|
|
61
|
+
env_setup: Optional shell command(s) to run before each command to set up
|
|
62
|
+
the environment (e.g., "source /path/to/settings.sh"). This is prepended
|
|
63
|
+
to each command with "&&".
|
|
64
|
+
output: Optional file object to write command output to. If None, uses
|
|
65
|
+
sys.stdout.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
List of command output strings (stdout + stderr combined) for each command
|
|
69
|
+
|
|
70
|
+
Raises:
|
|
71
|
+
RemoteBuildError: If SSH connection fails, clone fails, any command fails,
|
|
72
|
+
or file copy fails
|
|
73
|
+
"""
|
|
74
|
+
if output is None:
|
|
75
|
+
output = sys.stdout
|
|
76
|
+
|
|
77
|
+
# Extract repo name from URL for the clone directory
|
|
78
|
+
# Handle both https://github.com/org/repo.git and git@github.com:org/repo.git
|
|
79
|
+
repo_name = repo_url.rstrip("/").rstrip(".git").split("/")[-1]
|
|
80
|
+
if ":" in repo_name:
|
|
81
|
+
repo_name = repo_name.split(":")[-1].split("/")[-1]
|
|
82
|
+
|
|
83
|
+
command_outputs = []
|
|
84
|
+
remote_repo_path = None # Initialize to None for cleanup check
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
# Test SSH connection and get username if needed
|
|
88
|
+
_run_ssh_command(remote_host, ["echo", "SSH connection successful"])
|
|
89
|
+
|
|
90
|
+
# Determine the actual work path (with optional username subdirectory)
|
|
91
|
+
if use_username_subdir:
|
|
92
|
+
username = _run_ssh_command(remote_host, ["whoami"]).strip()
|
|
93
|
+
actual_work_path = f"{remote_work_path}/{username}"
|
|
94
|
+
else:
|
|
95
|
+
actual_work_path = remote_work_path
|
|
96
|
+
|
|
97
|
+
remote_repo_path = f"{actual_work_path}/{repo_name}"
|
|
98
|
+
|
|
99
|
+
# Create work directory and clone repo
|
|
100
|
+
clone_commands = [
|
|
101
|
+
f"mkdir -p {actual_work_path}",
|
|
102
|
+
f"rm -rf {remote_repo_path}", # Clean any previous clone
|
|
103
|
+
f"git clone --depth 1 --branch {tag} {repo_url} {remote_repo_path}",
|
|
104
|
+
]
|
|
105
|
+
for cmd in clone_commands:
|
|
106
|
+
print(f"[SSH] {cmd}", file=output)
|
|
107
|
+
cmd_output = _run_ssh_command(remote_host, [cmd])
|
|
108
|
+
if cmd_output.strip():
|
|
109
|
+
print(cmd_output, file=output)
|
|
110
|
+
|
|
111
|
+
# Run each command in sequence
|
|
112
|
+
for relative_path, cmd_list in commands:
|
|
113
|
+
work_dir = (
|
|
114
|
+
f"{remote_repo_path}/{relative_path}"
|
|
115
|
+
if relative_path
|
|
116
|
+
else remote_repo_path
|
|
117
|
+
)
|
|
118
|
+
# Build the full command with cd and optional env setup
|
|
119
|
+
cmd_str = " ".join(cmd_list)
|
|
120
|
+
if env_setup:
|
|
121
|
+
full_cmd = f"{env_setup} && cd {work_dir} && {cmd_str}"
|
|
122
|
+
else:
|
|
123
|
+
full_cmd = f"cd {work_dir} && {cmd_str}"
|
|
124
|
+
print(f"[SSH] cd {work_dir} && {cmd_str}", file=output)
|
|
125
|
+
cmd_output = _run_ssh_command(remote_host, [full_cmd])
|
|
126
|
+
if cmd_output.strip():
|
|
127
|
+
print(cmd_output, file=output)
|
|
128
|
+
command_outputs.append(cmd_output)
|
|
129
|
+
|
|
130
|
+
# Copy files back using scp
|
|
131
|
+
for file_path in files_to_copy:
|
|
132
|
+
remote_file = f"{remote_repo_path}/{file_path}"
|
|
133
|
+
local_file = student_code_path / file_path
|
|
134
|
+
|
|
135
|
+
# Create local directory if needed
|
|
136
|
+
local_file.parent.mkdir(parents=True, exist_ok=True)
|
|
137
|
+
|
|
138
|
+
print(f"[SCP] {remote_file} -> {local_file}", file=output)
|
|
139
|
+
_run_scp_command(remote_host, remote_file, local_file)
|
|
140
|
+
|
|
141
|
+
finally:
|
|
142
|
+
# Cleanup remote directory if requested
|
|
143
|
+
if cleanup and remote_repo_path is not None:
|
|
144
|
+
try:
|
|
145
|
+
_run_ssh_command(remote_host, [f"rm -rf {remote_repo_path}"])
|
|
146
|
+
except RemoteBuildError:
|
|
147
|
+
pass # Ignore cleanup errors
|
|
148
|
+
|
|
149
|
+
return command_outputs
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _run_ssh_command(remote_host: str, command: List[str]) -> str:
|
|
153
|
+
"""Run a command on the remote host via SSH.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
remote_host: SSH hostname
|
|
157
|
+
command: Command and arguments to run
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
Combined stdout and stderr output
|
|
161
|
+
|
|
162
|
+
Raises:
|
|
163
|
+
RemoteBuildError: If the SSH command fails
|
|
164
|
+
"""
|
|
165
|
+
ssh_cmd = [
|
|
166
|
+
"ssh",
|
|
167
|
+
"-x", # Disable X11 forwarding
|
|
168
|
+
"-o",
|
|
169
|
+
"BatchMode=yes", # Fail if password is required
|
|
170
|
+
"-o",
|
|
171
|
+
"StrictHostKeyChecking=accept-new", # Accept new host keys
|
|
172
|
+
"-o",
|
|
173
|
+
"ConnectTimeout=10",
|
|
174
|
+
remote_host,
|
|
175
|
+
] + command
|
|
176
|
+
|
|
177
|
+
try:
|
|
178
|
+
result = subprocess.run(
|
|
179
|
+
ssh_cmd,
|
|
180
|
+
capture_output=True,
|
|
181
|
+
text=True,
|
|
182
|
+
check=False,
|
|
183
|
+
timeout=300, # 5 minute timeout per command
|
|
184
|
+
)
|
|
185
|
+
except subprocess.TimeoutExpired as e:
|
|
186
|
+
raise RemoteBuildError(
|
|
187
|
+
f"SSH command timed out: {' '.join(command)}",
|
|
188
|
+
e.stdout or "",
|
|
189
|
+
) from e
|
|
190
|
+
except FileNotFoundError as e:
|
|
191
|
+
raise RemoteBuildError(
|
|
192
|
+
"SSH client not found. Is OpenSSH installed?",
|
|
193
|
+
) from e
|
|
194
|
+
|
|
195
|
+
output = result.stdout + result.stderr
|
|
196
|
+
|
|
197
|
+
if result.returncode != 0:
|
|
198
|
+
raise RemoteBuildError(
|
|
199
|
+
f"SSH command failed with exit code {result.returncode}: {' '.join(command)}",
|
|
200
|
+
output,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
return output
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _run_scp_command(
|
|
207
|
+
remote_host: str, remote_path: str, local_path: pathlib.Path
|
|
208
|
+
) -> None:
|
|
209
|
+
"""Copy a file or directory from the remote host via SCP.
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
remote_host: SSH hostname
|
|
213
|
+
remote_path: Path to file or directory on remote host
|
|
214
|
+
local_path: Local path to copy to
|
|
215
|
+
|
|
216
|
+
Raises:
|
|
217
|
+
RemoteBuildError: If the SCP command fails
|
|
218
|
+
"""
|
|
219
|
+
scp_cmd = [
|
|
220
|
+
"scp",
|
|
221
|
+
"-r", # Recursive copy (works for both files and directories)
|
|
222
|
+
"-o",
|
|
223
|
+
"BatchMode=yes",
|
|
224
|
+
"-o",
|
|
225
|
+
"StrictHostKeyChecking=accept-new",
|
|
226
|
+
"-o",
|
|
227
|
+
"ConnectTimeout=10",
|
|
228
|
+
"-o",
|
|
229
|
+
"ForwardX11=no",
|
|
230
|
+
f"{remote_host}:{remote_path}",
|
|
231
|
+
str(local_path),
|
|
232
|
+
]
|
|
233
|
+
|
|
234
|
+
try:
|
|
235
|
+
result = subprocess.run(
|
|
236
|
+
scp_cmd,
|
|
237
|
+
capture_output=True,
|
|
238
|
+
text=True,
|
|
239
|
+
check=False,
|
|
240
|
+
timeout=300, # 5 minute timeout for potentially large directory copies
|
|
241
|
+
)
|
|
242
|
+
except subprocess.TimeoutExpired as e:
|
|
243
|
+
raise RemoteBuildError(
|
|
244
|
+
f"SCP timed out copying: {remote_path}",
|
|
245
|
+
e.stdout or "",
|
|
246
|
+
) from e
|
|
247
|
+
except FileNotFoundError as e:
|
|
248
|
+
raise RemoteBuildError(
|
|
249
|
+
"SCP client not found. Is OpenSSH installed?",
|
|
250
|
+
) from e
|
|
251
|
+
|
|
252
|
+
if result.returncode != 0:
|
|
253
|
+
output = result.stdout + result.stderr
|
|
254
|
+
raise RemoteBuildError(
|
|
255
|
+
f"Failed to copy: {remote_path}",
|
|
256
|
+
output,
|
|
257
|
+
)
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""Manages student repositories when using Github sumission system"""
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
import re
|
|
7
|
+
import tempfile
|
|
8
|
+
|
|
9
|
+
from .utils import print_color, TermColors
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def clone_repo(git_path, tag, student_repo_path, output=None):
|
|
13
|
+
"""Clone the student repository
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
git_path: URL to the git repository
|
|
17
|
+
tag: Tag or branch to checkout
|
|
18
|
+
student_repo_path: Path to clone into
|
|
19
|
+
output: File handle to write output to (defaults to sys.stdout)
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
True if successful, False otherwise
|
|
23
|
+
"""
|
|
24
|
+
if output is None:
|
|
25
|
+
output = sys.stdout
|
|
26
|
+
|
|
27
|
+
# Track whether we're outputting to stdout (vs a log file)
|
|
28
|
+
output_to_stdout = output is sys.stdout
|
|
29
|
+
|
|
30
|
+
if student_repo_path.is_dir() and list(student_repo_path.iterdir()):
|
|
31
|
+
return _fetch_and_checkout(student_repo_path, tag, output, output_to_stdout)
|
|
32
|
+
|
|
33
|
+
return _clone_fresh(git_path, tag, student_repo_path, output, output_to_stdout)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _fetch_and_checkout(student_repo_path, tag, output, output_to_stdout):
|
|
37
|
+
"""Fetch and checkout when repo already exists."""
|
|
38
|
+
msg = f"Student repo {student_repo_path.name} already cloned. Re-fetching tag"
|
|
39
|
+
if output_to_stdout:
|
|
40
|
+
print_color(TermColors.BLUE, msg)
|
|
41
|
+
else:
|
|
42
|
+
print(msg, file=output)
|
|
43
|
+
|
|
44
|
+
# Fetch
|
|
45
|
+
cmd = ["git", "fetch", "--tags", "-f"]
|
|
46
|
+
try:
|
|
47
|
+
result = subprocess.run(
|
|
48
|
+
cmd,
|
|
49
|
+
cwd=student_repo_path,
|
|
50
|
+
check=True,
|
|
51
|
+
capture_output=True,
|
|
52
|
+
text=True,
|
|
53
|
+
)
|
|
54
|
+
if result.stdout:
|
|
55
|
+
print(result.stdout, file=output, end="")
|
|
56
|
+
if result.stderr:
|
|
57
|
+
print(result.stderr, file=output, end="")
|
|
58
|
+
except subprocess.CalledProcessError as e:
|
|
59
|
+
msg = f"git fetch failed: {e}"
|
|
60
|
+
if output_to_stdout:
|
|
61
|
+
print_color(TermColors.RED, msg)
|
|
62
|
+
else:
|
|
63
|
+
print(msg, file=output)
|
|
64
|
+
if e.stdout:
|
|
65
|
+
print(e.stdout, file=output, end="")
|
|
66
|
+
if e.stderr:
|
|
67
|
+
print(e.stderr, file=output, end="")
|
|
68
|
+
return False
|
|
69
|
+
|
|
70
|
+
# Checkout tag
|
|
71
|
+
if tag is None:
|
|
72
|
+
# Get the default branch
|
|
73
|
+
stdout = subprocess.run(
|
|
74
|
+
["git", "symbolic-ref", "refs/remotes/origin/HEAD", "--short"],
|
|
75
|
+
cwd=student_repo_path,
|
|
76
|
+
check=True,
|
|
77
|
+
capture_output=True,
|
|
78
|
+
universal_newlines=True,
|
|
79
|
+
).stdout
|
|
80
|
+
tag = stdout.split("/")[1].strip()
|
|
81
|
+
|
|
82
|
+
if tag not in ("master", "main"):
|
|
83
|
+
tag = "tags/" + tag
|
|
84
|
+
cmd = ["git", "checkout", tag, "-f"]
|
|
85
|
+
try:
|
|
86
|
+
result = subprocess.run(
|
|
87
|
+
cmd,
|
|
88
|
+
cwd=student_repo_path,
|
|
89
|
+
check=True,
|
|
90
|
+
capture_output=True,
|
|
91
|
+
text=True,
|
|
92
|
+
)
|
|
93
|
+
if result.stdout:
|
|
94
|
+
print(result.stdout, file=output, end="")
|
|
95
|
+
if result.stderr:
|
|
96
|
+
print(result.stderr, file=output, end="")
|
|
97
|
+
except subprocess.CalledProcessError as e:
|
|
98
|
+
msg = f"git checkout of tag failed: {e}"
|
|
99
|
+
if output_to_stdout:
|
|
100
|
+
print_color(TermColors.RED, msg)
|
|
101
|
+
else:
|
|
102
|
+
print(msg, file=output)
|
|
103
|
+
if e.stdout:
|
|
104
|
+
print(e.stdout, file=output, end="")
|
|
105
|
+
if e.stderr:
|
|
106
|
+
print(e.stderr, file=output, end="")
|
|
107
|
+
return False
|
|
108
|
+
|
|
109
|
+
return True
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _clone_fresh(git_path, tag, student_repo_path, output, output_to_stdout):
|
|
113
|
+
"""Clone a fresh copy of the repo."""
|
|
114
|
+
msg = f"Cloning repo, tag = {tag}"
|
|
115
|
+
if output_to_stdout:
|
|
116
|
+
print_color(TermColors.BLUE, msg)
|
|
117
|
+
else:
|
|
118
|
+
print(msg, file=output)
|
|
119
|
+
|
|
120
|
+
if tag:
|
|
121
|
+
cmd = [
|
|
122
|
+
"git",
|
|
123
|
+
"clone",
|
|
124
|
+
"--branch",
|
|
125
|
+
tag,
|
|
126
|
+
git_path,
|
|
127
|
+
str(student_repo_path.absolute()),
|
|
128
|
+
]
|
|
129
|
+
else:
|
|
130
|
+
cmd = ["git", "clone", git_path, str(student_repo_path.absolute())]
|
|
131
|
+
|
|
132
|
+
# If output was explicitly provided (e.g., a log file), write directly to it.
|
|
133
|
+
# Otherwise (stdout), redirect clone output to a temporary log file to keep terminal clean.
|
|
134
|
+
if not output_to_stdout:
|
|
135
|
+
return _clone_to_output(cmd, student_repo_path, output)
|
|
136
|
+
|
|
137
|
+
return _clone_to_temp_log(cmd, student_repo_path)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _clone_to_output(cmd, student_repo_path, output):
|
|
141
|
+
"""Clone and write output to the provided file handle."""
|
|
142
|
+
try:
|
|
143
|
+
result = subprocess.run(
|
|
144
|
+
cmd,
|
|
145
|
+
check=True,
|
|
146
|
+
capture_output=True,
|
|
147
|
+
text=True,
|
|
148
|
+
)
|
|
149
|
+
if result.stdout:
|
|
150
|
+
print(result.stdout, file=output, end="")
|
|
151
|
+
if result.stderr:
|
|
152
|
+
print(result.stderr, file=output, end="")
|
|
153
|
+
except KeyboardInterrupt:
|
|
154
|
+
shutil.rmtree(str(student_repo_path))
|
|
155
|
+
sys.exit(-1)
|
|
156
|
+
except subprocess.CalledProcessError as e:
|
|
157
|
+
print("Clone failed", file=output)
|
|
158
|
+
if e.stdout:
|
|
159
|
+
print(e.stdout, file=output, end="")
|
|
160
|
+
if e.stderr:
|
|
161
|
+
print(e.stderr, file=output, end="")
|
|
162
|
+
return False
|
|
163
|
+
return True
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _clone_to_temp_log(cmd, student_repo_path):
|
|
167
|
+
"""Clone and write output to a temporary log file."""
|
|
168
|
+
log_path = None
|
|
169
|
+
try:
|
|
170
|
+
with tempfile.NamedTemporaryFile(
|
|
171
|
+
delete=False, dir="/tmp", prefix="ygrader_clone_", suffix=".log"
|
|
172
|
+
) as tmp_log:
|
|
173
|
+
log_path = tmp_log.name
|
|
174
|
+
subprocess.run(cmd, check=True, stdout=tmp_log, stderr=subprocess.STDOUT)
|
|
175
|
+
# Inform user where the clone output was written
|
|
176
|
+
if log_path:
|
|
177
|
+
print(f"git clone output logged to {log_path}")
|
|
178
|
+
except KeyboardInterrupt:
|
|
179
|
+
shutil.rmtree(str(student_repo_path))
|
|
180
|
+
sys.exit(-1)
|
|
181
|
+
except subprocess.CalledProcessError:
|
|
182
|
+
print_color(TermColors.RED, "Clone failed")
|
|
183
|
+
if log_path:
|
|
184
|
+
print_color(TermColors.YELLOW, "See log:", log_path)
|
|
185
|
+
return False
|
|
186
|
+
return True
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def convert_github_url_format(url, to_https):
|
|
190
|
+
""" " Convert a github URL to either HTTPS or SSH format
|
|
191
|
+
|
|
192
|
+
If to_https is True, URLs will be converted to https://github.com/org/repo format:
|
|
193
|
+
|
|
194
|
+
>>> convert_github_url_format("git@github.com:byu-ecen123-classroom/123-labs-username01.git", True)
|
|
195
|
+
'https://github.com/byu-ecen123-classroom/123-labs-username01'
|
|
196
|
+
|
|
197
|
+
If to_https is False then SSH format is assumed, and URLs will be converted to git@github.com:org/repo format:
|
|
198
|
+
|
|
199
|
+
>>> convert_github_url_format("https://github.com/byu-ecen123-classroom/123-labs-username01", False)
|
|
200
|
+
'git@github.com:byu-ecen123-classroom/123-labs-username01.git'
|
|
201
|
+
|
|
202
|
+
It also works if the student provides the repo with ".git" extension
|
|
203
|
+
|
|
204
|
+
>>> convert_github_url_format("https://github.com/byu-ecen123-classroom/123-labs-username01.git", True)
|
|
205
|
+
'https://github.com/byu-ecen123-classroom/123-labs-username01'
|
|
206
|
+
|
|
207
|
+
Any invalid format is returned with modification:
|
|
208
|
+
|
|
209
|
+
>>> convert_github_url_format("invalid format", True)
|
|
210
|
+
'invalid format'
|
|
211
|
+
|
|
212
|
+
"""
|
|
213
|
+
org = repo = None
|
|
214
|
+
|
|
215
|
+
match = re.search(r"git@github\.com:(.*?)/(.*?).git", url)
|
|
216
|
+
if match:
|
|
217
|
+
org = match.group(1)
|
|
218
|
+
repo = match.group(2)
|
|
219
|
+
match = re.search(r"github\.com/(.*?)/(.*)", url)
|
|
220
|
+
if match:
|
|
221
|
+
org = match.group(1)
|
|
222
|
+
repo = match.group(2)
|
|
223
|
+
|
|
224
|
+
# Remove .git
|
|
225
|
+
if repo is not None and repo.endswith(".git"):
|
|
226
|
+
repo = repo[:-4]
|
|
227
|
+
|
|
228
|
+
if org is not None:
|
|
229
|
+
if to_https:
|
|
230
|
+
return "https://github.com/" + org + "/" + repo
|
|
231
|
+
return "git@github.com:" + org + "/" + repo + ".git"
|
|
232
|
+
return url
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def print_date(student_repo_path):
|
|
236
|
+
"""Print the last commit date to the repo"""
|
|
237
|
+
print("Last commit: ")
|
|
238
|
+
cmd = ["git", "log", "-1", r"--format=%cd"]
|
|
239
|
+
subprocess.run(cmd, cwd=str(student_repo_path), check=False)
|
|
@@ -1,138 +0,0 @@
|
|
|
1
|
-
"""Manages student repositories when using Github sumission system"""
|
|
2
|
-
|
|
3
|
-
import shutil
|
|
4
|
-
import subprocess
|
|
5
|
-
import sys
|
|
6
|
-
import re
|
|
7
|
-
import tempfile
|
|
8
|
-
|
|
9
|
-
from .utils import print_color, TermColors
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
def clone_repo(git_path, tag, student_repo_path):
|
|
13
|
-
"""Clone the student repository"""
|
|
14
|
-
|
|
15
|
-
if student_repo_path.is_dir() and list(student_repo_path.iterdir()):
|
|
16
|
-
print_color(
|
|
17
|
-
TermColors.BLUE,
|
|
18
|
-
"Student repo",
|
|
19
|
-
student_repo_path.name,
|
|
20
|
-
"already cloned. Re-fetching tag",
|
|
21
|
-
)
|
|
22
|
-
|
|
23
|
-
# Fetch
|
|
24
|
-
cmd = ["git", "fetch", "--tags", "-f"]
|
|
25
|
-
try:
|
|
26
|
-
subprocess.run(cmd, cwd=student_repo_path, check=True)
|
|
27
|
-
except subprocess.CalledProcessError:
|
|
28
|
-
print_color(TermColors.RED, "git fetch failed")
|
|
29
|
-
return False
|
|
30
|
-
|
|
31
|
-
# Checkout tag
|
|
32
|
-
if tag is None:
|
|
33
|
-
# Get the default branch
|
|
34
|
-
stdout = subprocess.run(
|
|
35
|
-
["git", "symbolic-ref", "refs/remotes/origin/HEAD", "--short"],
|
|
36
|
-
cwd=student_repo_path,
|
|
37
|
-
check=True,
|
|
38
|
-
capture_output=True,
|
|
39
|
-
universal_newlines=True,
|
|
40
|
-
).stdout
|
|
41
|
-
tag = stdout.split("/")[1].strip()
|
|
42
|
-
|
|
43
|
-
if tag not in ("master", "main"):
|
|
44
|
-
tag = "tags/" + tag
|
|
45
|
-
cmd = ["git", "checkout", tag, "-f"]
|
|
46
|
-
try:
|
|
47
|
-
subprocess.run(cmd, cwd=student_repo_path, check=True)
|
|
48
|
-
except subprocess.CalledProcessError:
|
|
49
|
-
print_color(TermColors.RED, "git checkout of tag failed")
|
|
50
|
-
return False
|
|
51
|
-
return True
|
|
52
|
-
|
|
53
|
-
print_color(TermColors.BLUE, "Cloning repo, tag =", tag)
|
|
54
|
-
if tag:
|
|
55
|
-
cmd = [
|
|
56
|
-
"git",
|
|
57
|
-
"clone",
|
|
58
|
-
"--branch",
|
|
59
|
-
tag,
|
|
60
|
-
git_path,
|
|
61
|
-
str(student_repo_path.absolute()),
|
|
62
|
-
]
|
|
63
|
-
else:
|
|
64
|
-
cmd = ["git", "clone", git_path, str(student_repo_path.absolute())]
|
|
65
|
-
|
|
66
|
-
# Redirect clone output to a temporary log file in /tmp
|
|
67
|
-
log_path = None
|
|
68
|
-
try:
|
|
69
|
-
with tempfile.NamedTemporaryFile(
|
|
70
|
-
delete=False, dir="/tmp", prefix="ygrader_clone_", suffix=".log"
|
|
71
|
-
) as tmp_log:
|
|
72
|
-
log_path = tmp_log.name
|
|
73
|
-
subprocess.run(cmd, check=True, stdout=tmp_log, stderr=subprocess.STDOUT)
|
|
74
|
-
# Inform user where the clone output was written
|
|
75
|
-
if log_path:
|
|
76
|
-
print(f"git clone output logged to {log_path}")
|
|
77
|
-
except KeyboardInterrupt:
|
|
78
|
-
shutil.rmtree(str(student_repo_path))
|
|
79
|
-
sys.exit(-1)
|
|
80
|
-
except subprocess.CalledProcessError:
|
|
81
|
-
print_color(TermColors.RED, "Clone failed")
|
|
82
|
-
if log_path:
|
|
83
|
-
print_color(TermColors.YELLOW, "See log:", log_path)
|
|
84
|
-
return False
|
|
85
|
-
return True
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
def convert_github_url_format(url, to_https):
|
|
89
|
-
""" " Convert a github URL to either HTTPS or SSH format
|
|
90
|
-
|
|
91
|
-
If to_https is True, URLs will be converted to https://github.com/org/repo format:
|
|
92
|
-
|
|
93
|
-
>>> convert_github_url_format("git@github.com:byu-ecen123-classroom/123-labs-username01.git", True)
|
|
94
|
-
'https://github.com/byu-ecen123-classroom/123-labs-username01'
|
|
95
|
-
|
|
96
|
-
If to_https is False then SSH format is assumed, and URLs will be converted to git@github.com:org/repo format:
|
|
97
|
-
|
|
98
|
-
>>> convert_github_url_format("https://github.com/byu-ecen123-classroom/123-labs-username01", False)
|
|
99
|
-
'git@github.com:byu-ecen123-classroom/123-labs-username01.git'
|
|
100
|
-
|
|
101
|
-
It also works if the student provides the repo with ".git" extension
|
|
102
|
-
|
|
103
|
-
>>> convert_github_url_format("https://github.com/byu-ecen123-classroom/123-labs-username01.git", True)
|
|
104
|
-
'https://github.com/byu-ecen123-classroom/123-labs-username01'
|
|
105
|
-
|
|
106
|
-
Any invalid format is returned with modification:
|
|
107
|
-
|
|
108
|
-
>>> convert_github_url_format("invalid format", True)
|
|
109
|
-
'invalid format'
|
|
110
|
-
|
|
111
|
-
"""
|
|
112
|
-
org = repo = None
|
|
113
|
-
|
|
114
|
-
match = re.search(r"git@github\.com:(.*?)/(.*?).git", url)
|
|
115
|
-
if match:
|
|
116
|
-
org = match.group(1)
|
|
117
|
-
repo = match.group(2)
|
|
118
|
-
match = re.search(r"github\.com/(.*?)/(.*)", url)
|
|
119
|
-
if match:
|
|
120
|
-
org = match.group(1)
|
|
121
|
-
repo = match.group(2)
|
|
122
|
-
|
|
123
|
-
# Remove .git
|
|
124
|
-
if repo is not None and repo.endswith(".git"):
|
|
125
|
-
repo = repo[:-4]
|
|
126
|
-
|
|
127
|
-
if org is not None:
|
|
128
|
-
if to_https:
|
|
129
|
-
return "https://github.com/" + org + "/" + repo
|
|
130
|
-
return "git@github.com:" + org + "/" + repo + ".git"
|
|
131
|
-
return url
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
def print_date(student_repo_path):
|
|
135
|
-
"""Print the last commit date to the repo"""
|
|
136
|
-
print("Last commit: ")
|
|
137
|
-
cmd = ["git", "log", "-1", r"--format=%cd"]
|
|
138
|
-
subprocess.run(cmd, cwd=str(student_repo_path), check=False)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|