QuizGenerator 0.5.0__py3-none-any.whl → 0.5.1__py3-none-any.whl

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.
@@ -17,6 +17,9 @@ from QuizGenerator.qrcode_generator import QuestionQRCode
17
17
  import re
18
18
 
19
19
  import logging
20
+ import os
21
+ import uuid
22
+
20
23
  log = logging.getLogger(__name__)
21
24
 
22
25
  class ContentAST:
@@ -1389,8 +1392,6 @@ class ContentAST:
1389
1392
  def _ensure_image_saved(self):
1390
1393
  """Save image data to file if not already saved."""
1391
1394
  if self.path is None:
1392
- import os
1393
- import uuid
1394
1395
 
1395
1396
  # Create imgs directory if it doesn't exist (use absolute path)
1396
1397
  img_dir = os.path.abspath("imgs")
QuizGenerator/generate.py CHANGED
@@ -42,8 +42,7 @@ def parse_args():
42
42
 
43
43
  # PDF Flags
44
44
  parser.add_argument("--num_pdfs", default=0, type=int, help="How many PDF quizzes to create")
45
- parser.add_argument("--typst", action="store_true",
46
- help="Use Typst instead of LaTeX for PDF generation")
45
+ parser.add_argument("--latex", action="store_false", dest="typst", help="Use Typst instead of LaTeX for PDF generation")
47
46
 
48
47
  subparsers = parser.add_subparsers(dest='command')
49
48
  test_parser = subparsers.add_parser("TEST")
@@ -7,9 +7,7 @@ import dataclasses
7
7
  import enum
8
8
  import io
9
9
  import logging
10
- import math
11
10
  import os
12
- import queue
13
11
  import uuid
14
12
  from typing import List
15
13
 
@@ -65,6 +63,8 @@ class SchedulingQuestion(ProcessQuestion, RegenerableChoiceMixin, TableQuestionM
65
63
  TIME_QUANTUM = None
66
64
 
67
65
  ROUNDING_DIGITS = 2
66
+ IMAGE_DPI = 140
67
+ IMAGE_FIGSIZE = (9.5, 5.5)
68
68
 
69
69
  @dataclasses.dataclass
70
70
  class Job:
@@ -552,7 +552,7 @@ class SchedulingQuestion(ProcessQuestion, RegenerableChoiceMixin, TableQuestionM
552
552
 
553
553
  def make_image(self):
554
554
 
555
- fig, ax = plt.subplots(1, 1)
555
+ fig, ax = plt.subplots(1, 1, figsize=self.IMAGE_FIGSIZE, dpi=self.IMAGE_DPI)
556
556
 
557
557
  for x_loc in set([t for job_id in self.job_stats.keys() for t in self.job_stats[job_id]["state_changes"] ]):
558
558
  ax.axvline(x_loc, zorder=0)
@@ -610,7 +610,8 @@ class SchedulingQuestion(ProcessQuestion, RegenerableChoiceMixin, TableQuestionM
610
610
 
611
611
  # Save to BytesIO object instead of a file
612
612
  buffer = io.BytesIO()
613
- plt.savefig(buffer, format='png')
613
+ plt.tight_layout()
614
+ plt.savefig(buffer, format='png', dpi=self.IMAGE_DPI, bbox_inches='tight', pad_inches=0.2)
614
615
  plt.close(fig)
615
616
 
616
617
  # Reset buffer position to the beginning
@@ -630,50 +631,508 @@ class SchedulingQuestion(ProcessQuestion, RegenerableChoiceMixin, TableQuestionM
630
631
  return image_path
631
632
 
632
633
 
633
- class MLFQ_Question(ProcessQuestion):
634
-
635
- MIN_DURATION = 10
636
- MAX_DURATION = 100
634
+ @QuestionRegistry.register()
635
+ class MLFQQuestion(ProcessQuestion, TableQuestionMixin, BodyTemplatesMixin):
636
+ MIN_DURATION = 4
637
+ MAX_DURATION = 12
637
638
  MIN_ARRIVAL = 0
638
- MAX_ARRIVAL = 100
639
-
639
+ MAX_ARRIVAL = 10
640
+ DEFAULT_NUM_JOBS = 3
641
+ DEFAULT_NUM_QUEUES = 3
642
+ ROUNDING_DIGITS = 2
643
+ IMAGE_DPI = 140
644
+ IMAGE_FIGSIZE = (9.5, 6.5)
645
+
640
646
  @dataclasses.dataclass
641
- class Job():
642
- arrival_time: float
643
- duration: float
644
- elapsed_time: float = 0.0
645
- response_time: float = None
646
- turnaround_time: float = None
647
-
648
- def run_for_slice(self, slice_duration):
649
- self.elapsed_time += slice_duration
650
-
651
- def is_complete(self):
652
- return math.isclose(self.duration, self.elapsed_time)
653
-
647
+ class Job:
648
+ job_id: int
649
+ arrival_time: int
650
+ duration: int
651
+ remaining_time: int
652
+ queue_level: int = 0
653
+ time_in_queue: int = 0
654
+ response_time: float | None = None
655
+ turnaround_time: float | None = None
656
+ remaining_quantum: int | None = None
657
+ run_intervals: List[tuple] = dataclasses.field(default_factory=list)
658
+ max_queue_level: int = 0
659
+
660
+ def __init__(
661
+ self,
662
+ num_jobs: int = DEFAULT_NUM_JOBS,
663
+ num_queues: int = DEFAULT_NUM_QUEUES,
664
+ min_job_length: int = MIN_DURATION,
665
+ max_job_length: int = MAX_DURATION,
666
+ boost_interval: int | None = None,
667
+ boost_interval_range: List[int] | None = None,
668
+ *args,
669
+ **kwargs
670
+ ):
671
+ kwargs["num_jobs"] = num_jobs
672
+ kwargs["num_queues"] = num_queues
673
+ kwargs["min_job_length"] = min_job_length
674
+ kwargs["max_job_length"] = max_job_length
675
+ if boost_interval is not None:
676
+ kwargs["boost_interval"] = boost_interval
677
+ if boost_interval_range is not None:
678
+ kwargs["boost_interval_range"] = boost_interval_range
679
+ super().__init__(*args, **kwargs)
680
+ self.num_jobs = num_jobs
681
+ self.num_queues = num_queues
682
+ self.min_job_length = min_job_length
683
+ self.max_job_length = max_job_length
684
+ self.boost_interval = boost_interval
685
+ self.boost_interval_range = boost_interval_range
686
+
687
+ def get_workload(self, num_jobs: int) -> List[MLFQQuestion.Job]:
688
+ arrivals = [0]
689
+ if num_jobs > 1:
690
+ arrivals.extend(
691
+ self.rng.randint(self.MIN_ARRIVAL, self.MAX_ARRIVAL)
692
+ for _ in range(num_jobs - 1)
693
+ )
694
+ if max(arrivals) == 0:
695
+ arrivals[-1] = self.rng.randint(1, self.MAX_ARRIVAL)
696
+
697
+ durations = [
698
+ self.rng.randint(self.min_job_length, self.max_job_length)
699
+ for _ in range(num_jobs)
700
+ ]
701
+
702
+ jobs = []
703
+ for i in range(num_jobs):
704
+ jobs.append(
705
+ MLFQQuestion.Job(
706
+ job_id=i,
707
+ arrival_time=arrivals[i],
708
+ duration=durations[i],
709
+ remaining_time=durations[i],
710
+ )
711
+ )
712
+ return jobs
713
+
714
+ def _normalize_queue_params(self, values: List[int] | None, num_queues: int) -> List[int]:
715
+ if values is None:
716
+ return []
717
+ values = list(values)
718
+ while len(values) < num_queues:
719
+ values.append(values[-1])
720
+ return values[:num_queues]
721
+
722
+ def run_simulation(
723
+ self,
724
+ jobs: List[MLFQQuestion.Job],
725
+ queue_quantums: List[int],
726
+ queue_allotments: List[int | None],
727
+ boost_interval: int | None,
728
+ ) -> None:
729
+ self.timeline = collections.defaultdict(list)
730
+ self.boost_times = []
731
+ pending = sorted(jobs, key=lambda j: (j.arrival_time, j.job_id))
732
+ queues = [collections.deque() for _ in range(len(queue_quantums))]
733
+ completed = set()
734
+
735
+ curr_time = pending[0].arrival_time if pending else 0
736
+ self.timeline[curr_time].append("Simulation Start")
737
+ next_boost_time = None
738
+ if boost_interval is not None:
739
+ next_boost_time = boost_interval
740
+
741
+ def enqueue_arrivals(up_to_time: int) -> None:
742
+ nonlocal pending
743
+ while pending and pending[0].arrival_time <= up_to_time:
744
+ job = pending.pop(0)
745
+ job.queue_level = len(queues) - 1
746
+ job.time_in_queue = 0
747
+ job.remaining_quantum = None
748
+ queues[-1].append(job)
749
+ self.timeline[job.arrival_time].append(
750
+ f"Job{job.job_id} arrived (dur = {job.duration})"
751
+ )
752
+
753
+ def apply_boost(curr_time: int, running_job: MLFQQuestion.Job | None = None) -> None:
754
+ jobs_to_boost = []
755
+ for q in queues:
756
+ while q:
757
+ jobs_to_boost.append(q.popleft())
758
+ if running_job is not None and running_job.remaining_time > 0:
759
+ jobs_to_boost.append(running_job)
760
+ if not jobs_to_boost:
761
+ self.boost_times.append(curr_time)
762
+ return
763
+ for job in sorted(jobs_to_boost, key=lambda j: j.job_id):
764
+ job.queue_level = len(queues) - 1
765
+ job.time_in_queue = 0
766
+ job.remaining_quantum = None
767
+ queues[-1].append(job)
768
+ self.timeline[curr_time].append(
769
+ f"Boosted all jobs to Q{len(queues) - 1}"
770
+ )
771
+ self.boost_times.append(curr_time)
772
+
773
+ enqueue_arrivals(curr_time)
774
+
775
+ while len(completed) < len(jobs):
776
+ q_idx = next(
777
+ (i for i in range(len(queues) - 1, -1, -1) if queues[i]),
778
+ None
779
+ )
780
+ if q_idx is None:
781
+ next_times = []
782
+ if pending:
783
+ next_times.append(pending[0].arrival_time)
784
+ if next_boost_time is not None:
785
+ next_times.append(next_boost_time)
786
+ if next_times:
787
+ next_time = min(next_times)
788
+ if next_time > curr_time:
789
+ self.timeline[curr_time].append("CPU idle")
790
+ curr_time = next_time
791
+ enqueue_arrivals(curr_time)
792
+ while next_boost_time is not None and curr_time >= next_boost_time:
793
+ apply_boost(curr_time)
794
+ next_boost_time += boost_interval
795
+ continue
796
+ break
797
+
798
+ job = queues[q_idx].popleft()
799
+ quantum = queue_quantums[q_idx]
800
+ if job.remaining_quantum is None or job.remaining_quantum <= 0:
801
+ job.remaining_quantum = quantum
802
+
803
+ slice_duration = min(job.remaining_quantum, job.remaining_time)
804
+ preempted = False
805
+ if q_idx < len(queues) - 1 and pending:
806
+ next_arrival = pending[0].arrival_time
807
+ if next_arrival < curr_time + slice_duration:
808
+ slice_duration = next_arrival - curr_time
809
+ preempted = True
810
+ if next_boost_time is not None and next_boost_time < curr_time + slice_duration:
811
+ slice_duration = next_boost_time - curr_time
812
+ preempted = True
813
+
814
+ if job.response_time is None:
815
+ job.response_time = curr_time - job.arrival_time
816
+
817
+ if slice_duration > 0:
818
+ self.timeline[curr_time].append(
819
+ f"Running Job{job.job_id} in Q{q_idx} for {slice_duration}"
820
+ )
821
+ job.run_intervals.append((curr_time, curr_time + slice_duration, q_idx))
822
+ curr_time += slice_duration
823
+ job.remaining_time -= slice_duration
824
+ job.time_in_queue += slice_duration
825
+ job.remaining_quantum -= slice_duration
826
+
827
+ enqueue_arrivals(curr_time)
828
+ boosted_current_job = False
829
+ while next_boost_time is not None and curr_time >= next_boost_time:
830
+ apply_boost(curr_time, running_job=job if not boosted_current_job else None)
831
+ boosted_current_job = True
832
+ next_boost_time += boost_interval
833
+
834
+ if job.remaining_time <= 0:
835
+ job.turnaround_time = curr_time - job.arrival_time
836
+ job.remaining_quantum = None
837
+ completed.add(job.job_id)
838
+ self.timeline[curr_time].append(
839
+ f"Completed Job{job.job_id} (TAT = {job.turnaround_time})"
840
+ )
841
+ continue
842
+ if boosted_current_job:
843
+ continue
844
+
845
+ allotment = queue_allotments[q_idx]
846
+ if (
847
+ allotment is not None
848
+ and job.time_in_queue >= allotment
849
+ and q_idx > 0
850
+ ):
851
+ job.queue_level = q_idx - 1
852
+ job.max_queue_level = max(job.max_queue_level, job.queue_level)
853
+ job.time_in_queue = 0
854
+ job.remaining_quantum = None
855
+ queues[q_idx - 1].append(job)
856
+ self.timeline[curr_time].append(
857
+ f"Demoted Job{job.job_id} to Q{q_idx - 1}"
858
+ )
859
+ continue
860
+
861
+ if preempted and job.remaining_quantum > 0:
862
+ queues[q_idx].appendleft(job)
863
+ else:
864
+ if job.remaining_quantum <= 0:
865
+ job.remaining_quantum = None
866
+ queues[q_idx].append(job)
867
+
654
868
  def refresh(self, *args, **kwargs):
655
869
  super().refresh(*args, **kwargs)
656
-
657
- # Set up defaults
658
- # todo: allow for per-queue specification of durations, likely through dicts
659
- num_queues = kwargs.get("num_queues", 2)
660
- num_jobs = kwargs.get("num_jobs", 2)
661
-
662
- # Set up queues that we will be using
663
- mlfq_queues = {
664
- priority : queue.Queue()
665
- for priority in range(num_queues)
870
+
871
+ self.num_jobs = kwargs.get("num_jobs", self.num_jobs)
872
+ self.num_queues = kwargs.get("num_queues", self.num_queues)
873
+ self.min_job_length = kwargs.get("min_job_length", self.min_job_length)
874
+ self.max_job_length = kwargs.get("max_job_length", self.max_job_length)
875
+ self.boost_interval = kwargs.get("boost_interval", self.boost_interval)
876
+ self.boost_interval_range = kwargs.get(
877
+ "boost_interval_range",
878
+ self.boost_interval_range
879
+ )
880
+ if self.boost_interval is None and self.boost_interval_range:
881
+ low, high = self.boost_interval_range
882
+ self.boost_interval = self.rng.randint(low, high)
883
+
884
+ jobs = self.get_workload(self.num_jobs)
885
+
886
+ queue_quantums = [2**(self.num_queues - 1 - i) for i in range(self.num_queues)]
887
+ queue_quantums = self._normalize_queue_params(queue_quantums, self.num_queues)
888
+ queue_quantums = [int(q) for q in queue_quantums]
889
+
890
+ queue_allotments = [None] + [
891
+ queue_quantums[i] * 2 for i in range(1, self.num_queues)
892
+ ]
893
+ queue_allotments = self._normalize_queue_params(queue_allotments, self.num_queues)
894
+ queue_allotments = [
895
+ int(allotment) if allotment is not None else None
896
+ for allotment in queue_allotments
897
+ ]
898
+ queue_allotments[0] = None
899
+
900
+ self.queue_quantums = queue_quantums
901
+ self.queue_allotments = queue_allotments
902
+
903
+ self.run_simulation(jobs, queue_quantums, queue_allotments, self.boost_interval)
904
+
905
+ self.job_stats = {
906
+ job.job_id: {
907
+ "arrival_time": job.arrival_time,
908
+ "duration": job.duration,
909
+ "Response": job.response_time,
910
+ "TAT": job.turnaround_time,
911
+ "run_intervals": list(job.run_intervals),
912
+ }
913
+ for job in jobs
666
914
  }
667
-
668
- # Set up jobs that we'll be using
669
- jobs = [
670
- MLFQ_Question.Job(
671
- arrival_time=self.rng.randint(self.MIN_ARRIVAL, self.MAX_ARRIVAL),
672
- duration=self.rng.randint(self.MIN_DURATION, self.MAX_DURATION),
673
-
915
+
916
+ for job_id in sorted(self.job_stats.keys()):
917
+ self.answers.update({
918
+ f"answer__turnaround_time_job{job_id}": Answer.auto_float(
919
+ f"answer__turnaround_time_job{job_id}",
920
+ self.job_stats[job_id]["TAT"]
921
+ )
922
+ })
923
+
924
+ return self.is_interesting()
925
+
926
+ def _get_body(self, *args, **kwargs):
927
+ answers: List[Answer] = []
928
+
929
+ queue_rows = []
930
+ for i in reversed(range(self.num_queues)):
931
+ allotment = self.queue_allotments[i]
932
+ queue_rows.append([
933
+ f"Q{i}",
934
+ self.queue_quantums[i],
935
+ "infinite" if allotment is None else allotment
936
+ ])
937
+ queue_table = ContentAST.Table(
938
+ headers=["Queue", "Quantum", "Allotment"],
939
+ data=queue_rows
940
+ )
941
+
942
+ table_rows = []
943
+ for job_id in sorted(self.job_stats.keys()):
944
+ table_rows.append({
945
+ "Job ID": f"Job{job_id}",
946
+ "Arrival": self.job_stats[job_id]["arrival_time"],
947
+ "Duration": self.job_stats[job_id]["duration"],
948
+ "TAT": f"answer__turnaround_time_job{job_id}",
949
+ })
950
+ answers.append(self.answers[f"answer__turnaround_time_job{job_id}"])
951
+
952
+ scheduling_table = self.create_answer_table(
953
+ headers=["Job ID", "Arrival", "Duration", "TAT"],
954
+ data_rows=table_rows,
955
+ answer_columns=["TAT"]
956
+ )
957
+
958
+ intro_text = (
959
+ "Assume an MLFQ scheduler with round-robin inside each queue. "
960
+ f"New jobs enter the highest-priority queue (Q{self.num_queues - 1}) "
961
+ "and a job is demoted after using its total allotment for that queue. "
962
+ "If a higher-priority job arrives, it preempts any lower-priority job."
963
+ )
964
+
965
+ instructions = (
966
+ f"Compute the turnaround time (TAT) for each job. "
967
+ f"Round to at most {Answer.DEFAULT_ROUNDING_DIGITS} digits after the decimal."
968
+ )
969
+
970
+ body = ContentAST.Section()
971
+ body.add_element(ContentAST.Paragraph([intro_text]))
972
+ body.add_element(queue_table)
973
+ if self.boost_interval is not None:
974
+ body.add_element(ContentAST.Paragraph([
975
+ f"Every {self.boost_interval} time units, all jobs are boosted to "
976
+ f"Q{self.num_queues - 1}. After a boost, scheduling restarts with the "
977
+ "lowest job number in that queue."
978
+ ]))
979
+ body.add_element(ContentAST.Paragraph([instructions]))
980
+ body.add_element(scheduling_table)
981
+ return body, answers
982
+
983
+ def get_body(self, *args, **kwargs) -> ContentAST.Section:
984
+ body, _ = self._get_body(*args, **kwargs)
985
+ return body
986
+
987
+ def _get_explanation(self, **kwargs):
988
+ explanation = ContentAST.Section()
989
+
990
+ explanation.add_element(
991
+ ContentAST.Paragraph([
992
+ "Turnaround time (TAT) is the completion time minus the arrival time.",
993
+ "We calculate it for each job after simulating the schedule."
994
+ ])
995
+ )
996
+
997
+ explanation.add_element(
998
+ ContentAST.Paragraph([
999
+ "For each job:"
1000
+ ] + [
1001
+ f"Job{job_id}_TAT = "
1002
+ f"{self.job_stats[job_id]['arrival_time'] + self.job_stats[job_id]['TAT']:0.{self.ROUNDING_DIGITS}f} "
1003
+ f"- {self.job_stats[job_id]['arrival_time']:0.{self.ROUNDING_DIGITS}f} "
1004
+ f"= {self.job_stats[job_id]['TAT']:0.{self.ROUNDING_DIGITS}f}"
1005
+ for job_id in sorted(self.job_stats.keys())
1006
+ ])
1007
+ )
1008
+
1009
+ explanation.add_element(
1010
+ ContentAST.Table(
1011
+ headers=["Time", "Events"],
1012
+ data=[
1013
+ [f"{t:0.{self.ROUNDING_DIGITS}f}s"] + ['\n'.join(events)]
1014
+ for t in sorted(self.timeline.keys())
1015
+ if (events := [
1016
+ event for event in self.timeline[t]
1017
+ if (
1018
+ "arrived" in event
1019
+ or "Demoted" in event
1020
+ or "Boosted" in event
1021
+ or "Completed" in event
1022
+ or "Simulation Start" in event
1023
+ or "CPU idle" in event
1024
+ )
1025
+ ])
1026
+ ]
1027
+ )
1028
+ )
1029
+
1030
+ explanation.add_element(
1031
+ ContentAST.Picture(
1032
+ img_data=self.make_image(),
1033
+ caption="MLFQ Scheduling Overview"
1034
+ )
1035
+ )
1036
+
1037
+ return explanation, []
1038
+
1039
+ def get_explanation(self, **kwargs) -> ContentAST.Section:
1040
+ explanation, _ = self._get_explanation(**kwargs)
1041
+ return explanation
1042
+
1043
+ def make_image(self):
1044
+ fig, ax = plt.subplots(1, 1, figsize=self.IMAGE_FIGSIZE, dpi=self.IMAGE_DPI)
1045
+
1046
+ num_jobs = len(self.job_stats)
1047
+ if num_jobs == 0:
1048
+ buffer = io.BytesIO()
1049
+ plt.tight_layout()
1050
+ plt.savefig(buffer, format='png', dpi=self.IMAGE_DPI, bbox_inches='tight')
1051
+ plt.close(fig)
1052
+ buffer.seek(0)
1053
+ return buffer
1054
+
1055
+ job_colors = {
1056
+ job_id: str(0.15 + 0.7 * (idx / max(1, num_jobs - 1)))
1057
+ for idx, job_id in enumerate(sorted(self.job_stats.keys()))
1058
+ }
1059
+ job_lane = {
1060
+ job_id: idx
1061
+ for idx, job_id in enumerate(sorted(self.job_stats.keys(), reverse=True))
1062
+ }
1063
+ lanes_per_queue = num_jobs
1064
+
1065
+ for job_id in sorted(self.job_stats.keys()):
1066
+ for start, stop, queue_level in self.job_stats[job_id]["run_intervals"]:
1067
+ y_loc = queue_level * lanes_per_queue + job_lane[job_id]
1068
+ ax.barh(
1069
+ y=[y_loc],
1070
+ left=[start],
1071
+ width=[stop - start],
1072
+ edgecolor='black',
1073
+ linewidth=1.5,
1074
+ color=job_colors[job_id]
1075
+ )
1076
+
1077
+ for queue_idx in range(self.num_queues):
1078
+ if queue_idx % 2 == 1:
1079
+ ax.axhspan(
1080
+ queue_idx * lanes_per_queue - 0.5,
1081
+ (queue_idx + 1) * lanes_per_queue - 0.5,
1082
+ facecolor='0.97',
1083
+ edgecolor='none',
1084
+ zorder=-1
1085
+ )
1086
+
1087
+ arrival_times = sorted({
1088
+ self.job_stats[job_id]["arrival_time"]
1089
+ for job_id in self.job_stats.keys()
1090
+ })
1091
+ bottom_label_y = -0.1
1092
+ for arrival_time in arrival_times:
1093
+ ax.axvline(arrival_time, color='0.2', linestyle=':', linewidth=1.2, zorder=0)
1094
+ ax.text(
1095
+ arrival_time + 0.2,
1096
+ bottom_label_y,
1097
+ f"{arrival_time:0.{self.ROUNDING_DIGITS}f}s",
1098
+ color='0.2',
1099
+ rotation=90,
1100
+ ha='left',
1101
+ va='bottom'
674
1102
  )
1103
+
1104
+ completion_times = sorted({
1105
+ self.job_stats[job_id]["arrival_time"] + self.job_stats[job_id]["TAT"]
1106
+ for job_id in self.job_stats.keys()
1107
+ })
1108
+ for completion_time in completion_times:
1109
+ ax.axvline(completion_time, color='red', linewidth=1.5, zorder=0)
1110
+ ax.text(
1111
+ completion_time - 0.6,
1112
+ self.num_queues * lanes_per_queue - 0.5,
1113
+ f"{completion_time:0.{self.ROUNDING_DIGITS}f}s",
1114
+ color='red',
1115
+ rotation=90,
1116
+ ha='center',
1117
+ va='top'
1118
+ )
1119
+
1120
+ for boost_time in sorted(set(self.boost_times)):
1121
+ ax.axvline(boost_time, color='tab:blue', linestyle='--', linewidth=1.2, zorder=0)
1122
+
1123
+ tick_positions = [
1124
+ q * lanes_per_queue + (lanes_per_queue - 1) / 2
1125
+ for q in range(self.num_queues)
675
1126
  ]
676
-
677
- curr_time = -1.0
678
- while True:
679
- pass
1127
+ ax.set_yticks(tick_positions)
1128
+ ax.set_yticklabels([f"Q{i}" for i in range(self.num_queues)])
1129
+ ax.set_ylim(-0.5, self.num_queues * lanes_per_queue - 0.5)
1130
+ ax.set_xlim(xmin=0)
1131
+ ax.set_xlabel("Time")
1132
+
1133
+ buffer = io.BytesIO()
1134
+ plt.tight_layout()
1135
+ plt.savefig(buffer, format='png', dpi=self.IMAGE_DPI, bbox_inches='tight')
1136
+ plt.close(fig)
1137
+ buffer.seek(0)
1138
+ return buffer
QuizGenerator/quiz.py CHANGED
@@ -11,6 +11,7 @@ import subprocess
11
11
  import tempfile
12
12
  from datetime import datetime
13
13
  from typing import List, Dict, Optional
14
+ import re
14
15
 
15
16
  import yaml
16
17
 
@@ -87,6 +88,11 @@ class Quiz:
87
88
 
88
89
  # Get general quiz information from the dictionary
89
90
  name = exam_dict.get("name", f"Unnamed Exam ({datetime.now().strftime('%a %b %d %I:%M %p')})")
91
+ if isinstance(name, str):
92
+ def replace_time(match: re.Match) -> str:
93
+ fmt = match.group(1) or "%b %d %I:%M%p"
94
+ return datetime.now().strftime(fmt)
95
+ name = re.sub(r"\$TIME(?:\{([^}]+)\})?", replace_time, name)
90
96
  practice = exam_dict.get("practice", False)
91
97
  description = exam_dict.get("description", None)
92
98
  sort_order = list(map(lambda t: Question.Topic.from_string(t), exam_dict.get("sort order", [])))
@@ -515,4 +521,4 @@ def main():
515
521
 
516
522
  if __name__ == "__main__":
517
523
  main()
518
-
524
+
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: QuizGenerator
3
- Version: 0.5.0
3
+ Version: 0.5.1
4
4
  Summary: Generate randomized quiz questions for Canvas LMS and PDF exams
5
5
  Project-URL: Homepage, https://github.com/OtterDen-Lab/QuizGenerator
6
6
  Project-URL: Documentation, https://github.com/OtterDen-Lab/QuizGenerator/tree/main/documentation
@@ -1,14 +1,14 @@
1
1
  QuizGenerator/__init__.py,sha256=8EV-k90A3PNC8Cm2-ZquwNyVyvnwW1gs6u-nGictyhs,840
2
2
  QuizGenerator/__main__.py,sha256=Dd9w4R0Unm3RiXztvR4Y_g9-lkWp6FHg-4VN50JbKxU,151
3
3
  QuizGenerator/constants.py,sha256=AO-UWwsWPLb1k2JW6KP8rl9fxTcdT0rW-6XC6zfnDOs,4386
4
- QuizGenerator/contentast.py,sha256=iFfPiO7eJzY0k5XG_h7aiPRl5ov58HS9Zgr2NARZz48,100139
5
- QuizGenerator/generate.py,sha256=sFlhWwQbPKSrWCr09OhOpE41nJlwzD5w_qRDCDRlWec,8587
4
+ QuizGenerator/contentast.py,sha256=k38x8ukpLxCk2saun_O3K_n8NJL1leLhj-5iog0MkNg,100124
5
+ QuizGenerator/generate.py,sha256=FNHkHsxysQrsMOfLqborr5FYIKc0zd4qI97TJwAMtFs,8581
6
6
  QuizGenerator/misc.py,sha256=JpHdZZI-dCa15KQYXlJrrMFrnsMMkhXpEvyGC2R7wiE,666
7
7
  QuizGenerator/mixins.py,sha256=3o8tkTTtfqxUKCQrBtQc152owjXuWAVYqzi-HP4Rx2s,19172
8
8
  QuizGenerator/performance.py,sha256=CM3zLarJXN5Hfrl4-6JRBqD03j4BU1B2QW699HAr1Ds,7002
9
9
  QuizGenerator/qrcode_generator.py,sha256=S3mzZDk2UiHiw6ipSCpWPMhbKvSRR1P5ordZJUTo6ug,10776
10
10
  QuizGenerator/question.py,sha256=S-DC6pWSgP5NDjSqj6kmiVxqzMrL9sGucZFMhr9H7yw,31633
11
- QuizGenerator/quiz.py,sha256=75a1d8rrX1LXwbH8fzT4YzolF9zUvcvrrV1EQg3iJpg,21245
11
+ QuizGenerator/quiz.py,sha256=f2HLrawUlu3ULkNDzcihBWAt-e-49AIPz_l1edMAEQ0,21503
12
12
  QuizGenerator/regenerate.py,sha256=Uh4B9aKQvL3zD7PT-uH-GvrcSuUygV1BimvPVuErc-g,16525
13
13
  QuizGenerator/typst_utils.py,sha256=XtMEO1e4_Tg0G1zR9D1fmrYKlUfHenBPdGoCKR0DhZg,3154
14
14
  QuizGenerator/canvas/__init__.py,sha256=TwFP_zgxPIlWtkvIqQ6mcvBNTL9swIH_rJl7DGKcvkQ,286
@@ -22,7 +22,7 @@ QuizGenerator/premade_questions/cst334/math_questions.py,sha256=yqQsNeXJH8ySPrzY
22
22
  QuizGenerator/premade_questions/cst334/memory_questions.py,sha256=ImjUSFE5soDb_79uzai6aVvHtnMQ0DVdCZ6QGRf1Fao,55279
23
23
  QuizGenerator/premade_questions/cst334/ostep13_vsfs.py,sha256=d9jjrynEw44vupAH_wKl57UoHooCNEJXaC5DoNYualk,16163
24
24
  QuizGenerator/premade_questions/cst334/persistence_questions.py,sha256=2P79FHZBVmtfov56PokmNhp9QXMWQtuljiIFwpDJ9jA,17825
25
- QuizGenerator/premade_questions/cst334/process.py,sha256=3YQppkuDuOjAEPiiqCa-62BTR2i6UgjFEI6cOHB6RMM,24585
25
+ QuizGenerator/premade_questions/cst334/process.py,sha256=Wk-XatiL5YdIKEr1CenPJNUUtlOssUaP5dzY_BO_QjI,40108
26
26
  QuizGenerator/premade_questions/cst463/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
27
27
  QuizGenerator/premade_questions/cst463/gradient_descent/__init__.py,sha256=sH2CUV6zK9FT3jWTn453ys6_JTrUKRtZnU8hK6RmImU,240
28
28
  QuizGenerator/premade_questions/cst463/gradient_descent/gradient_calculation.py,sha256=WUCI_KlgGN5lgF50USEoMdj-hDgkmu0JBTBQlcEiUYU,13928
@@ -43,8 +43,8 @@ QuizGenerator/premade_questions/cst463/neural-network-basics/__init__.py,sha256=
43
43
  QuizGenerator/premade_questions/cst463/neural-network-basics/neural_network_questions.py,sha256=DMK9jYk_yn7XevFRG06UL8yi7UMT64P8KOzb3ikhq0g,46129
44
44
  QuizGenerator/premade_questions/cst463/tensorflow-intro/__init__.py,sha256=G1gEHtG4KakYgi8ZXSYYhX6bQRtnm2tZVGx36d63Nmo,173
45
45
  QuizGenerator/premade_questions/cst463/tensorflow-intro/tensorflow_questions.py,sha256=vCJHq6SNf-U2BIgNKt33gAEfeSgMbKDuLaWj9zdx9cw,33616
46
- quizgenerator-0.5.0.dist-info/METADATA,sha256=jiVnF5aYOd6pG1KIhcutG_urCt0TVfpMAWpt9kq3rIo,7212
47
- quizgenerator-0.5.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
48
- quizgenerator-0.5.0.dist-info/entry_points.txt,sha256=aOIdRdw26xY8HkxOoKHBnUPe2mwGv5Ti3U1zojb6zxQ,98
49
- quizgenerator-0.5.0.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
50
- quizgenerator-0.5.0.dist-info/RECORD,,
46
+ quizgenerator-0.5.1.dist-info/METADATA,sha256=kT5tAexb_kDMZmJ-zQbCPorvY98xaCA0tutt_IWhHUw,7212
47
+ quizgenerator-0.5.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
48
+ quizgenerator-0.5.1.dist-info/entry_points.txt,sha256=aOIdRdw26xY8HkxOoKHBnUPe2mwGv5Ti3U1zojb6zxQ,98
49
+ quizgenerator-0.5.1.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
50
+ quizgenerator-0.5.1.dist-info/RECORD,,