autograder.api.common
1import os 2import sys 3import typing 4 5import edq.core.errors 6import edq.net.request 7import edq.util.json 8import edq.util.time 9import requests 10 11import autograder 12import autograder.api.config 13import autograder.api.constants 14import autograder.error 15import autograder.model.config 16 17DEFAULT_SOURCE_NAME: str = 'edq-autograder-py' 18DEFAULT_SOURCE_VERSION: str = autograder.__version__ 19 20TESTING_SOURCE_NAME: str = 'testing' 21TESTING_SOURCE_VERSION: str = '0.0.0' 22 23_source_name: str = DEFAULT_SOURCE_NAME # pylint: disable=invalid-name 24_source_version: str = DEFAULT_SOURCE_VERSION # pylint: disable=invalid-name 25 26def set_testing_source_info() -> None: 27 """ Set source info for API requests to consistent values for testing. """ 28 29 global _source_name # pylint: disable=global-statement 30 global _source_version # pylint: disable=global-statement 31 32 _source_name = TESTING_SOURCE_NAME 33 _source_version = TESTING_SOURCE_VERSION 34 35def make_api_request( 36 endpoint: str, 37 config: autograder.model.config.Config, 38 api_params: typing.List[autograder.api.config.APIParam], 39 write: typing.Union[bool, None] = None, 40 exit_on_error: bool = False, 41 post_paths: typing.Union[typing.List[str], None] = None, 42 ) -> typing.Dict[str, typing.Any]: 43 """ 44 Given arguments (usually from argparse), API params, and an endpoint, 45 make an API request. 46 Will raise an error on any error or error response. 47 48 Requests that MAY write data to the server should pass `write` as true. 49 When set, a header (autograder.api.constants.HEADER_KEY_WRITE) will be sent along with the API request. 50 This should only affect testing, and will allow server runners to restart when write operations are sent. 51 """ 52 53 try: 54 return _make_api_request(endpoint, config, api_params, post_paths = post_paths, write = write) 55 except autograder.error.AutograderError as ex: 56 if (exit_on_error): 57 print("ERROR: " + ex.args[0], file = sys.stderr) 58 autograder.error.exit_from_error(1) 59 60 raise ex 61 62def _make_api_request( 63 endpoint: str, 64 config: autograder.model.config.Config, 65 api_params: typing.List[autograder.api.config.APIParam], 66 write: typing.Union[bool, None] = None, 67 post_paths: typing.Union[typing.List[str], None] = None, 68 ) -> typing.Dict[str, typing.Any]: 69 """ Wrapped function for make_api_request(). """ 70 71 payload = _verify_payload(config, api_params) 72 73 return send_api_request(endpoint, payload, post_paths = post_paths, write = write) 74 75def _verify_payload( 76 config: autograder.model.config.Config, 77 api_params: typing.List[autograder.api.config.APIParam], 78 ) -> typing.Dict[str, typing.Any]: 79 """ 80 Verify that the given payload matches the listed API parameters, 81 and return a new copy of the payload with only the specified keys. 82 """ 83 84 payload = {} 85 86 for api_param in api_params: 87 if (not api_param.api): 88 continue 89 90 value = api_param.clean_value(getattr(config, api_param.config_key, None)) 91 92 # If there is no value, try an alt key. 93 if ((value is None) and (api_param.alt_config_key is not None)): 94 value = api_param.clean_value(getattr(config, api_param.alt_config_key, None)) 95 96 if (value is None): 97 if (api_param.api_required): 98 names = [f"'{name}'" for name in [api_param.config_key, api_param.alt_config_key, api_param.cli_flag] if (name is not None)] 99 names = sorted(set(names)) 100 101 raise autograder.error.APIError(None, 102 f"Required parameter {' / '.join(names)} not found.") 103 104 if (api_param.omit_empty): 105 continue 106 107 payload[api_param.api_key] = value 108 109 return payload 110 111def send_api_request( 112 endpoint: str, 113 payload: typing.Dict[str, typing.Any], 114 write: typing.Union[bool, None] = None, 115 post_paths: typing.Union[typing.List[str], None] = None, 116 ) -> typing.Dict[str, typing.Any]: 117 """ 118 Make an autograder API request. 119 On a failure (including non 200), an error will be raised with error information. 120 Otherwise (including a successful response with a failed operation (soft error)), 121 the content of the response will be returned (converted from JSON to a dict). 122 """ 123 124 if (post_paths is None): 125 post_paths = [] 126 127 server = payload.pop('server', None) 128 if (server is None): 129 raise autograder.error.APIError(None, "No server provided.") 130 131 server = server.rstrip('/') 132 endpoint = endpoint.lstrip('/') 133 134 url = f"{server}/api/{autograder.api.constants.API_VERSION}/{endpoint}" 135 136 # Add source information. 137 payload['source'] = _source_name 138 payload['source-version'] = _source_version 139 140 headers = {} 141 if (write is not None): 142 headers[autograder.api.constants.HEADER_KEY_WRITE] = str(write).lower() 143 144 post_files = {} 145 for path in post_paths: 146 filename = os.path.basename(path) 147 if (filename in post_files): 148 raise autograder.error.APIError(None, f"Cannot submit duplicate filenames ('{filename}').") 149 150 if (not os.path.isfile(path)): 151 raise autograder.error.APIError(None, f"Path does not exist, or is not a file: '{path}'.") 152 153 post_files[filename] = open(path, 'rb') # pylint: disable=consider-using-with 154 155 try: 156 raw_response, raw_body = edq.net.request.make_post( 157 url, 158 data = {autograder.api.constants.API_REQUEST_JSON_KEY: edq.util.json.dumps(payload)}, 159 headers = headers, 160 files = post_files, 161 raise_for_status = False) 162 except edq.core.errors.RetryError as ex: 163 # If this is not a connection error, then re-raise. 164 if (not ex.contains_instance(requests.exceptions.ConnectionError)): 165 raise ex 166 167 raise autograder.error.ConnectionError((f"Could not connect to autograder server '{server}'." # pylint: disable=raise-missing-from 168 + " This is a networking issue (e.g., network down, server down, wrong server address), not an authentication issue.")) 169 170 for file in post_files.values(): 171 file.close() 172 173 try: 174 response_body = edq.util.json.loads(raw_body, strict = True) 175 except Exception as ex: 176 message = ("Autograder response does not contain valid JSON." 177 + " Contact a server admin with the following." 178 + f" Response:\n---\n{raw_response.text}\n---") 179 180 raise autograder.error.APIError(None, message) from ex 181 182 if (not response_body.get(autograder.api.constants.API_RESPONSE_KEY_SUCCESS, False)): 183 message = 'Request to the autograder failed.' 184 if (autograder.api.constants.API_RESPONSE_KEY_MESSAGE in response_body): 185 message = f"Failed to complete operation: {response_body[autograder.api.constants.API_RESPONSE_KEY_MESSAGE]}" 186 187 # Replace any timestamps in the message. 188 message = edq.util.time.Timestamp.convert_embedded(message, pretty = True) 189 190 code = response_body.get(autograder.api.constants.API_RESPONSE_KEY_STATUS, None) 191 raise autograder.error.APIError(code, message) 192 193 content = response_body[autograder.api.constants.API_RESPONSE_KEY_CONTENT] 194 return typing.cast(typing.Dict[str, typing.Any], content)
27def set_testing_source_info() -> None: 28 """ Set source info for API requests to consistent values for testing. """ 29 30 global _source_name # pylint: disable=global-statement 31 global _source_version # pylint: disable=global-statement 32 33 _source_name = TESTING_SOURCE_NAME 34 _source_version = TESTING_SOURCE_VERSION
Set source info for API requests to consistent values for testing.
36def make_api_request( 37 endpoint: str, 38 config: autograder.model.config.Config, 39 api_params: typing.List[autograder.api.config.APIParam], 40 write: typing.Union[bool, None] = None, 41 exit_on_error: bool = False, 42 post_paths: typing.Union[typing.List[str], None] = None, 43 ) -> typing.Dict[str, typing.Any]: 44 """ 45 Given arguments (usually from argparse), API params, and an endpoint, 46 make an API request. 47 Will raise an error on any error or error response. 48 49 Requests that MAY write data to the server should pass `write` as true. 50 When set, a header (autograder.api.constants.HEADER_KEY_WRITE) will be sent along with the API request. 51 This should only affect testing, and will allow server runners to restart when write operations are sent. 52 """ 53 54 try: 55 return _make_api_request(endpoint, config, api_params, post_paths = post_paths, write = write) 56 except autograder.error.AutograderError as ex: 57 if (exit_on_error): 58 print("ERROR: " + ex.args[0], file = sys.stderr) 59 autograder.error.exit_from_error(1) 60 61 raise ex
Given arguments (usually from argparse), API params, and an endpoint, make an API request. Will raise an error on any error or error response.
Requests that MAY write data to the server should pass write as true.
When set, a header (autograder.api.constants.HEADER_KEY_WRITE) will be sent along with the API request.
This should only affect testing, and will allow server runners to restart when write operations are sent.
112def send_api_request( 113 endpoint: str, 114 payload: typing.Dict[str, typing.Any], 115 write: typing.Union[bool, None] = None, 116 post_paths: typing.Union[typing.List[str], None] = None, 117 ) -> typing.Dict[str, typing.Any]: 118 """ 119 Make an autograder API request. 120 On a failure (including non 200), an error will be raised with error information. 121 Otherwise (including a successful response with a failed operation (soft error)), 122 the content of the response will be returned (converted from JSON to a dict). 123 """ 124 125 if (post_paths is None): 126 post_paths = [] 127 128 server = payload.pop('server', None) 129 if (server is None): 130 raise autograder.error.APIError(None, "No server provided.") 131 132 server = server.rstrip('/') 133 endpoint = endpoint.lstrip('/') 134 135 url = f"{server}/api/{autograder.api.constants.API_VERSION}/{endpoint}" 136 137 # Add source information. 138 payload['source'] = _source_name 139 payload['source-version'] = _source_version 140 141 headers = {} 142 if (write is not None): 143 headers[autograder.api.constants.HEADER_KEY_WRITE] = str(write).lower() 144 145 post_files = {} 146 for path in post_paths: 147 filename = os.path.basename(path) 148 if (filename in post_files): 149 raise autograder.error.APIError(None, f"Cannot submit duplicate filenames ('{filename}').") 150 151 if (not os.path.isfile(path)): 152 raise autograder.error.APIError(None, f"Path does not exist, or is not a file: '{path}'.") 153 154 post_files[filename] = open(path, 'rb') # pylint: disable=consider-using-with 155 156 try: 157 raw_response, raw_body = edq.net.request.make_post( 158 url, 159 data = {autograder.api.constants.API_REQUEST_JSON_KEY: edq.util.json.dumps(payload)}, 160 headers = headers, 161 files = post_files, 162 raise_for_status = False) 163 except edq.core.errors.RetryError as ex: 164 # If this is not a connection error, then re-raise. 165 if (not ex.contains_instance(requests.exceptions.ConnectionError)): 166 raise ex 167 168 raise autograder.error.ConnectionError((f"Could not connect to autograder server '{server}'." # pylint: disable=raise-missing-from 169 + " This is a networking issue (e.g., network down, server down, wrong server address), not an authentication issue.")) 170 171 for file in post_files.values(): 172 file.close() 173 174 try: 175 response_body = edq.util.json.loads(raw_body, strict = True) 176 except Exception as ex: 177 message = ("Autograder response does not contain valid JSON." 178 + " Contact a server admin with the following." 179 + f" Response:\n---\n{raw_response.text}\n---") 180 181 raise autograder.error.APIError(None, message) from ex 182 183 if (not response_body.get(autograder.api.constants.API_RESPONSE_KEY_SUCCESS, False)): 184 message = 'Request to the autograder failed.' 185 if (autograder.api.constants.API_RESPONSE_KEY_MESSAGE in response_body): 186 message = f"Failed to complete operation: {response_body[autograder.api.constants.API_RESPONSE_KEY_MESSAGE]}" 187 188 # Replace any timestamps in the message. 189 message = edq.util.time.Timestamp.convert_embedded(message, pretty = True) 190 191 code = response_body.get(autograder.api.constants.API_RESPONSE_KEY_STATUS, None) 192 raise autograder.error.APIError(code, message) 193 194 content = response_body[autograder.api.constants.API_RESPONSE_KEY_CONTENT] 195 return typing.cast(typing.Dict[str, typing.Any], content)
Make an autograder API request. On a failure (including non 200), an error will be raised with error information. Otherwise (including a successful response with a failed operation (soft error)), the content of the response will be returned (converted from JSON to a dict).