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.
Files changed (25) hide show
  1. {ygrader-2.5.2/ygrader.egg-info → ygrader-2.6.0}/PKG-INFO +1 -1
  2. {ygrader-2.5.2 → ygrader-2.6.0}/setup.py +1 -1
  3. {ygrader-2.5.2 → ygrader-2.6.0}/ygrader/__init__.py +1 -0
  4. {ygrader-2.5.2 → ygrader-2.6.0}/ygrader/grader.py +266 -28
  5. ygrader-2.6.0/ygrader/remote.py +257 -0
  6. ygrader-2.6.0/ygrader/student_repos.py +239 -0
  7. {ygrader-2.5.2 → ygrader-2.6.0/ygrader.egg-info}/PKG-INFO +1 -1
  8. {ygrader-2.5.2 → ygrader-2.6.0}/ygrader.egg-info/SOURCES.txt +1 -0
  9. ygrader-2.5.2/ygrader/student_repos.py +0 -138
  10. {ygrader-2.5.2 → ygrader-2.6.0}/LICENSE +0 -0
  11. {ygrader-2.5.2 → ygrader-2.6.0}/setup.cfg +0 -0
  12. {ygrader-2.5.2 → ygrader-2.6.0}/test/test_interactive.py +0 -0
  13. {ygrader-2.5.2 → ygrader-2.6.0}/test/test_unittest.py +0 -0
  14. {ygrader-2.5.2 → ygrader-2.6.0}/ygrader/deductions.py +0 -0
  15. {ygrader-2.5.2 → ygrader-2.6.0}/ygrader/feedback.py +0 -0
  16. {ygrader-2.5.2 → ygrader-2.6.0}/ygrader/grades_csv.py +0 -0
  17. {ygrader-2.5.2 → ygrader-2.6.0}/ygrader/grading_item.py +0 -0
  18. {ygrader-2.5.2 → ygrader-2.6.0}/ygrader/grading_item_config.py +0 -0
  19. {ygrader-2.5.2 → ygrader-2.6.0}/ygrader/score_input.py +0 -0
  20. {ygrader-2.5.2 → ygrader-2.6.0}/ygrader/send_ctrl_backtick.ahk +0 -0
  21. {ygrader-2.5.2 → ygrader-2.6.0}/ygrader/upstream_merger.py +0 -0
  22. {ygrader-2.5.2 → ygrader-2.6.0}/ygrader/utils.py +0 -0
  23. {ygrader-2.5.2 → ygrader-2.6.0}/ygrader.egg-info/dependency_links.txt +0 -0
  24. {ygrader-2.5.2 → ygrader-2.6.0}/ygrader.egg-info/requires.txt +0 -0
  25. {ygrader-2.5.2 → ygrader-2.6.0}/ygrader.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ygrader
3
- Version: 2.5.2
3
+ Version: 2.6.0
4
4
  Summary: Grading scripts used in BYU's Electrical and Computer Engineering Department
5
5
  Home-page: https://github.com/byu-cpe/ygrader
6
6
  Author: Jeff Goeders
@@ -4,7 +4,7 @@ setup(
4
4
  name="ygrader",
5
5
  packages=["ygrader"],
6
6
  package_data={"ygrader": ["*.ahk"]},
7
- version="2.5.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",
@@ -8,3 +8,4 @@ from .grading_item_config import (
8
8
  LearningSuiteColumnParseError,
9
9
  )
10
10
  from .feedback import assemble_grades
11
+ from .remote import run_remote_build, RemoteBuildError
@@ -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, item, fcn_extra_args_dict=grading_fcn_args_dict
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(prep_fcn, item=None)
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. Print # students who need a grade
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._run_grading(student_grades_df, grouped_df)
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(row, student_work_path)
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", grades_csv.get_concated_names(row), "..."
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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ygrader
3
- Version: 2.5.2
3
+ Version: 2.6.0
4
4
  Summary: Grading scripts used in BYU's Electrical and Computer Engineering Department
5
5
  Home-page: https://github.com/byu-cpe/ygrader
6
6
  Author: Jeff Goeders
@@ -9,6 +9,7 @@ ygrader/grader.py
9
9
  ygrader/grades_csv.py
10
10
  ygrader/grading_item.py
11
11
  ygrader/grading_item_config.py
12
+ ygrader/remote.py
12
13
  ygrader/score_input.py
13
14
  ygrader/send_ctrl_backtick.ahk
14
15
  ygrader/student_repos.py
@@ -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