quizzy 0.3.1__py3-none-any.whl → 0.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- quizzy/__main__.py +2 -8
- quizzy/app.py +71 -14
- quizzy/models.py +56 -12
- {quizzy-0.3.1.dist-info → quizzy-0.5.0.dist-info}/METADATA +3 -2
- quizzy-0.5.0.dist-info/RECORD +10 -0
- quizzy-0.3.1.dist-info/RECORD +0 -10
- {quizzy-0.3.1.dist-info → quizzy-0.5.0.dist-info}/WHEEL +0 -0
- {quizzy-0.3.1.dist-info → quizzy-0.5.0.dist-info}/entry_points.txt +0 -0
- {quizzy-0.3.1.dist-info → quizzy-0.5.0.dist-info}/licenses/LICENSE +0 -0
quizzy/__main__.py
CHANGED
quizzy/app.py
CHANGED
@@ -2,22 +2,18 @@ from __future__ import annotations
|
|
2
2
|
|
3
3
|
import argparse
|
4
4
|
import pathlib
|
5
|
+
import shlex
|
6
|
+
import sys
|
7
|
+
from typing import NoReturn
|
5
8
|
|
6
9
|
from textual import app, binding, containers, log, message, reactive, screen, widgets
|
10
|
+
from textual_serve import server
|
7
11
|
|
8
12
|
from quizzy import __version__, models
|
9
13
|
|
10
14
|
NoCorrectAnswerType = type("NoCorrectAnswerType", (object,), {})
|
11
15
|
NoCorrectAnswer = NoCorrectAnswerType()
|
12
16
|
|
13
|
-
|
14
|
-
def get_arg_parser() -> argparse.ArgumentParser:
|
15
|
-
parser = argparse.ArgumentParser(prog=__name__.split(".")[0], description="A terminal quiz app")
|
16
|
-
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
17
|
-
parser.add_argument("quizfile", type=pathlib.Path, help="Quiz file")
|
18
|
-
return parser
|
19
|
-
|
20
|
-
|
21
17
|
QuestionScreenResult = models.Team | NoCorrectAnswerType | None
|
22
18
|
|
23
19
|
|
@@ -193,7 +189,7 @@ class QuestionButton(widgets.Button):
|
|
193
189
|
def wait_for_result(team: QuestionScreenResult) -> None:
|
194
190
|
if team is None:
|
195
191
|
return
|
196
|
-
|
192
|
+
|
197
193
|
self.disabled = True
|
198
194
|
self.question.answered = True
|
199
195
|
if isinstance(team, NoCorrectAnswerType):
|
@@ -230,12 +226,9 @@ class QuestionBoard(containers.HorizontalGroup):
|
|
230
226
|
class QuizzyApp(app.App[None]):
|
231
227
|
CSS_PATH = "quizzy.tcss"
|
232
228
|
|
233
|
-
def __init__(self) -> None:
|
229
|
+
def __init__(self, config: models.Config) -> None:
|
234
230
|
super().__init__()
|
235
|
-
|
236
|
-
namespace = parser.parse_args()
|
237
|
-
|
238
|
-
self.config = models.load_config(namespace.quizfile)
|
231
|
+
self.config = config
|
239
232
|
self.scoreboard_widget = Scoreboard(self.config.teams)
|
240
233
|
|
241
234
|
def compose(self) -> app.ComposeResult:
|
@@ -248,3 +241,67 @@ class QuizzyApp(app.App[None]):
|
|
248
241
|
|
249
242
|
def on_mount(self) -> None:
|
250
243
|
self.theme = "textual-light"
|
244
|
+
|
245
|
+
|
246
|
+
def get_arg_parser() -> argparse.ArgumentParser:
|
247
|
+
parser = argparse.ArgumentParser(
|
248
|
+
prog=__name__.split(".")[0],
|
249
|
+
description="A quiz app to run in the terminal or the browser",
|
250
|
+
epilog=(
|
251
|
+
"When an unfinished quiz is closed, the state is saved to a timestamped quiz file in the current working "
|
252
|
+
"directory."
|
253
|
+
),
|
254
|
+
)
|
255
|
+
|
256
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
257
|
+
|
258
|
+
serve_group = parser.add_argument_group("Serve options")
|
259
|
+
serve_group.add_argument("--serve", action="store_true", help="Serve the app through the browser")
|
260
|
+
serve_group.add_argument("--host", default="localhost", help="Host to serve the app on")
|
261
|
+
serve_group.add_argument("--port", type=int, default=8000, help="Port to serve the app on")
|
262
|
+
|
263
|
+
parser.add_argument("quizfile", type=pathlib.Path, help="Quiz file")
|
264
|
+
return parser
|
265
|
+
|
266
|
+
|
267
|
+
def serve_app(host: str, port: int) -> None:
|
268
|
+
"""Serve the app through textual-serve.
|
269
|
+
|
270
|
+
:param host: Host to serve the app on
|
271
|
+
:param port: Port to serve the app on
|
272
|
+
"""
|
273
|
+
# The --serve flag is set, drop it from the args and serve the app instead through textual-serve
|
274
|
+
args = list(sys.argv)
|
275
|
+
args.remove("--serve")
|
276
|
+
|
277
|
+
server.Server(shlex.join(args), host=host, port=port).serve()
|
278
|
+
|
279
|
+
|
280
|
+
def run_app(
|
281
|
+
quiz_file: pathlib.Path,
|
282
|
+
) -> None:
|
283
|
+
"""Run the app in the terminal.
|
284
|
+
|
285
|
+
:param quizfile: File to use for this quiz
|
286
|
+
"""
|
287
|
+
try:
|
288
|
+
config = models.load_config(quiz_file)
|
289
|
+
except Exception as e:
|
290
|
+
print(f"Error loading quiz file: {e}", file=sys.stderr)
|
291
|
+
sys.exit(1)
|
292
|
+
app = QuizzyApp(config)
|
293
|
+
try:
|
294
|
+
app.run()
|
295
|
+
finally:
|
296
|
+
models.dump_config_if_not_finished(config)
|
297
|
+
|
298
|
+
|
299
|
+
def main() -> NoReturn:
|
300
|
+
parser = get_arg_parser()
|
301
|
+
namespace = parser.parse_args()
|
302
|
+
|
303
|
+
if namespace.serve:
|
304
|
+
serve_app(namespace.host, namespace.port)
|
305
|
+
else:
|
306
|
+
run_app(namespace.quizfile)
|
307
|
+
sys.exit(0)
|
quizzy/models.py
CHANGED
@@ -1,8 +1,11 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
|
+
import datetime
|
4
|
+
import json
|
3
5
|
import pathlib
|
4
|
-
from typing import Self
|
6
|
+
from typing import Annotated, Any, Self
|
5
7
|
|
8
|
+
import pydantic
|
6
9
|
import yaml
|
7
10
|
from pydantic import Field, model_validator
|
8
11
|
from pydantic.dataclasses import dataclass
|
@@ -21,12 +24,20 @@ class Team:
|
|
21
24
|
return self.name
|
22
25
|
|
23
26
|
|
27
|
+
def is_multiple_of_100(v: int) -> int:
|
28
|
+
if v % 100 != 0:
|
29
|
+
raise ValueError("Value must be a multiple of 100")
|
30
|
+
return v
|
31
|
+
|
32
|
+
|
24
33
|
@dataclass
|
25
34
|
class Question:
|
26
|
-
question: str
|
27
|
-
answer: str
|
28
|
-
value: int = Field(
|
29
|
-
|
35
|
+
question: str = Field(description="The question to ask. Markdown is supported.")
|
36
|
+
answer: str = Field(description="The answer to the question. Markdown is supported.")
|
37
|
+
value: Annotated[int, pydantic.AfterValidator(is_multiple_of_100)] = Field(
|
38
|
+
gt=0, description="The value of the question in points. Must be a multiple of 100."
|
39
|
+
)
|
40
|
+
answered: bool = Field(default=False, description="Whether the question has been answered already.")
|
30
41
|
|
31
42
|
def __str__(self) -> str:
|
32
43
|
return self.question
|
@@ -34,8 +45,8 @@ class Question:
|
|
34
45
|
|
35
46
|
@dataclass
|
36
47
|
class Category:
|
37
|
-
name: str
|
38
|
-
questions: list[Question] = Field(max_length=5)
|
48
|
+
name: str = Field(description="The name of the category.")
|
49
|
+
questions: list[Question] = Field(max_length=5, description="The questions in this category.")
|
39
50
|
|
40
51
|
@model_validator(mode="after")
|
41
52
|
def sort_questions(self) -> Self:
|
@@ -47,13 +58,46 @@ class Category:
|
|
47
58
|
|
48
59
|
@dataclass
|
49
60
|
class Config:
|
50
|
-
categories: list[Category] = Field(max_length=5)
|
51
|
-
teams: list[Team] = Field(default=[Team("Team 1"), Team("Team 2")])
|
52
|
-
# TODO: Support random questions
|
61
|
+
categories: list[Category] = Field(max_length=5, description="The categories in the quiz.")
|
62
|
+
teams: list[Team] = Field(default=[Team("Team 1"), Team("Team 2")], description="The teams in the quiz.")
|
53
63
|
|
64
|
+
def is_finished(self) -> bool:
|
65
|
+
for category in self.categories:
|
66
|
+
for question in category.questions:
|
67
|
+
if not question.answered:
|
68
|
+
return False
|
69
|
+
return True
|
54
70
|
|
55
|
-
|
71
|
+
|
72
|
+
def _get_dict(path: pathlib.Path) -> dict[str, Any]:
|
73
|
+
"""Get the dictionary from a file.
|
74
|
+
|
75
|
+
:param path: Path of the file to load.
|
76
|
+
:raises ValueError: Raised if the file format is not supported.
|
77
|
+
:return: Dictionary with the contents of the file.
|
78
|
+
"""
|
56
79
|
with path.open() as f:
|
57
|
-
|
80
|
+
if path.suffix == ".json":
|
81
|
+
return json.load(f)
|
82
|
+
if path.suffix in {".yml", ".yaml"}:
|
83
|
+
return yaml.safe_load(f)
|
84
|
+
raise ValueError(f"Unsupported file format: {path.suffix}")
|
58
85
|
|
86
|
+
|
87
|
+
def load_config(path: pathlib.Path) -> Config:
|
88
|
+
raw = _get_dict(path)
|
59
89
|
return Config(**raw)
|
90
|
+
|
91
|
+
|
92
|
+
def dump_config_if_not_finished(config: Config) -> None:
|
93
|
+
"""Dump the config if any of the questions is not answered yet.
|
94
|
+
|
95
|
+
:param config: _description_
|
96
|
+
"""
|
97
|
+
if config.is_finished():
|
98
|
+
return
|
99
|
+
p = pathlib.Path() / f"quizzy-run-{datetime.datetime.now().strftime('%Y%m%d-%H%M%S')}.json"
|
100
|
+
print(f"This quiz is not finished yet. Saving current state to '{p}'.")
|
101
|
+
print("This file can be re-used later.")
|
102
|
+
with p.open("wb") as f:
|
103
|
+
f.write(pydantic.TypeAdapter(Config).dump_json(config))
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: quizzy
|
3
|
-
Version: 0.
|
3
|
+
Version: 0.5.0
|
4
4
|
Summary: A Python TUI quiz app
|
5
5
|
Author-email: Jonas Ehrlich <jonas.ehrlich@gmail.com>
|
6
6
|
License-Expression: MIT
|
@@ -8,6 +8,7 @@ License-File: LICENSE
|
|
8
8
|
Requires-Python: >=3.10
|
9
9
|
Requires-Dist: pydantic>=2.10.3
|
10
10
|
Requires-Dist: pyyaml>=6.0.2
|
11
|
+
Requires-Dist: textual-serve>=1.1.1
|
11
12
|
Requires-Dist: textual>=1.0.0
|
12
13
|
Description-Content-Type: text/markdown
|
13
14
|
|
@@ -63,7 +64,7 @@ uv run quizzy examples/quizzy.yaml
|
|
63
64
|
Serve on a webserver using textual:
|
64
65
|
|
65
66
|
``` sh
|
66
|
-
|
67
|
+
uvx quizzy --serve examples/quizzy.yaml
|
67
68
|
```
|
68
69
|
|
69
70
|
Run in development mode:
|
@@ -0,0 +1,10 @@
|
|
1
|
+
quizzy/__init__.py,sha256=IILIKzO2OO60Rhsh3SqhdKHDf7XI_RLRwzztBGZZmHY,78
|
2
|
+
quizzy/__main__.py,sha256=GiZpRpqbaOVG0pDtJswF_VsLByVFP2VMhHgm0Lm0LWw,36
|
3
|
+
quizzy/app.py,sha256=x2MYAF9vS_gTu369avqIkwH9n_uN2KrTzEhZO48j2wE,10893
|
4
|
+
quizzy/models.py,sha256=CxWOvAi5TbZZK76-aKRIPZhrDbHNM4U3tcrZaQ7xCJs,3189
|
5
|
+
quizzy/quizzy.tcss,sha256=VcPEGQ1J7oGatPf3kB-Hzo9AHQdrI7cOV8hDT_l9-3A,1810
|
6
|
+
quizzy-0.5.0.dist-info/METADATA,sha256=Tm5z_2q4WNNdQd4g3ciJshTe8XXW4gDaIFxtgW43NsY,1608
|
7
|
+
quizzy-0.5.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
8
|
+
quizzy-0.5.0.dist-info/entry_points.txt,sha256=2RiVMgcS4h7TM59u9dyBQFm53cG6Eyekmb8fqZ5rXHM,48
|
9
|
+
quizzy-0.5.0.dist-info/licenses/LICENSE,sha256=JWN3MACgsucm6y_rlL_2MUzst0-wNh-Wab3XkxtfVzM,1070
|
10
|
+
quizzy-0.5.0.dist-info/RECORD,,
|
quizzy-0.3.1.dist-info/RECORD
DELETED
@@ -1,10 +0,0 @@
|
|
1
|
-
quizzy/__init__.py,sha256=IILIKzO2OO60Rhsh3SqhdKHDf7XI_RLRwzztBGZZmHY,78
|
2
|
-
quizzy/__main__.py,sha256=TxTz5dT__4--aF49xzShPppphmYLCNYKMraPhp6scIc,117
|
3
|
-
quizzy/app.py,sha256=4Eh_c-kmM9T0MlfOmf4huYxdgFMWwQ_eCDzamLE81zQ,9312
|
4
|
-
quizzy/models.py,sha256=JrAc2IvtXByIKNg_lX7uQMK19zwi2WsYcNbttXccbOs,1265
|
5
|
-
quizzy/quizzy.tcss,sha256=VcPEGQ1J7oGatPf3kB-Hzo9AHQdrI7cOV8hDT_l9-3A,1810
|
6
|
-
quizzy-0.3.1.dist-info/METADATA,sha256=XPzfP0l-hLMlOBw19NNmXHfLTSOWKG9qyfHZkB_no1Q,1590
|
7
|
-
quizzy-0.3.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
8
|
-
quizzy-0.3.1.dist-info/entry_points.txt,sha256=2RiVMgcS4h7TM59u9dyBQFm53cG6Eyekmb8fqZ5rXHM,48
|
9
|
-
quizzy-0.3.1.dist-info/licenses/LICENSE,sha256=JWN3MACgsucm6y_rlL_2MUzst0-wNh-Wab3XkxtfVzM,1070
|
10
|
-
quizzy-0.3.1.dist-info/RECORD,,
|
File without changes
|
File without changes
|
File without changes
|