autograder.api.config
1import argparse 2import copy 3import typing 4 5import edq.util.hash 6import edq.util.parse 7import edq.util.time 8 9import autograder.api.constants 10import autograder.model.log 11import autograder.model.stats 12import autograder.model.user 13import autograder.error 14import autograder.filespec 15import autograder.util.parse 16 17CSV_TO_LIST_DELIMITER: str = ',' 18MAP_KEY_VALUE_SEP: str = '=' 19 20DEFAULT_CLI_ACTIONS: typing.Dict[typing.Type, str] = { 21 bool: 'store_true', 22 edq.util.time.Timestamp: 'store', 23 float: 'store', 24 int: 'store', 25 str: 'store', 26} 27""" Map value types to default CLI actions. """ 28 29@typing.runtime_checkable 30class CLIAddFunction(typing.Protocol): 31 """ A function for manually adding APIParams to a parser. """ 32 33 def __call__(self, parser: typing.Union[argparse.ArgumentParser, argparse._ArgumentGroup], param: 'APIParam') -> None: 34 """ 35 Add the given APIParam to the parser. 36 """ 37 38class APIParam: 39 """ 40 A definition for a parameter to the autograder API. 41 This class also contains information for representing these parameters in 42 config (dicts), on the CLI (argparse options), and in the payload of autograder requests. 43 """ 44 45 def __init__(self, 46 config_key: str, 47 description: str, 48 alt_config_key: typing.Union[str, None] = None, 49 api_key: typing.Union[str, None] = None, 50 cli_flag: typing.Union[str, None] = None, 51 api: bool = True, 52 api_required: typing.Union[bool, None] = None, # Default to the value of `api`. 53 cli: bool = True, 54 cli_required: typing.Union[bool, None] = None, # Default to false. 55 value_type: typing.Type = str, 56 cli_type: typing.Union[typing.Any, None] = None, 57 skip_clean: bool = False, 58 hash_value: bool = False, 59 omit_empty: bool = True, 60 cli_action: typing.Union[typing.Any, None] = None, 61 cli_default_value: typing.Any = None, 62 cli_show_default: typing.Union[bool, None] = None, 63 cli_extra_options: typing.Union[typing.Dict[str, typing.Any], None] = None, 64 cli_add_func: typing.Union[CLIAddFunction, None] = None, 65 **kwargs: typing.Any) -> None: 66 if (len(config_key.strip()) == 0): 67 raise autograder.error.APIError(None, "APIParam cannot have an empty key.") 68 69 self.config_key: str = config_key 70 """ 71 The main key/label used to reference this parameter. 72 Will be used in the application config and config dicts. 73 """ 74 75 if (len(description) == 0): 76 raise autograder.error.APIError(None, "APIParam cannot have an empty description.") 77 78 self.description: str = description 79 """ A description used for this parameter. """ 80 81 self.alt_config_key: typing.Union[str, None] = alt_config_key 82 """ 83 An alternative config key that the value could be found under when reading a config file. 84 A value found under this key will always be stored under self.config_key. 85 """ 86 87 if (api_key is None): 88 api_key = config_key.replace('_', '-') 89 90 self.api_key: str = api_key 91 """ The key in the API request payload for this parameter. """ 92 93 if (cli_flag is None): 94 cli_flag = config_key.replace('_', '-') 95 96 self.cli_flag: str = cli_flag 97 """ The flag used on the CLI for this parameter. """ 98 99 self.api: bool = api 100 """ If this parameter should be included on the API payload. """ 101 102 if (api_required is None): 103 api_required = api 104 105 self.api_required: bool = api_required 106 """ If this parameter is required when calling the API. """ 107 108 self.cli: bool = cli 109 """ If this parameter should be included on the CLI. """ 110 111 if (cli_required is None): 112 cli_required = False 113 114 self.cli_required: bool = cli_required 115 """ If this parameter is required on the CLI. """ 116 117 self.value_type: typing.Type = value_type 118 """ The type that a clean value from this parameter should be. """ 119 120 if (cli_type is None): 121 cli_type = self.value_type 122 123 self.cli_type: typing.Any = cli_type 124 """ 125 The type of value that will be looked for on the CLI. 126 Defaults to the same as `self.value_type`. 127 128 Note that this value will be used as the `type` argument to `argparse.ArgumentParser.add_argument()`. 129 """ 130 131 self.skip_clean: bool = skip_clean 132 """ Skip any cleaning steps for this parameter's value. """ 133 134 self.hash_value: bool = hash_value 135 """ Hash the value during the cleaning processes (typically used for passwords). """ 136 137 self.omit_empty: bool = omit_empty 138 """ Do not include this parameter in the API payload if its value is empty. """ 139 140 self.cli_default_value: typing.Any = cli_default_value 141 """ The default value for the CLI argument. """ 142 143 if (cli_show_default is None): 144 # Usually we will want to show the default value, 145 # but boolean flags don't make much sense to show defaults with. 146 cli_show_default = (value_type != bool) 147 148 self.cli_show_default: bool = cli_show_default 149 """ Show the default value (in CLI's help) for a non-required param. """ 150 151 if (cli_extra_options is None): 152 cli_extra_options = {} 153 154 self.cli_extra_options: typing.Dict[str, typing.Any] = cli_extra_options 155 """ 156 Additional options to pass (as kwargs) to `argparse.ArgumentParser.add_argument()`. 157 These should not conflict with the options set using the direct members. 158 """ 159 160 self.cli_add_func: typing.Union[CLIAddFunction, None] = cli_add_func 161 """ A function that can be used to override the standard behavior in add_to_parser(). """ 162 163 if (self.cli and (self.cli_add_func is None) and (cli_action is None)): 164 # Choose action based on the value's type. 165 cli_action = DEFAULT_CLI_ACTIONS.get(self.value_type, None) 166 if (cli_action is None): 167 raise ValueError(f"Unknown action for value type '{self.value_type}'.") 168 169 self.cli_action: typing.Any = cli_action 170 """ The value that value will be used as the `action` argument to `argparse.ArgumentParser.add_argument()`. """ 171 172 def clean_value(self, value: typing.Union[typing.Any, None]) -> typing.Union[typing.Any, None]: 173 """ 174 Return a clean version of the given value (according to this API param). 175 An error will be raised if cleaning fails. 176 Empty values will be returned as None. 177 """ 178 179 if (self.skip_clean): 180 return value 181 182 if (value is None): 183 return None 184 185 if (issubclass(self.value_type, edq.util.time.Timestamp)): 186 return edq.util.time.Timestamp.guess(value) 187 188 if (issubclass(self.value_type, bool)): 189 return edq.util.parse.boolean(value) 190 191 if (issubclass(self.value_type, int)): 192 return int(value) 193 194 if (issubclass(self.value_type, list)): 195 return autograder.util.parse.string_list(value) 196 197 if (issubclass(self.value_type, str)): 198 value = str(value).strip() 199 if (len(value) == 0): 200 return None 201 202 if (self.hash_value): 203 value = edq.util.hash.sha256_hex(value) 204 205 if (issubclass(self.value_type, autograder.filespec.FileSpec)): 206 return autograder.filespec.parse(value) 207 208 return value 209 210 def optional(self) -> 'APIParam': 211 """ Make a copy of this APIParam, mark the copy as optional, and return the copy. """ 212 213 new_param = copy.deepcopy(self) 214 new_param.api_required = False 215 new_param.cli_required = False 216 return new_param 217 218 def required(self) -> 'APIParam': 219 """ Make a copy of this APIParam, mark the copy as required, and return the copy. """ 220 221 new_param = copy.deepcopy(self) 222 new_param.api_required = True 223 new_param.cli_required = True 224 return new_param 225 226 def add_to_parser(self, parser: typing.Union[argparse.ArgumentParser, argparse._ArgumentGroup]) -> None: 227 """ 228 Add this API parameter to the given parser. 229 If this parameter is not supposed to be represented as a CLI argument, it will not be added. 230 """ 231 232 if (not self.cli): 233 return 234 235 # Check if there is a specialized function for this param. 236 if (self.cli_add_func is not None): 237 self.cli_add_func(parser, self) 238 return 239 240 kwargs = { 241 'dest': self.config_key, 242 'action': self.cli_action, 243 'help': self.description, 244 } 245 246 if (self.cli_action not in {'store_true', 'store_false'}): 247 kwargs['type'] = self.cli_type 248 249 if (self.cli_required): 250 kwargs['required'] = True 251 else: 252 kwargs['required'] = False 253 kwargs['default'] = self.cli_default_value 254 255 if (self.cli_show_default): 256 kwargs['help'] += ' (default: %(default)s)' 257 258 kwargs.update(self.cli_extra_options) 259 260 parser.add_argument(f'--{self.cli_flag}', **kwargs) 261 262class ArgumentMap(argparse.Action): 263 """ An argparse Action for key/value pairs separated with MAP_KEY_VALUE_SEP. """ 264 265 def __call__(self, parser, namespace, raw_values, option_string = None): # type: ignore[no-untyped-def] 266 if (not hasattr(namespace, self.dest)): 267 setattr(namespace, self.dest, {}) 268 269 all_values = getattr(namespace, self.dest) 270 if (all_values is None): 271 all_values = {} 272 setattr(namespace, self.dest, all_values) 273 274 for raw_value in raw_values: 275 parts = raw_value.split(MAP_KEY_VALUE_SEP, 1) 276 if (len(parts) != 2): 277 raise ValueError(f"Argument map key/value pair ('{raw_value}') is missing a separator '{MAP_KEY_VALUE_SEP}'.") 278 279 all_values[parts[0].strip()] = parts[1].strip() 280 281def _cli_add_func_submission_files(parser: typing.Union[argparse.ArgumentParser, argparse._ArgumentGroup], param: APIParam) -> None: 282 parser.add_argument('files', metavar = 'FILE', 283 action = 'extend', type = str, nargs = '+', 284 help = param.description) 285 286def _cli_add_func_submission_specs(parser: typing.Union[argparse.ArgumentParser, argparse._ArgumentGroup], param: APIParam) -> None: 287 parser.add_argument('submission_specs', metavar = 'SUBMISSION', 288 action = 'extend', type = str, nargs = '+', 289 help = param.description) 290 291def _cli_add_func_upsert_zip(parser: typing.Union[argparse.ArgumentParser, argparse._ArgumentGroup], param: APIParam) -> None: 292 parser.add_argument('path', metavar = 'PATH', 293 action = 'store', type = str, 294 help = param.description) 295 296# This is used as an argparse argument type. 297# See: https://docs.python.org/3/library/argparse.html#type 298# This converts a csv string and returns a list of strings, 299# e.g. --to email1@gmail.com,email2@gmail.com -> ["email1@gmail.com", "email2@gmail.com"]. 300# This will strip each compnent. 301def _csv_to_list(raw_argument: typing.Union[None, str]) -> typing.List[str]: 302 if (raw_argument is None): 303 raise ValueError('Parameter argument cannot be None.') 304 305 raw_argument = raw_argument.strip() 306 if (len(raw_argument) == 0): 307 raise ValueError('Parameter argument cannot be empty.') 308 309 return [part.strip() for part in raw_argument.split(CSV_TO_LIST_DELIMITER)] 310 311# Common API params. 312 313PARAM_ALLOW_LATE = APIParam( 314 'allow_late', 315 'Allow this submission to be graded, even if it is late.', 316 value_type = bool, 317 cli_default_value = False, 318) 319 320PARAM_ASSIGNMENT = APIParam( 321 'assignment', 322 'The ID of the assignment to make this request to.', 323 api_key = 'assignment-id', 324 cli_show_default = False, 325) 326 327PARAM_COURSE = APIParam( 328 'course', 329 'The ID of the course to make this request to.', 330 api_key = 'course-id', 331 cli_show_default = False, 332) 333 334PARAM_COURSE_USER_REFERENCES = APIParam( 335 'target_users', 336 ('A CSV list of course user references.' 337 + ' Course user references may be specified in four ways:' 338 + ' 1) Email address of the requested user,' 339 + ' 2) "*" to request all users in the course,' 340 + ' 3) "<course role>" (e.g., student, grader) to request all course users with that role,' 341 + ' and 4) any of the previous options preceded by a minus sign (e.g., "-alice@test.edulinq.org", "-student")' 342 + ' to exclude that user or role from the request.' 343 + ' Default: All users in the course.'), 344 api_required = False, 345 value_type = list, 346 cli_action = 'extend', 347 cli_type = _csv_to_list, 348 cli_show_default = False, 349) 350 351PARAM_DRY_RUN = APIParam( 352 'dry_run', 353 'Do not commit/finalize the operation, just do all the steps and state what the result would look like.', 354 api_required = False, 355 value_type = bool, 356 cli_default_value = False, 357) 358 359PARAM_EMAIL_BODY = APIParam( 360 'body', 361 'The email body.', 362 api_required = False, 363) 364 365PARAM_EMAIL_COURSE_BCC = APIParam( 366 'bcc', 367 'A CSV list of email addresses. Accepts course user references.', 368 api_required = False, 369 value_type = list, 370 cli_action = 'extend', 371 cli_type = _csv_to_list, 372 cli_show_default = False, 373) 374 375PARAM_EMAIL_COURSE_CC = APIParam( 376 'cc', 377 'A CSV list of email addresses. Accepts course user references.', 378 api_required = False, 379 value_type = list, 380 cli_action = 'extend', 381 cli_type = _csv_to_list, 382 cli_show_default = False, 383) 384 385PARAM_EMAIL_COURSE_TO = APIParam( 386 'to', 387 ('A CSV list of email addresses.' 388 + ' Accepts course user references.' 389 + ' Course user references may be specified in four ways:' 390 + ' 1) Email address of the requested user,' 391 + ' 2) "*" to request all users in the course,' 392 + ' 3) "<course role>" (e.g., student, grader)' 393 + ' to request all course users with that role,' 394 + ' and 4) any of the previous options preceded by a minus sign' 395 + ' (e.g., "-alice@test.edulinq.org", "-student")' 396 + ' to exclude that user or role from the request.'), 397 api_required = False, 398 value_type = list, 399 cli_action = 'extend', 400 cli_type = _csv_to_list, 401 cli_show_default = False, 402) 403 404PARAM_EMAIL_HTML = APIParam( 405 'email_html', 406 'Indicates the email body contains HTML.', 407 api_key = 'html', 408 cli_flag = 'html', 409 api_required = False, 410 value_type = bool, 411 cli_default_value = False, 412) 413 414PARAM_EMAIL_SUBJECT = APIParam( 415 'subject', 416 'The email subject.', 417 cli_required = True, 418) 419 420PARAM_FILESPEC_PART_PATH = APIParam( 421 'filespec_path', 422 'The path the filespec points to.', 423 api = False, 424 cli_required = True, 425 cli_flag = 'path', 426 cli_default_value = '', 427 cli_show_default = False, 428) 429 430PARAM_FILESPEC_PART_REFERENCE = APIParam( 431 'filespec_reference', 432 'The reference (e.g., git commit/branch) of the filespec.', 433 api = False, 434 cli_flag = 'reference', 435 cli_default_value = '', 436 cli_show_default = False, 437) 438 439PARAM_FILESPEC_PART_TOKEN = APIParam( 440 'filespec_token', 441 'The token for filespec authentication.', 442 api = False, 443 cli_flag = 'token', 444 cli_default_value = '', 445 cli_show_default = False, 446) 447 448PARAM_FILESPEC_PART_TYPE = APIParam( 449 'filespec_type', 450 'The type of filespec.', 451 api = False, 452 cli_required = True, 453 cli_flag = 'type', 454 cli_default_value = '', 455 cli_show_default = False, 456) 457 458PARAM_FILESPEC_PART_USERNAME = APIParam( 459 'filespec_username', 460 'The username for filespec authentication.', 461 api = False, 462 cli_flag = 'username', 463 cli_default_value = '', 464 cli_show_default = False, 465) 466 467PARAM_FORCE_COMPUTE = APIParam( 468 'force_compute', 469 'Force the server to compute the result, ignoring any existing cache.', 470 api_required = False, 471 value_type = bool, 472 cli_default_value = False, 473) 474 475PARAM_MESSAGE = APIParam( 476 'message', 477 'An optional message to include with this operation.', 478 api_required = False, 479) 480 481PARAM_NAME = APIParam( 482 'name', 483 'An optional name to use.', 484 api_required = False, 485) 486 487PARAM_NEW_PASS = APIParam( 488 'new_pass', 489 'The new password.', 490 hash_value = True, 491 cli_required = True, 492) 493 494PARAM_NEW_USER_COURSE = APIParam( 495 'new_course', 496 'An optional course to enroll the new user in', 497 api = False, 498 cli_default_value = '', 499 cli_show_default = False, 500) 501 502PARAM_NEW_USER_COURSE_ROLE = APIParam( 503 'new_course_role', 504 'The course role for the new user.', 505 api = False, 506 cli_default_value = autograder.model.user.CourseRole.STUDENT.value, 507 cli_extra_options = {'choices': [role.value for role in autograder.model.user.CourseRole]}, 508) 509 510PARAM_NEW_USER_EMAIL = APIParam( 511 'new_email', 512 'The email for the new user.', 513 api = False, 514 cli_required = True, 515) 516 517PARAM_NEW_USER_NAME = APIParam( 518 'new_name', 519 'The name for the new user.', 520 api = False, 521 cli_default_value = '', 522 cli_show_default = False, 523) 524 525PARAM_NEW_USER_PASS = APIParam( 526 'new_pass', 527 'The password for the new user (random if not supplied).', 528 api = False, 529 cli_default_value = '', 530 cli_show_default = False, 531) 532 533PARAM_NEW_USER_LMS_ID = APIParam( 534 'new_lms_id', 535 'The LMS ID for the new user.', 536 api = False, 537 cli_default_value = '', 538 cli_show_default = False, 539) 540 541PARAM_NEW_USER_SERVER_ROLE = APIParam( 542 'new_role', 543 'The server role for the new user.', 544 api = False, 545 cli_default_value = autograder.model.user.ServerRole.USER.value, 546 cli_extra_options = {'choices': [role.value for role in autograder.model.user.ServerRole]}, 547) 548 549PARAM_OVERWRITE_RECORDS = APIParam( 550 'overwrite_records', 551 'Replace any existing records that match the current operation (e.g. re-do existing results).', 552 value_type = bool, 553 cli_default_value = False, 554) 555 556PARAM_OUT_DIR = APIParam( 557 'out_dir', 558 'A directory to write output in.', 559 api = False, 560 cli_default_value = '.', 561) 562 563PARAM_PROXY_EMAIL = APIParam( 564 'proxy_email', 565 'The email of the user the request is pretending to be made under (the submission will be made on behalf of this user).', 566 cli_required = True, 567) 568 569PARAM_PROXY_TIME = APIParam( 570 'proxy_time', 571 ('The proxy timestamp that will be applied to the request.' 572 + ' By default, the earlier time between now and' 573 + ' one minute before the due date will be used.' 574 + ' Use this option to manually set the proxy time.' 575 + ' Timestamps are milliseconds from the UNIX epoch' 576 + ' (https://en.wikipedia.org/wiki/Unix_time).'), 577 value_type = edq.util.time.Timestamp, 578 cli_type = str, 579 api_required = False, 580) 581 582PARAM_QUERY_AFTER = APIParam( 583 'query_after', 584 'If supplied, only return records after this timestamp.', 585 api_key = 'after', 586 cli_flag = 'after', 587 value_type = edq.util.time.Timestamp, 588 api_required = False, 589) 590 591PARAM_QUERY_BEFORE = APIParam( 592 'query_before', 593 'If supplied, only return records before this timestamp.', 594 api_key = 'before', 595 cli_flag = 'before', 596 value_type = edq.util.time.Timestamp, 597 api_required = False, 598) 599 600PARAM_QUERY_LIMIT = APIParam( 601 'limit', 602 'The maximum number of records to return.', 603 api_key = 'limit', 604 cli_flag = 'limit', 605 value_type = int, 606 api_required = False, 607) 608 609PARAM_QUERY_LOG_LEVEL = APIParam( 610 'query_level', 611 'The minimum level of log records to return.', 612 api_key = 'level', 613 cli_flag = 'level', 614 api_required = False, 615 cli_default_value = 'INFO', 616 cli_extra_options = {'choices': autograder.model.log.LOG_LEVEL_TEXT_TO_INT.keys()} 617) 618 619PARAM_QUERY_METRIC_TYPE = APIParam( 620 'query_metric_type', 621 'The type of metric to query for. See: https://github.com/edulinq/autograder-server/blob/main/internal/stats/metrics.go#L29', 622 api_key = 'type', 623 cli_flag = 'metric-type', 624 cli_required = True, 625 cli_extra_options = {'choices': autograder.model.stats.METRIC_TYPES} 626) 627 628PARAM_QUERY_PAST = APIParam( 629 'query_past', 630 'If supplied, only return log records in this duration (using "h", "m", or "s" suffixes) (e.g., "24h", "10m", or "1h10m10s").', 631 api_key = 'past', 632 cli_flag = 'past', 633 api_required = False, 634) 635 636PARAM_QUERY_SORT = APIParam( 637 'query_sort', 638 'Sort the results. -1 for ascending, 0 for no sorting, 1 for descending.', 639 api_key = 'sort', 640 cli_flag = 'sort', 641 value_type = int, 642 api_required = False, 643) 644 645PARAM_QUERY_TARGET_ASSIGNMENT = APIParam( 646 'query_target_assignment', 647 'If supplied, only return records for this assignment.', 648 api_key = 'target-assignment', 649 cli_flag = 'target-assignment', 650 api_required = False, 651 cli_show_default = False, 652) 653 654PARAM_QUERY_TARGET_COURSE = APIParam( 655 'query_target_course', 656 'If supplied, only return records for this course.', 657 api_key = 'target-course', 658 cli_flag = 'target-course', 659 api_required = False, 660 cli_show_default = False, 661) 662 663PARAM_QUERY_TARGET_EMAIL = APIParam( 664 'query_target_email', 665 'If supplied, only return records for this user.', 666 api_key = 'target-email', 667 cli_flag = 'target-email', 668 api_required = False, 669 cli_show_default = False, 670) 671 672PARAM_QUERY_USE_TESTING_DATA = APIParam( 673 'query_use_testing_data', 674 'Query from hard-coded testing data (instead of real data).', 675 api_key = 'use-testing-data', 676 value_type = bool, 677 api_required = False, 678 cli = False, 679) 680 681PARAM_QUERY_WHERE = APIParam( 682 'query_where', 683 'Only includes records with a patching key/value pair.', 684 api_key = 'where', 685 cli_flag = 'where', 686 api_required = False, 687 value_type = dict, 688 cli_action = ArgumentMap, 689 cli_extra_options = { 690 'metavar': (f"KEY{MAP_KEY_VALUE_SEP}VALUE"), 691 'nargs': '+', 692 }, 693) 694 695PARAM_RAW_COURSE_USERS = APIParam( 696 'raw_course_users', 697 'Raw course users to operate on.', 698 value_type = list, 699 cli = False, 700 skip_clean = True, 701) 702 703PARAM_RAW_SERVER_USERS = APIParam( 704 'raw_server_users', 705 'Raw server users to operate on.', 706 value_type = list, 707 api_key = 'raw-users', 708 cli = False, 709 skip_clean = True, 710) 711 712PARAM_REGRADE_CUTOFF = APIParam( 713 'regrade_cutoff', 714 ('All submissions occurring before the cutoff time will be regraded.' 715 + ' By default, the current time will be used.' 716 + ' Time is milliseconds from the UNIX epoch' 717 + ' (https://en.wikipedia.org/wiki/Unix_time).'), 718 value_type = int, 719 api_required = False, 720) 721 722PARAM_SEND_EMAILS = APIParam( 723 'send_emails', 724 'Send any relevant emails to users affected by this operation (e.g., a user being enrolled in a course).', 725 value_type = bool, 726 cli_default_value = False, 727) 728 729PARAM_SERVER = APIParam( 730 'server', 731 'The URL of the autograder server to communicate with.', 732 cli_show_default = False, 733) 734 735PARAM_SKIP_BUILD_IMAGES = APIParam( 736 'skip_build_images', 737 'Skip building assignment Docker images.', 738 value_type = bool, 739 cli_default_value = False, 740) 741 742PARAM_SKIP_EMAILS = APIParam( 743 'skip_emails', 744 'Skip sending any emails.', 745 value_type = bool, 746 cli_default_value = False, 747) 748 749PARAM_SKIP_INSERTS = APIParam( 750 'skip_inserts', 751 'Skip insert operations.', 752 value_type = bool, 753 cli_default_value = False, 754) 755 756PARAM_SKIP_LMS_SYNC = APIParam( 757 'skip_lms_sync', 758 'Skip syncing with the LMS.', 759 value_type = bool, 760 cli_default_value = False, 761) 762 763PARAM_SKIP_SOURCE_SYNC = APIParam( 764 'skip_source_sync', 765 'Skip syncing (updating with) the course source.', 766 value_type = bool, 767 cli_default_value = False, 768) 769 770PARAM_SKIP_TEMPLATE_FILES = APIParam( 771 'skip_template_files', 772 'Skip fetching assignment template files.', 773 value_type = bool, 774 cli_default_value = False, 775) 776 777PARAM_SKIP_UPDATES = APIParam( 778 'skip_updates', 779 'Skip update operations.', 780 value_type = bool, 781 cli_default_value = False, 782) 783 784PARAM_STATUS_ACTIVE = APIParam( 785 'active', 786 'Mark the course as active. Omit to mark it as inactive.', 787 api_required = False, 788 value_type = bool, 789 cli_default_value = False, 790) 791 792PARAM_STATUS_CLEAR = APIParam( 793 'clear', 794 'Ignore the target owner and remove all statuses the caller has permission to remove.', 795 api_required = False, 796 value_type = bool, 797 cli_default_value = False, 798) 799 800PARAM_STATUS_FORCE = APIParam( 801 'force', 802 'If the status already exists for the caller, overwrite it.', 803 api_required = False, 804 value_type = bool, 805 cli_default_value = False, 806) 807 808PARAM_SUBMISSION_FILES = APIParam( 809 'files', 810 'The path to your submission file(s).', 811 value_type = list, 812 api = False, 813 cli_add_func = _cli_add_func_submission_files, 814) 815 816PARAM_SUBMISSION_SPECS = APIParam( 817 'submission_specs', 818 ('A list of submission specifications to analyze.' 819 + ' Submissions may span courses and assignments.' 820 + ' Submissions may be specified in three ways:' 821 + ' 1) "<course id>::<assignment id>::<user email>::<submission short id> for a specific submission,' 822 + ' 2) "<course id>::<assignment id>::<user email> for the given user\'s most recent submission to the given assignment,' 823 + ' and 3) "<course id>::<assignment id> for the most recent submission for all students.'), 824 api_key = 'submissions', 825 cli_flag = 'submissions', 826 value_type = list, 827 cli_add_func = _cli_add_func_submission_specs, 828) 829 830PARAM_TARGET_ASSIGNMENTS = APIParam( 831 'target_assignments', 832 ('A CSV list of assignment IDs to target.' 833 + ' Default: All assignments in the course.'), 834 api_required = False, 835 value_type = list, 836 cli_action = 'extend', 837 cli_type = _csv_to_list, 838 cli_show_default = False, 839) 840 841PARAM_TARGET_EMAIL = APIParam( 842 'target_email', 843 'The email of the user that is the target of this request.', 844) 845 846PARAM_TARGET_EMAIL_OR_SELF = APIParam( 847 'target_email', 848 'The email of the user that is the target of this request (defaults to you).', 849 api_required = False, 850) 851 852PARAM_TARGET_OWNER = APIParam( 853 'target_owner', 854 'The email of the course status owner that is the target of this request (defaults to you).', 855 api_required = False, 856) 857 858PARAM_TARGET_USER_OR_SELF = APIParam( 859 'target_user', 860 'The user that is the target of this request (defaults to you).', 861 api_required = False, 862) 863 864PARAM_TARGET_USERS = APIParam( 865 'target_users', 866 ('A CSV list of server user references.' 867 + ' Server user references may be specified in eight ways:' 868 + ' 1) Email address of the requested user,' 869 + ' 2) "*" to request all users in the server,' 870 + ' 3) "<server role>" (e.g., user, creator)' 871 + ' to request all users with that server role,' 872 + ' 4) "<course id>::<course role>" (e.g., course101::student)' 873 + ' to request all users in the target course with that course role,' 874 + ' 5) "*::<course role>" (e.g., *::student)' 875 + ' to request all users with that course role in any course,' 876 + ' 6) "<course id>::*" to request all users in the target course,' 877 + ' 7) "*::*" to request all users enrolled in at least one course,' 878 + ' and 8) any of the previous options preceded by a minus sign' 879 + ' (e.g., "-alice@test.edulinq.org", "-user", "-*::student")' 880 + ' to exclude that user or group from the request.' 881 + ' Default: All users in the server.'), 882 api_required = False, 883 value_type = list, 884 cli_action = 'extend', 885 cli_type = _csv_to_list, 886 cli_show_default = False, 887) 888 889PARAM_TARGET_SUBMISSION_OR_RECENT = APIParam( 890 'target_submission', 891 'The ID of the submission (default to the most recent submission).', 892 api_required = False, 893) 894 895PARAM_TOKEN_ID = APIParam( 896 'token_id', 897 'The id of the token to target.', 898 cli_required = True, 899) 900 901PARAM_UPSERT_FILESPEC = APIParam( 902 'filespec', 903 'A filespec pointing to a course to upload.', 904 value_type = autograder.filespec.FileSpec, 905 cli = False, 906) 907 908PARAM_UPSERT_ZIP = APIParam( 909 'path', 910 ('The path to your course material.' 911 + ' Either a zip file (with .zip extension) or directory (that will get zipped).'), 912 value_type = str, 913 api = False, 914 cli_add_func = _cli_add_func_upsert_zip, 915) 916 917PARAM_USER_EMAIL = APIParam( 918 'user', 919 'The email of the user making this request.', 920 alt_config_key = 'auth_user', 921 api_key = 'user-email', 922 cli_flag = 'user', 923 cli_show_default = False, 924) 925 926PARAM_USER_PASS = APIParam( 927 'pass', 928 'The password of the user making this request.', 929 alt_config_key = 'auth_pass', 930 api_key = 'user-pass', 931 cli_flag = 'pass', 932 hash_value = True, 933 cli_show_default = False, 934) 935 936PARAM_WAIT_FOR_COMPLETION = APIParam( 937 'wait_for_completion', 938 'Wait for the full job to complete before returning.', 939 value_type = bool, 940 cli_default_value = False, 941)
Map value types to default CLI actions.
30@typing.runtime_checkable 31class CLIAddFunction(typing.Protocol): 32 """ A function for manually adding APIParams to a parser. """ 33 34 def __call__(self, parser: typing.Union[argparse.ArgumentParser, argparse._ArgumentGroup], param: 'APIParam') -> None: 35 """ 36 Add the given APIParam to the parser. 37 """
A function for manually adding APIParams to a parser.
1953def _no_init_or_replace_init(self, *args, **kwargs): 1954 cls = type(self) 1955 1956 if cls._is_protocol: 1957 raise TypeError('Protocols cannot be instantiated') 1958 1959 # Already using a custom `__init__`. No need to calculate correct 1960 # `__init__` to call. This can lead to RecursionError. See bpo-45121. 1961 if cls.__init__ is not _no_init_or_replace_init: 1962 return 1963 1964 # Initially, `__init__` of a protocol subclass is set to `_no_init_or_replace_init`. 1965 # The first instantiation of the subclass will call `_no_init_or_replace_init` which 1966 # searches for a proper new `__init__` in the MRO. The new `__init__` 1967 # replaces the subclass' old `__init__` (ie `_no_init_or_replace_init`). Subsequent 1968 # instantiation of the protocol subclass will thus use the new 1969 # `__init__` and no longer call `_no_init_or_replace_init`. 1970 for base in cls.__mro__: 1971 init = base.__dict__.get('__init__', _no_init_or_replace_init) 1972 if init is not _no_init_or_replace_init: 1973 cls.__init__ = init 1974 break 1975 else: 1976 # should not happen 1977 cls.__init__ = object.__init__ 1978 1979 cls.__init__(self, *args, **kwargs)
39class APIParam: 40 """ 41 A definition for a parameter to the autograder API. 42 This class also contains information for representing these parameters in 43 config (dicts), on the CLI (argparse options), and in the payload of autograder requests. 44 """ 45 46 def __init__(self, 47 config_key: str, 48 description: str, 49 alt_config_key: typing.Union[str, None] = None, 50 api_key: typing.Union[str, None] = None, 51 cli_flag: typing.Union[str, None] = None, 52 api: bool = True, 53 api_required: typing.Union[bool, None] = None, # Default to the value of `api`. 54 cli: bool = True, 55 cli_required: typing.Union[bool, None] = None, # Default to false. 56 value_type: typing.Type = str, 57 cli_type: typing.Union[typing.Any, None] = None, 58 skip_clean: bool = False, 59 hash_value: bool = False, 60 omit_empty: bool = True, 61 cli_action: typing.Union[typing.Any, None] = None, 62 cli_default_value: typing.Any = None, 63 cli_show_default: typing.Union[bool, None] = None, 64 cli_extra_options: typing.Union[typing.Dict[str, typing.Any], None] = None, 65 cli_add_func: typing.Union[CLIAddFunction, None] = None, 66 **kwargs: typing.Any) -> None: 67 if (len(config_key.strip()) == 0): 68 raise autograder.error.APIError(None, "APIParam cannot have an empty key.") 69 70 self.config_key: str = config_key 71 """ 72 The main key/label used to reference this parameter. 73 Will be used in the application config and config dicts. 74 """ 75 76 if (len(description) == 0): 77 raise autograder.error.APIError(None, "APIParam cannot have an empty description.") 78 79 self.description: str = description 80 """ A description used for this parameter. """ 81 82 self.alt_config_key: typing.Union[str, None] = alt_config_key 83 """ 84 An alternative config key that the value could be found under when reading a config file. 85 A value found under this key will always be stored under self.config_key. 86 """ 87 88 if (api_key is None): 89 api_key = config_key.replace('_', '-') 90 91 self.api_key: str = api_key 92 """ The key in the API request payload for this parameter. """ 93 94 if (cli_flag is None): 95 cli_flag = config_key.replace('_', '-') 96 97 self.cli_flag: str = cli_flag 98 """ The flag used on the CLI for this parameter. """ 99 100 self.api: bool = api 101 """ If this parameter should be included on the API payload. """ 102 103 if (api_required is None): 104 api_required = api 105 106 self.api_required: bool = api_required 107 """ If this parameter is required when calling the API. """ 108 109 self.cli: bool = cli 110 """ If this parameter should be included on the CLI. """ 111 112 if (cli_required is None): 113 cli_required = False 114 115 self.cli_required: bool = cli_required 116 """ If this parameter is required on the CLI. """ 117 118 self.value_type: typing.Type = value_type 119 """ The type that a clean value from this parameter should be. """ 120 121 if (cli_type is None): 122 cli_type = self.value_type 123 124 self.cli_type: typing.Any = cli_type 125 """ 126 The type of value that will be looked for on the CLI. 127 Defaults to the same as `self.value_type`. 128 129 Note that this value will be used as the `type` argument to `argparse.ArgumentParser.add_argument()`. 130 """ 131 132 self.skip_clean: bool = skip_clean 133 """ Skip any cleaning steps for this parameter's value. """ 134 135 self.hash_value: bool = hash_value 136 """ Hash the value during the cleaning processes (typically used for passwords). """ 137 138 self.omit_empty: bool = omit_empty 139 """ Do not include this parameter in the API payload if its value is empty. """ 140 141 self.cli_default_value: typing.Any = cli_default_value 142 """ The default value for the CLI argument. """ 143 144 if (cli_show_default is None): 145 # Usually we will want to show the default value, 146 # but boolean flags don't make much sense to show defaults with. 147 cli_show_default = (value_type != bool) 148 149 self.cli_show_default: bool = cli_show_default 150 """ Show the default value (in CLI's help) for a non-required param. """ 151 152 if (cli_extra_options is None): 153 cli_extra_options = {} 154 155 self.cli_extra_options: typing.Dict[str, typing.Any] = cli_extra_options 156 """ 157 Additional options to pass (as kwargs) to `argparse.ArgumentParser.add_argument()`. 158 These should not conflict with the options set using the direct members. 159 """ 160 161 self.cli_add_func: typing.Union[CLIAddFunction, None] = cli_add_func 162 """ A function that can be used to override the standard behavior in add_to_parser(). """ 163 164 if (self.cli and (self.cli_add_func is None) and (cli_action is None)): 165 # Choose action based on the value's type. 166 cli_action = DEFAULT_CLI_ACTIONS.get(self.value_type, None) 167 if (cli_action is None): 168 raise ValueError(f"Unknown action for value type '{self.value_type}'.") 169 170 self.cli_action: typing.Any = cli_action 171 """ The value that value will be used as the `action` argument to `argparse.ArgumentParser.add_argument()`. """ 172 173 def clean_value(self, value: typing.Union[typing.Any, None]) -> typing.Union[typing.Any, None]: 174 """ 175 Return a clean version of the given value (according to this API param). 176 An error will be raised if cleaning fails. 177 Empty values will be returned as None. 178 """ 179 180 if (self.skip_clean): 181 return value 182 183 if (value is None): 184 return None 185 186 if (issubclass(self.value_type, edq.util.time.Timestamp)): 187 return edq.util.time.Timestamp.guess(value) 188 189 if (issubclass(self.value_type, bool)): 190 return edq.util.parse.boolean(value) 191 192 if (issubclass(self.value_type, int)): 193 return int(value) 194 195 if (issubclass(self.value_type, list)): 196 return autograder.util.parse.string_list(value) 197 198 if (issubclass(self.value_type, str)): 199 value = str(value).strip() 200 if (len(value) == 0): 201 return None 202 203 if (self.hash_value): 204 value = edq.util.hash.sha256_hex(value) 205 206 if (issubclass(self.value_type, autograder.filespec.FileSpec)): 207 return autograder.filespec.parse(value) 208 209 return value 210 211 def optional(self) -> 'APIParam': 212 """ Make a copy of this APIParam, mark the copy as optional, and return the copy. """ 213 214 new_param = copy.deepcopy(self) 215 new_param.api_required = False 216 new_param.cli_required = False 217 return new_param 218 219 def required(self) -> 'APIParam': 220 """ Make a copy of this APIParam, mark the copy as required, and return the copy. """ 221 222 new_param = copy.deepcopy(self) 223 new_param.api_required = True 224 new_param.cli_required = True 225 return new_param 226 227 def add_to_parser(self, parser: typing.Union[argparse.ArgumentParser, argparse._ArgumentGroup]) -> None: 228 """ 229 Add this API parameter to the given parser. 230 If this parameter is not supposed to be represented as a CLI argument, it will not be added. 231 """ 232 233 if (not self.cli): 234 return 235 236 # Check if there is a specialized function for this param. 237 if (self.cli_add_func is not None): 238 self.cli_add_func(parser, self) 239 return 240 241 kwargs = { 242 'dest': self.config_key, 243 'action': self.cli_action, 244 'help': self.description, 245 } 246 247 if (self.cli_action not in {'store_true', 'store_false'}): 248 kwargs['type'] = self.cli_type 249 250 if (self.cli_required): 251 kwargs['required'] = True 252 else: 253 kwargs['required'] = False 254 kwargs['default'] = self.cli_default_value 255 256 if (self.cli_show_default): 257 kwargs['help'] += ' (default: %(default)s)' 258 259 kwargs.update(self.cli_extra_options) 260 261 parser.add_argument(f'--{self.cli_flag}', **kwargs)
A definition for a parameter to the autograder API. This class also contains information for representing these parameters in config (dicts), on the CLI (argparse options), and in the payload of autograder requests.
46 def __init__(self, 47 config_key: str, 48 description: str, 49 alt_config_key: typing.Union[str, None] = None, 50 api_key: typing.Union[str, None] = None, 51 cli_flag: typing.Union[str, None] = None, 52 api: bool = True, 53 api_required: typing.Union[bool, None] = None, # Default to the value of `api`. 54 cli: bool = True, 55 cli_required: typing.Union[bool, None] = None, # Default to false. 56 value_type: typing.Type = str, 57 cli_type: typing.Union[typing.Any, None] = None, 58 skip_clean: bool = False, 59 hash_value: bool = False, 60 omit_empty: bool = True, 61 cli_action: typing.Union[typing.Any, None] = None, 62 cli_default_value: typing.Any = None, 63 cli_show_default: typing.Union[bool, None] = None, 64 cli_extra_options: typing.Union[typing.Dict[str, typing.Any], None] = None, 65 cli_add_func: typing.Union[CLIAddFunction, None] = None, 66 **kwargs: typing.Any) -> None: 67 if (len(config_key.strip()) == 0): 68 raise autograder.error.APIError(None, "APIParam cannot have an empty key.") 69 70 self.config_key: str = config_key 71 """ 72 The main key/label used to reference this parameter. 73 Will be used in the application config and config dicts. 74 """ 75 76 if (len(description) == 0): 77 raise autograder.error.APIError(None, "APIParam cannot have an empty description.") 78 79 self.description: str = description 80 """ A description used for this parameter. """ 81 82 self.alt_config_key: typing.Union[str, None] = alt_config_key 83 """ 84 An alternative config key that the value could be found under when reading a config file. 85 A value found under this key will always be stored under self.config_key. 86 """ 87 88 if (api_key is None): 89 api_key = config_key.replace('_', '-') 90 91 self.api_key: str = api_key 92 """ The key in the API request payload for this parameter. """ 93 94 if (cli_flag is None): 95 cli_flag = config_key.replace('_', '-') 96 97 self.cli_flag: str = cli_flag 98 """ The flag used on the CLI for this parameter. """ 99 100 self.api: bool = api 101 """ If this parameter should be included on the API payload. """ 102 103 if (api_required is None): 104 api_required = api 105 106 self.api_required: bool = api_required 107 """ If this parameter is required when calling the API. """ 108 109 self.cli: bool = cli 110 """ If this parameter should be included on the CLI. """ 111 112 if (cli_required is None): 113 cli_required = False 114 115 self.cli_required: bool = cli_required 116 """ If this parameter is required on the CLI. """ 117 118 self.value_type: typing.Type = value_type 119 """ The type that a clean value from this parameter should be. """ 120 121 if (cli_type is None): 122 cli_type = self.value_type 123 124 self.cli_type: typing.Any = cli_type 125 """ 126 The type of value that will be looked for on the CLI. 127 Defaults to the same as `self.value_type`. 128 129 Note that this value will be used as the `type` argument to `argparse.ArgumentParser.add_argument()`. 130 """ 131 132 self.skip_clean: bool = skip_clean 133 """ Skip any cleaning steps for this parameter's value. """ 134 135 self.hash_value: bool = hash_value 136 """ Hash the value during the cleaning processes (typically used for passwords). """ 137 138 self.omit_empty: bool = omit_empty 139 """ Do not include this parameter in the API payload if its value is empty. """ 140 141 self.cli_default_value: typing.Any = cli_default_value 142 """ The default value for the CLI argument. """ 143 144 if (cli_show_default is None): 145 # Usually we will want to show the default value, 146 # but boolean flags don't make much sense to show defaults with. 147 cli_show_default = (value_type != bool) 148 149 self.cli_show_default: bool = cli_show_default 150 """ Show the default value (in CLI's help) for a non-required param. """ 151 152 if (cli_extra_options is None): 153 cli_extra_options = {} 154 155 self.cli_extra_options: typing.Dict[str, typing.Any] = cli_extra_options 156 """ 157 Additional options to pass (as kwargs) to `argparse.ArgumentParser.add_argument()`. 158 These should not conflict with the options set using the direct members. 159 """ 160 161 self.cli_add_func: typing.Union[CLIAddFunction, None] = cli_add_func 162 """ A function that can be used to override the standard behavior in add_to_parser(). """ 163 164 if (self.cli and (self.cli_add_func is None) and (cli_action is None)): 165 # Choose action based on the value's type. 166 cli_action = DEFAULT_CLI_ACTIONS.get(self.value_type, None) 167 if (cli_action is None): 168 raise ValueError(f"Unknown action for value type '{self.value_type}'.") 169 170 self.cli_action: typing.Any = cli_action 171 """ The value that value will be used as the `action` argument to `argparse.ArgumentParser.add_argument()`. """
The main key/label used to reference this parameter. Will be used in the application config and config dicts.
An alternative config key that the value could be found under when reading a config file. A value found under this key will always be stored under self.config_key.
The type of value that will be looked for on the CLI.
Defaults to the same as self.value_type.
Note that this value will be used as the type argument to argparse.ArgumentParser.add_argument().
Additional options to pass (as kwargs) to argparse.ArgumentParser.add_argument().
These should not conflict with the options set using the direct members.
A function that can be used to override the standard behavior in add_to_parser().
The value that value will be used as the action argument to argparse.ArgumentParser.add_argument().
173 def clean_value(self, value: typing.Union[typing.Any, None]) -> typing.Union[typing.Any, None]: 174 """ 175 Return a clean version of the given value (according to this API param). 176 An error will be raised if cleaning fails. 177 Empty values will be returned as None. 178 """ 179 180 if (self.skip_clean): 181 return value 182 183 if (value is None): 184 return None 185 186 if (issubclass(self.value_type, edq.util.time.Timestamp)): 187 return edq.util.time.Timestamp.guess(value) 188 189 if (issubclass(self.value_type, bool)): 190 return edq.util.parse.boolean(value) 191 192 if (issubclass(self.value_type, int)): 193 return int(value) 194 195 if (issubclass(self.value_type, list)): 196 return autograder.util.parse.string_list(value) 197 198 if (issubclass(self.value_type, str)): 199 value = str(value).strip() 200 if (len(value) == 0): 201 return None 202 203 if (self.hash_value): 204 value = edq.util.hash.sha256_hex(value) 205 206 if (issubclass(self.value_type, autograder.filespec.FileSpec)): 207 return autograder.filespec.parse(value) 208 209 return value
Return a clean version of the given value (according to this API param). An error will be raised if cleaning fails. Empty values will be returned as None.
211 def optional(self) -> 'APIParam': 212 """ Make a copy of this APIParam, mark the copy as optional, and return the copy. """ 213 214 new_param = copy.deepcopy(self) 215 new_param.api_required = False 216 new_param.cli_required = False 217 return new_param
Make a copy of this APIParam, mark the copy as optional, and return the copy.
219 def required(self) -> 'APIParam': 220 """ Make a copy of this APIParam, mark the copy as required, and return the copy. """ 221 222 new_param = copy.deepcopy(self) 223 new_param.api_required = True 224 new_param.cli_required = True 225 return new_param
Make a copy of this APIParam, mark the copy as required, and return the copy.
227 def add_to_parser(self, parser: typing.Union[argparse.ArgumentParser, argparse._ArgumentGroup]) -> None: 228 """ 229 Add this API parameter to the given parser. 230 If this parameter is not supposed to be represented as a CLI argument, it will not be added. 231 """ 232 233 if (not self.cli): 234 return 235 236 # Check if there is a specialized function for this param. 237 if (self.cli_add_func is not None): 238 self.cli_add_func(parser, self) 239 return 240 241 kwargs = { 242 'dest': self.config_key, 243 'action': self.cli_action, 244 'help': self.description, 245 } 246 247 if (self.cli_action not in {'store_true', 'store_false'}): 248 kwargs['type'] = self.cli_type 249 250 if (self.cli_required): 251 kwargs['required'] = True 252 else: 253 kwargs['required'] = False 254 kwargs['default'] = self.cli_default_value 255 256 if (self.cli_show_default): 257 kwargs['help'] += ' (default: %(default)s)' 258 259 kwargs.update(self.cli_extra_options) 260 261 parser.add_argument(f'--{self.cli_flag}', **kwargs)
Add this API parameter to the given parser. If this parameter is not supposed to be represented as a CLI argument, it will not be added.
263class ArgumentMap(argparse.Action): 264 """ An argparse Action for key/value pairs separated with MAP_KEY_VALUE_SEP. """ 265 266 def __call__(self, parser, namespace, raw_values, option_string = None): # type: ignore[no-untyped-def] 267 if (not hasattr(namespace, self.dest)): 268 setattr(namespace, self.dest, {}) 269 270 all_values = getattr(namespace, self.dest) 271 if (all_values is None): 272 all_values = {} 273 setattr(namespace, self.dest, all_values) 274 275 for raw_value in raw_values: 276 parts = raw_value.split(MAP_KEY_VALUE_SEP, 1) 277 if (len(parts) != 2): 278 raise ValueError(f"Argument map key/value pair ('{raw_value}') is missing a separator '{MAP_KEY_VALUE_SEP}'.") 279 280 all_values[parts[0].strip()] = parts[1].strip()
An argparse Action for key/value pairs separated with MAP_KEY_VALUE_SEP.