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,3 +609,49 @@ | ||
609 | generate-question | yamllint - | 609 | generate-question | yamllint - |
610 | correct-answer | yamllint - | 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
@@ -32,7 +32,7 @@ proof of submission and for review. | @@ -32,7 +32,7 @@ proof of submission and for review. | ||
32 | ''' | 32 | ''' |
33 | 33 | ||
34 | APP_NAME = 'perguntations' | 34 | APP_NAME = 'perguntations' |
35 | -APP_VERSION = '2020.11.dev2' | 35 | +APP_VERSION = '2020.11.dev3' |
36 | APP_DESCRIPTION = __doc__ | 36 | APP_DESCRIPTION = __doc__ |
37 | 37 | ||
38 | __author__ = 'Miguel Barão' | 38 | __author__ = 'Miguel Barão' |
perguntations/app.py
@@ -190,7 +190,7 @@ class App(): | @@ -190,7 +190,7 @@ class App(): | ||
190 | raise AppException('Failed to create test factory!') from exc | 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 | event_loop = asyncio.get_event_loop() | 194 | event_loop = asyncio.get_event_loop() |
195 | self.pregenerated_tests += [ | 195 | self.pregenerated_tests += [ |
196 | event_loop.run_until_complete(self.testfactory.generate()) | 196 | event_loop.run_until_complete(self.testfactory.generate()) |
perguntations/questions.py
@@ -13,6 +13,12 @@ import re | @@ -13,6 +13,12 @@ import re | ||
13 | from typing import Any, Dict, NewType | 13 | from typing import Any, Dict, NewType |
14 | import uuid | 14 | import uuid |
15 | 15 | ||
16 | + | ||
17 | +from urllib.error import HTTPError | ||
18 | +import json | ||
19 | +import http.client | ||
20 | + | ||
21 | + | ||
16 | # this project | 22 | # this project |
17 | from perguntations.tools import run_script, run_script_async | 23 | from perguntations.tools import run_script, run_script_async |
18 | 24 | ||
@@ -584,6 +590,130 @@ class QuestionTextArea(Question): | @@ -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 | class QuestionInformation(Question): | 717 | class QuestionInformation(Question): |
588 | ''' | 718 | ''' |
589 | Not really a question, just an information panel. | 719 | Not really a question, just an information panel. |
@@ -645,6 +775,7 @@ class QFactory(): | @@ -645,6 +775,7 @@ class QFactory(): | ||
645 | 'text-regex': QuestionTextRegex, | 775 | 'text-regex': QuestionTextRegex, |
646 | 'numeric-interval': QuestionNumericInterval, | 776 | 'numeric-interval': QuestionNumericInterval, |
647 | 'textarea': QuestionTextArea, | 777 | 'textarea': QuestionTextArea, |
778 | + 'code': QuestionCode, | ||
648 | # -- informative panels -- | 779 | # -- informative panels -- |
649 | 'information': QuestionInformation, | 780 | 'information': QuestionInformation, |
650 | 'success': QuestionInformation, | 781 | 'success': QuestionInformation, |
perguntations/serve.py
@@ -227,6 +227,7 @@ class RootHandler(BaseHandler): | @@ -227,6 +227,7 @@ class RootHandler(BaseHandler): | ||
227 | 'text-regex': 'question-text.html', | 227 | 'text-regex': 'question-text.html', |
228 | 'numeric-interval': 'question-text.html', | 228 | 'numeric-interval': 'question-text.html', |
229 | 'textarea': 'question-textarea.html', | 229 | 'textarea': 'question-textarea.html', |
230 | + 'code': 'question-textarea.html', | ||
230 | # -- information panels -- | 231 | # -- information panels -- |
231 | 'information': 'question-information.html', | 232 | 'information': 'question-information.html', |
232 | 'success': 'question-information.html', | 233 | 'success': 'question-information.html', |
@@ -289,7 +290,7 @@ class RootHandler(BaseHandler): | @@ -289,7 +290,7 @@ class RootHandler(BaseHandler): | ||
289 | else: | 290 | else: |
290 | ans[i] = ans[i][0] | 291 | ans[i] = ans[i][0] |
291 | elif question['type'] in ('text', 'text-regex', 'textarea', | 292 | elif question['type'] in ('text', 'text-regex', 'textarea', |
292 | - 'numeric-interval'): | 293 | + 'numeric-interval', 'code'): |
293 | ans[i] = ans[i][0] | 294 | ans[i] = ans[i][0] |
294 | 295 | ||
295 | # correct answered questions and logout | 296 | # correct answered questions and logout |
@@ -477,6 +478,7 @@ class ReviewHandler(BaseHandler): | @@ -477,6 +478,7 @@ class ReviewHandler(BaseHandler): | ||
477 | 'text-regex': 'review-question-text.html', | 478 | 'text-regex': 'review-question-text.html', |
478 | 'numeric-interval': 'review-question-text.html', | 479 | 'numeric-interval': 'review-question-text.html', |
479 | 'textarea': 'review-question-text.html', | 480 | 'textarea': 'review-question-text.html', |
481 | + 'code': 'review-question-text.html', | ||
480 | # -- information panels -- | 482 | # -- information panels -- |
481 | 'information': 'review-question-information.html', | 483 | 'information': 'review-question-information.html', |
482 | 'success': 'review-question-information.html', | 484 | 'success': 'review-question-information.html', |
perguntations/templates/review-question.html
@@ -47,7 +47,7 @@ | @@ -47,7 +47,7 @@ | ||
47 | pontos | 47 | pontos |
48 | </p> | 48 | </p> |
49 | <p class="text-warning">{{ q['comments'] }}</p> | 49 | <p class="text-warning">{{ q['comments'] }}</p> |
50 | - {% if q.get('solution', '') %} | 50 | + {% if q['solution'] %} |
51 | <hr> | 51 | <hr> |
52 | {{ md('**Solução:** \n\n' + q['solution']) }} | 52 | {{ md('**Solução:** \n\n' + q['solution']) }} |
53 | {% end %} | 53 | {% end %} |
@@ -57,8 +57,8 @@ | @@ -57,8 +57,8 @@ | ||
57 | {{ round(q['grade'] * q['points'], 2) }} | 57 | {{ round(q['grade'] * q['points'], 2) }} |
58 | pontos | 58 | pontos |
59 | </p> | 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 | <hr> | 62 | <hr> |
63 | {{ md('**Solução:** \n\n' + q['solution']) }} | 63 | {{ md('**Solução:** \n\n' + q['solution']) }} |
64 | {% end %} | 64 | {% end %} |
@@ -102,7 +102,7 @@ | @@ -102,7 +102,7 @@ | ||
102 | <i class="fas fa-ban fa-3x" aria-hidden="true"></i> | 102 | <i class="fas fa-ban fa-3x" aria-hidden="true"></i> |
103 | {{ round(q['grade'] * q['points'], 2) }} pontos<br> | 103 | {{ round(q['grade'] * q['points'], 2) }} pontos<br> |
104 | {{ q['comments'] }} | 104 | {{ q['comments'] }} |
105 | - {% if q.get('solution', '') %} | 105 | + {% if q['solution'] %} |
106 | <hr> | 106 | <hr> |
107 | {{ md('**Solução:** \n\n' + q['solution']) }} | 107 | {{ md('**Solução:** \n\n' + q['solution']) }} |
108 | {% end %} | 108 | {% end %} |