Commit bfedb0e29e181a6dfece6a7cc0bd52fa9535da30
1 parent
4f960e53
Exists in
master
and in
1 other branch
initial support for submitting code to a JOBE server
Showing
7 changed files
with
188 additions
and
7 deletions
Show diff stats
demo/demo.yaml
demo/questions/questions-tutorial.yaml
... | ... | @@ -609,3 +609,49 @@ |
609 | 609 | generate-question | yamllint - |
610 | 610 | correct-answer | yamllint - |
611 | 611 | ``` |
612 | + | |
613 | +# ---------------------------------------------------------------------------- | |
614 | +- type: code | |
615 | + ref: tut-code | |
616 | + title: Submissão de código (JOBE) | |
617 | + text: | | |
618 | + É possível enviar código para ser compilado e executado por um servidor | |
619 | + JOBE instalado separadamente, ver [JOBE](https://github.com/trampgeek/jobe). | |
620 | + | |
621 | + ```yaml | |
622 | + - type: code | |
623 | + ref: tut-code | |
624 | + title: Submissão de código (JOBE) | |
625 | + text: | | |
626 | + Escreva um programa em C que recebe uma string no standard input e | |
627 | + mostra a mensagem `hello ` seguida da string. | |
628 | + Por exemplo, se o input for `Maria`, o output deverá ser `hello Maria`. | |
629 | + server: 127.0.0.1 # replace by appropriate address | |
630 | + language: c | |
631 | + correct: | |
632 | + - stdin: 'Maria' | |
633 | + stdout: 'hello Maria' | |
634 | + - stdin: 'xyz' | |
635 | + stdout: 'hello xyz' | |
636 | + ``` | |
637 | + | |
638 | + Existem várias linguagens suportadas pelo servidor JOBE (C, C++, Java, | |
639 | + Python2, Python3, Octave, Pascal, PHP). | |
640 | + O campo `correct` deverá ser uma lista de casos a testar. | |
641 | + Se um caso incluir `stdin`, este será enviado para o programa e o `stdout` | |
642 | + obtido será comparado com o declarado. A pergunta é considerada correcta se | |
643 | + todos os outputs coincidirem. | |
644 | + answer: | | |
645 | + #include <stdio.h> | |
646 | + int main() { | |
647 | + char name[20]; | |
648 | + scanf("%s", name); | |
649 | + printf("hello %s", name); | |
650 | + } | |
651 | + server: 192.168.1.141 | |
652 | + language: c | |
653 | + correct: | |
654 | + - stdin: 'Maria' | |
655 | + stdout: 'hello Maria' | |
656 | + - stdin: 'xyz' | |
657 | + stdout: 'hello xyz' | ... | ... |
perguntations/__init__.py
perguntations/app.py
... | ... | @@ -190,7 +190,7 @@ class App(): |
190 | 190 | raise AppException('Failed to create test factory!') from exc |
191 | 191 | |
192 | 192 | # ------------------------------------------------------------------------ |
193 | - def _pregenerate_tests(self, num): | |
193 | + def _pregenerate_tests(self, num): # TODO needs improvement | |
194 | 194 | event_loop = asyncio.get_event_loop() |
195 | 195 | self.pregenerated_tests += [ |
196 | 196 | event_loop.run_until_complete(self.testfactory.generate()) | ... | ... |
perguntations/questions.py
... | ... | @@ -13,6 +13,12 @@ import re |
13 | 13 | from typing import Any, Dict, NewType |
14 | 14 | import uuid |
15 | 15 | |
16 | + | |
17 | +from urllib.error import HTTPError | |
18 | +import json | |
19 | +import http.client | |
20 | + | |
21 | + | |
16 | 22 | # this project |
17 | 23 | from perguntations.tools import run_script, run_script_async |
18 | 24 | |
... | ... | @@ -584,6 +590,130 @@ class QuestionTextArea(Question): |
584 | 590 | |
585 | 591 | |
586 | 592 | # ============================================================================ |
593 | +class QuestionCode(Question): | |
594 | + '''An instance of QuestionCode will always have the keys: | |
595 | + type (str) | |
596 | + text (str) | |
597 | + correct (str with script to run) | |
598 | + answer (None or an actual answer) | |
599 | + ''' | |
600 | + | |
601 | + _outcomes = { | |
602 | + 0: 'JOBE outcome: Successful run', | |
603 | + 11: 'JOBE outcome: Compile error', | |
604 | + 12: 'JOBE outcome: Runtime error', | |
605 | + 13: 'JOBE outcome: Time limit exceeded', | |
606 | + 15: 'JOBE outcome: Successful run', | |
607 | + 17: 'JOBE outcome: Memory limit exceeded', | |
608 | + 19: 'JOBE outcome: Illegal system call', | |
609 | + 20: 'JOBE outcome: Internal error, please report', | |
610 | + 21: 'JOBE outcome: Server overload', | |
611 | + } | |
612 | + | |
613 | + # ------------------------------------------------------------------------ | |
614 | + def __init__(self, q: QDict) -> None: | |
615 | + super().__init__(q) | |
616 | + | |
617 | + self.set_defaults(QDict({ | |
618 | + 'text': '', | |
619 | + 'timeout': 5, # seconds | |
620 | + 'server': '127.0.0.1', # JOBE server | |
621 | + 'language': 'c', | |
622 | + 'correct': [{'stdin': '', 'stdout': '', 'stderr': '', 'args': ''}], | |
623 | + })) | |
624 | + | |
625 | + # ------------------------------------------------------------------------ | |
626 | + def correct(self) -> None: | |
627 | + super().correct() | |
628 | + | |
629 | + if self['answer'] is None: | |
630 | + return | |
631 | + | |
632 | + # submit answer to JOBE server | |
633 | + resource = '/jobe/index.php/restapi/runs/' | |
634 | + headers = {"Content-type": "application/json; charset=utf-8", | |
635 | + "Accept": "application/json"} | |
636 | + | |
637 | + for expected in self['correct']: | |
638 | + data_json = json.dumps({ | |
639 | + 'run_spec' : { | |
640 | + 'language_id': self['language'], | |
641 | + 'sourcecode': self['answer'], | |
642 | + 'input': expected.get('stdin', ''), | |
643 | + }, | |
644 | + }) | |
645 | + | |
646 | + try: | |
647 | + connect = http.client.HTTPConnection(self['server']) | |
648 | + connect.request( | |
649 | + method='POST', | |
650 | + url=resource, | |
651 | + body=data_json, | |
652 | + headers=headers | |
653 | + ) | |
654 | + response = connect.getresponse() | |
655 | + logger.debug('JOBE response status %d', response.status) | |
656 | + if response.status != 204: | |
657 | + content = response.read().decode('utf8') | |
658 | + if content: | |
659 | + result = json.loads(content) | |
660 | + connect.close() | |
661 | + | |
662 | + except (HTTPError, ValueError): | |
663 | + logger.error('HTTPError while connecting to JOBE server') | |
664 | + | |
665 | + try: | |
666 | + outcome = result['outcome'] | |
667 | + except (NameError, TypeError, KeyError): | |
668 | + logger.error('Bad result returned from JOBE server: %s', result) | |
669 | + return | |
670 | + logger.debug(self._outcomes[outcome]) | |
671 | + | |
672 | + if result['cmpinfo']: # compiler errors and warnings | |
673 | + self['comments'] = f'Erros de compilação:\n{result["cmpinfo"]}' | |
674 | + self['grade'] = 0.0 | |
675 | + return | |
676 | + | |
677 | + if result['stdout'] != expected['stdout']: | |
678 | + self['comments'] = 'O output gerado é diferente do esperado.' | |
679 | + self['grade'] = 0.0 | |
680 | + return | |
681 | + | |
682 | + self['comments'] = 'Ok!' | |
683 | + self['grade'] = 1.0 | |
684 | + | |
685 | + | |
686 | + # ------------------------------------------------------------------------ | |
687 | + async def correct_async(self) -> None: | |
688 | + self.correct() | |
689 | + | |
690 | + | |
691 | + # out = run_script( | |
692 | + # script=self['correct'], | |
693 | + # args=self['args'], | |
694 | + # stdin=self['answer'], | |
695 | + # timeout=self['timeout'] | |
696 | + # ) | |
697 | + | |
698 | + # if out is None: | |
699 | + # logger.warning('No grade after running "%s".', self["correct"]) | |
700 | + # self['comments'] = 'O programa de correcção abortou...' | |
701 | + # self['grade'] = 0.0 | |
702 | + # elif isinstance(out, dict): | |
703 | + # self['comments'] = out.get('comments', '') | |
704 | + # try: | |
705 | + # self['grade'] = float(out['grade']) | |
706 | + # except ValueError: | |
707 | + # logger.error('Output error in "%s".', self["correct"]) | |
708 | + # except KeyError: | |
709 | + # logger.error('No grade in "%s".', self["correct"]) | |
710 | + # else: | |
711 | + # try: | |
712 | + # self['grade'] = float(out) | |
713 | + # except (TypeError, ValueError): | |
714 | + # logger.error('Invalid grade in "%s".', self["correct"]) | |
715 | + | |
716 | +# ============================================================================ | |
587 | 717 | class QuestionInformation(Question): |
588 | 718 | ''' |
589 | 719 | Not really a question, just an information panel. |
... | ... | @@ -645,6 +775,7 @@ class QFactory(): |
645 | 775 | 'text-regex': QuestionTextRegex, |
646 | 776 | 'numeric-interval': QuestionNumericInterval, |
647 | 777 | 'textarea': QuestionTextArea, |
778 | + 'code': QuestionCode, | |
648 | 779 | # -- informative panels -- |
649 | 780 | 'information': QuestionInformation, |
650 | 781 | 'success': QuestionInformation, | ... | ... |
perguntations/serve.py
... | ... | @@ -227,6 +227,7 @@ class RootHandler(BaseHandler): |
227 | 227 | 'text-regex': 'question-text.html', |
228 | 228 | 'numeric-interval': 'question-text.html', |
229 | 229 | 'textarea': 'question-textarea.html', |
230 | + 'code': 'question-textarea.html', | |
230 | 231 | # -- information panels -- |
231 | 232 | 'information': 'question-information.html', |
232 | 233 | 'success': 'question-information.html', |
... | ... | @@ -289,7 +290,7 @@ class RootHandler(BaseHandler): |
289 | 290 | else: |
290 | 291 | ans[i] = ans[i][0] |
291 | 292 | elif question['type'] in ('text', 'text-regex', 'textarea', |
292 | - 'numeric-interval'): | |
293 | + 'numeric-interval', 'code'): | |
293 | 294 | ans[i] = ans[i][0] |
294 | 295 | |
295 | 296 | # correct answered questions and logout |
... | ... | @@ -477,6 +478,7 @@ class ReviewHandler(BaseHandler): |
477 | 478 | 'text-regex': 'review-question-text.html', |
478 | 479 | 'numeric-interval': 'review-question-text.html', |
479 | 480 | 'textarea': 'review-question-text.html', |
481 | + 'code': 'review-question-text.html', | |
480 | 482 | # -- information panels -- |
481 | 483 | 'information': 'review-question-information.html', |
482 | 484 | 'success': 'review-question-information.html', | ... | ... |
perguntations/templates/review-question.html
... | ... | @@ -47,7 +47,7 @@ |
47 | 47 | pontos |
48 | 48 | </p> |
49 | 49 | <p class="text-warning">{{ q['comments'] }}</p> |
50 | - {% if q.get('solution', '') %} | |
50 | + {% if q['solution'] %} | |
51 | 51 | <hr> |
52 | 52 | {{ md('**Solução:** \n\n' + q['solution']) }} |
53 | 53 | {% end %} |
... | ... | @@ -57,8 +57,8 @@ |
57 | 57 | {{ round(q['grade'] * q['points'], 2) }} |
58 | 58 | pontos |
59 | 59 | </p> |
60 | - <p class="text-danger">{{ q['comments'] }}</p> | |
61 | - {% if q.get('solution', '') %} | |
60 | + <p class="text-danger"><pre>{{ q['comments'] }}</pre></p> | |
61 | + {% if q['solution'] %} | |
62 | 62 | <hr> |
63 | 63 | {{ md('**Solução:** \n\n' + q['solution']) }} |
64 | 64 | {% end %} |
... | ... | @@ -102,7 +102,7 @@ |
102 | 102 | <i class="fas fa-ban fa-3x" aria-hidden="true"></i> |
103 | 103 | {{ round(q['grade'] * q['points'], 2) }} pontos<br> |
104 | 104 | {{ q['comments'] }} |
105 | - {% if q.get('solution', '') %} | |
105 | + {% if q['solution'] %} | |
106 | 106 | <hr> |
107 | 107 | {{ md('**Solução:** \n\n' + q['solution']) }} |
108 | 108 | {% end %} | ... | ... |