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 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 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 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 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_NAME = APIParam( 476 'name', 477 'An optional name to use.', 478 api_required = False, 479) 480 481PARAM_NEW_PASS = APIParam( 482 'new_pass', 483 'The new password.', 484 hash_value = True, 485 cli_required = True, 486) 487 488PARAM_NEW_USER_COURSE = APIParam( 489 'new_course', 490 'An optional course to enroll the new user in', 491 api = False, 492 cli_default_value = '', 493 cli_show_default = False, 494) 495 496PARAM_NEW_USER_COURSE_ROLE = APIParam( 497 'new_course_role', 498 'The course role for the new user.', 499 api = False, 500 cli_default_value = autograder.model.user.CourseRole.STUDENT.value, 501 cli_extra_options = {'choices': [role.value for role in autograder.model.user.CourseRole]}, 502) 503 504PARAM_NEW_USER_EMAIL = APIParam( 505 'new_email', 506 'The email for the new user.', 507 api = False, 508 cli_required = True, 509) 510 511PARAM_NEW_USER_NAME = APIParam( 512 'new_name', 513 'The name for the new user.', 514 api = False, 515 cli_default_value = '', 516 cli_show_default = False, 517) 518 519PARAM_NEW_USER_PASS = APIParam( 520 'new_pass', 521 'The password for the new user (random if not supplied).', 522 api = False, 523 cli_default_value = '', 524 cli_show_default = False, 525) 526 527PARAM_NEW_USER_LMS_ID = APIParam( 528 'new_lms_id', 529 'The LMS ID for the new user.', 530 api = False, 531 cli_default_value = '', 532 cli_show_default = False, 533) 534 535PARAM_NEW_USER_SERVER_ROLE = APIParam( 536 'new_role', 537 'The server role for the new user.', 538 api = False, 539 cli_default_value = autograder.model.user.ServerRole.USER.value, 540 cli_extra_options = {'choices': [role.value for role in autograder.model.user.ServerRole]}, 541) 542 543PARAM_OVERWRITE_RECORDS = APIParam( 544 'overwrite_records', 545 'Replace any existing records that match the current operation (e.g. re-do existing results).', 546 value_type = bool, 547 cli_default_value = False, 548) 549 550PARAM_OUT_DIR = APIParam( 551 'out_dir', 552 'A directory to write output in.', 553 api = False, 554 cli_default_value = '.', 555) 556 557PARAM_PROXY_EMAIL = APIParam( 558 'proxy_email', 559 'The email of the user the request is pretending to be made under (the submission will be made on behalf of this user).', 560 cli_required = True, 561) 562 563PARAM_PROXY_TIME = APIParam( 564 'proxy_time', 565 ('The proxy timestamp that will be applied to the request.' 566 + ' By default, the earlier time between now and' 567 + ' one minute before the due date will be used.' 568 + ' Use this option to manually set the proxy time.' 569 + ' Timestamps are milliseconds from the UNIX epoch' 570 + ' (https://en.wikipedia.org/wiki/Unix_time).'), 571 value_type = edq.util.time.Timestamp, 572 cli_type = str, 573 api_required = False, 574) 575 576PARAM_QUERY_AFTER = APIParam( 577 'query_after', 578 'If supplied, only return records after this timestamp.', 579 api_key = 'after', 580 cli_flag = 'after', 581 value_type = edq.util.time.Timestamp, 582 api_required = False, 583) 584 585PARAM_QUERY_BEFORE = APIParam( 586 'query_before', 587 'If supplied, only return records before this timestamp.', 588 api_key = 'before', 589 cli_flag = 'before', 590 value_type = edq.util.time.Timestamp, 591 api_required = False, 592) 593 594PARAM_QUERY_LIMIT = APIParam( 595 'limit', 596 'The maximum number of records to return.', 597 api_key = 'limit', 598 cli_flag = 'limit', 599 value_type = int, 600 api_required = False, 601) 602 603PARAM_QUERY_LOG_LEVEL = APIParam( 604 'query_level', 605 'The minimum level of log records to return.', 606 api_key = 'level', 607 cli_flag = 'level', 608 api_required = False, 609 cli_default_value = 'INFO', 610 cli_extra_options = {'choices': autograder.model.log.LOG_LEVEL_TEXT_TO_INT.keys()} 611) 612 613PARAM_QUERY_METRIC_TYPE = APIParam( 614 'query_metric_type', 615 'The type of metric to query for. See: https://github.com/edulinq/autograder-server/blob/main/internal/stats/metrics.go#L29', 616 api_key = 'type', 617 cli_flag = 'metric-type', 618 cli_required = True, 619 cli_extra_options = {'choices': autograder.model.stats.METRIC_TYPES} 620) 621 622PARAM_QUERY_PAST = APIParam( 623 'query_past', 624 'If supplied, only return log records in this duration (using "h", "m", or "s" suffixes) (e.g., "24h", "10m", or "1h10m10s").', 625 api_key = 'past', 626 cli_flag = 'past', 627 api_required = False, 628) 629 630PARAM_QUERY_SORT = APIParam( 631 'query_sort', 632 'Sort the results. -1 for ascending, 0 for no sorting, 1 for descending.', 633 api_key = 'sort', 634 cli_flag = 'sort', 635 value_type = int, 636 api_required = False, 637) 638 639PARAM_QUERY_TARGET_ASSIGNMENT = APIParam( 640 'query_target_assignment', 641 'If supplied, only return records for this assignment.', 642 api_key = 'target-assignment', 643 cli_flag = 'target-assignment', 644 api_required = False, 645 cli_show_default = False, 646) 647 648PARAM_QUERY_TARGET_COURSE = APIParam( 649 'query_target_course', 650 'If supplied, only return records for this course.', 651 api_key = 'target-course', 652 cli_flag = 'target-course', 653 api_required = False, 654 cli_show_default = False, 655) 656 657PARAM_QUERY_TARGET_EMAIL = APIParam( 658 'query_target_email', 659 'If supplied, only return records for this user.', 660 api_key = 'target-email', 661 cli_flag = 'target-email', 662 api_required = False, 663 cli_show_default = False, 664) 665 666PARAM_QUERY_USE_TESTING_DATA = APIParam( 667 'query_use_testing_data', 668 'Query from hard-coded testing data (instead of real data).', 669 api_key = 'use-testing-data', 670 value_type = bool, 671 api_required = False, 672 cli = False, 673) 674 675PARAM_QUERY_WHERE = APIParam( 676 'query_where', 677 'Only includes records with a patching key/value pair.', 678 api_key = 'where', 679 cli_flag = 'where', 680 api_required = False, 681 value_type = dict, 682 cli_action = ArgumentMap, 683 cli_extra_options = { 684 'metavar': (f"KEY{MAP_KEY_VALUE_SEP}VALUE"), 685 'nargs': '+', 686 }, 687) 688 689PARAM_RAW_COURSE_USERS = APIParam( 690 'raw_course_users', 691 'Raw course users to operate on.', 692 value_type = list, 693 cli = False, 694 skip_clean = True, 695) 696 697PARAM_RAW_SERVER_USERS = APIParam( 698 'raw_server_users', 699 'Raw server users to operate on.', 700 value_type = list, 701 api_key = 'raw-users', 702 cli = False, 703 skip_clean = True, 704) 705 706PARAM_REGRADE_CUTOFF = APIParam( 707 'regrade_cutoff', 708 ('All submissions occurring before the cutoff time will be regraded.' 709 + ' By default, the current time will be used.' 710 + ' Time is milliseconds from the UNIX epoch' 711 + ' (https://en.wikipedia.org/wiki/Unix_time).'), 712 value_type = int, 713 api_required = False, 714) 715 716PARAM_SEND_EMAILS = APIParam( 717 'send_emails', 718 'Send any relevant emails to users affected by this operation (e.g., a user being enrolled in a course).', 719 value_type = bool, 720 cli_default_value = False, 721) 722 723PARAM_SERVER = APIParam( 724 'server', 725 'The URL of the autograder server to communicate with.', 726 cli_show_default = False, 727) 728 729PARAM_SKIP_BUILD_IMAGES = APIParam( 730 'skip_build_images', 731 'Skip building assignment Docker images.', 732 value_type = bool, 733 cli_default_value = False, 734) 735 736PARAM_SKIP_EMAILS = APIParam( 737 'skip_emails', 738 'Skip sending any emails.', 739 value_type = bool, 740 cli_default_value = False, 741) 742 743PARAM_SKIP_INSERTS = APIParam( 744 'skip_inserts', 745 'Skip insert operations.', 746 value_type = bool, 747 cli_default_value = False, 748) 749 750PARAM_SKIP_LMS_SYNC = APIParam( 751 'skip_lms_sync', 752 'Skip syncing with the LMS.', 753 value_type = bool, 754 cli_default_value = False, 755) 756 757PARAM_SKIP_SOURCE_SYNC = APIParam( 758 'skip_source_sync', 759 'Skip syncing (updating with) the course source.', 760 value_type = bool, 761 cli_default_value = False, 762) 763 764PARAM_SKIP_TEMPLATE_FILES = APIParam( 765 'skip_template_files', 766 'Skip fetching assignment template files.', 767 value_type = bool, 768 cli_default_value = False, 769) 770 771PARAM_SKIP_UPDATES = APIParam( 772 'skip_updates', 773 'Skip update operations.', 774 value_type = bool, 775 cli_default_value = False, 776) 777 778PARAM_SUBMISSION_MESSAGE = APIParam( 779 'message', 780 'An optional message to attach to the submission.', 781 api_required = False, 782) 783 784PARAM_SUBMISSION_FILES = APIParam( 785 'files', 786 'The path to your submission file(s).', 787 value_type = list, 788 api = False, 789 cli_add_func = _cli_add_func_submission_files, 790) 791 792PARAM_SUBMISSION_SPECS = APIParam( 793 'submission_specs', 794 ('A list of submission specifications to analyze.' 795 + ' Submissions may span courses and assignments.' 796 + ' Submissions may be specified in three ways:' 797 + ' 1) "<course id>::<assignment id>::<user email>::<submission short id> for a specific submission,' 798 + ' 2) "<course id>::<assignment id>::<user email> for the given user\'s most recent submission to the given assignment,' 799 + ' and 3) "<course id>::<assignment id> for the most recent submission for all students.'), 800 api_key = 'submissions', 801 cli_flag = 'submissions', 802 value_type = list, 803 cli_add_func = _cli_add_func_submission_specs, 804) 805 806PARAM_TARGET_EMAIL = APIParam( 807 'target_email', 808 'The email of the user that is the target of this request.', 809) 810 811PARAM_TARGET_EMAIL_OR_SELF = APIParam( 812 'target_email', 813 'The email of the user that is the target of this request (defaults to you).', 814 api_required = False, 815) 816 817PARAM_TARGET_USER_OR_SELF = APIParam( 818 'target_user', 819 'The user that is the target of this request (defaults to you).', 820 api_required = False, 821) 822 823PARAM_TARGET_USERS = APIParam( 824 'target_users', 825 ('A list of server user references.' 826 + ' Server user references may be specified in eight ways:' 827 + ' 1) Email address of the requested user,' 828 + ' 2) "*" to request all users in the server,' 829 + ' 3) "<server role>" (e.g., user, creator)' 830 + ' to request all users with that server role,' 831 + ' 4) "<course id>::<course role>" (e.g., course101::student)' 832 + ' to request all users in the target course with that course role,' 833 + ' 5) "*::<course role>" (e.g., *::student)' 834 + ' to request all users with that course role in any course,' 835 + ' 6) "<course id>::*" to request all users in the target course,' 836 + ' 7) "*::*" to request all users enrolled in at least one course,' 837 + ' and 8) any of the previous options preceded by a minus sign' 838 + ' (e.g., "-alice@test.edulinq.org", "-user", "-*::student")' 839 + ' to exclude that user or group from the request.' 840 + ' Default: All users in the server.'), 841 api_required = False, 842 value_type = list, 843 cli_action = 'extend', 844 cli_type = _csv_to_list, 845 cli_show_default = False, 846) 847 848PARAM_TARGET_SUBMISSION_OR_RECENT = APIParam( 849 'target_submission', 850 'The ID of the submission (default to the most recent submission).', 851 api_required = False, 852) 853 854PARAM_TOKEN_ID = APIParam( 855 'token_id', 856 'The id of the token to target.', 857 cli_required = True, 858) 859 860PARAM_UPSERT_FILESPEC = APIParam( 861 'filespec', 862 'A filespec pointing to a course to upload.', 863 value_type = autograder.filespec.FileSpec, 864 cli = False, 865) 866 867PARAM_UPSERT_ZIP = APIParam( 868 'path', 869 ('The path to your course material.' 870 + ' Either a zip file (with .zip extension) or directory (that will get zipped).'), 871 value_type = str, 872 api = False, 873 cli_add_func = _cli_add_func_upsert_zip, 874) 875 876PARAM_USER_EMAIL = APIParam( 877 'user', 878 'The email of the user making this request.', 879 alt_config_key = 'auth_user', 880 api_key = 'user-email', 881 cli_flag = 'user', 882 cli_show_default = False, 883) 884 885PARAM_USER_PASS = APIParam( 886 'pass', 887 'The password of the user making this request.', 888 alt_config_key = 'auth_pass', 889 api_key = 'user-pass', 890 cli_flag = 'pass', 891 hash_value = True, 892 cli_show_default = False, 893) 894 895PARAM_WAIT_FOR_COMPLETION = APIParam( 896 'wait_for_completion', 897 'Wait for the full job to complete before returning.', 898 value_type = bool, 899 cli_default_value = False, 900)
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.