quizzy 0.3.1__tar.gz → 0.5.0__tar.gz
Sign up to get free protection for your applications and to get access to all the features.
- {quizzy-0.3.1 → quizzy-0.5.0}/.gitignore +2 -0
- {quizzy-0.3.1 → quizzy-0.5.0}/CHANGELOG.md +8 -0
- {quizzy-0.3.1 → quizzy-0.5.0}/PKG-INFO +3 -2
- {quizzy-0.3.1 → quizzy-0.5.0}/README.md +1 -1
- {quizzy-0.3.1 → quizzy-0.5.0}/pyproject.toml +7 -2
- quizzy-0.5.0/quizzy/__main__.py +3 -0
- {quizzy-0.3.1 → quizzy-0.5.0}/quizzy/app.py +71 -14
- quizzy-0.5.0/quizzy/models.py +103 -0
- {quizzy-0.3.1 → quizzy-0.5.0}/uv.lock +3 -1
- quizzy-0.3.1/quizzy/__main__.py +0 -9
- quizzy-0.3.1/quizzy/models.py +0 -59
- {quizzy-0.3.1 → quizzy-0.5.0}/.github/workflows/ci.yaml +0 -0
- {quizzy-0.3.1 → quizzy-0.5.0}/.github/workflows/publish-to-pypi.yaml +0 -0
- {quizzy-0.3.1 → quizzy-0.5.0}/.pre-commit-config.yaml +0 -0
- {quizzy-0.3.1 → quizzy-0.5.0}/LICENSE +0 -0
- {quizzy-0.3.1 → quizzy-0.5.0}/assets/question-board.png +0 -0
- {quizzy-0.3.1 → quizzy-0.5.0}/examples/quizzy.yaml +0 -0
- {quizzy-0.3.1 → quizzy-0.5.0}/quizzy/__init__.py +0 -0
- {quizzy-0.3.1 → quizzy-0.5.0}/quizzy/quizzy.tcss +0 -0
@@ -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:
|
@@ -1,12 +1,17 @@
|
|
1
1
|
|
2
2
|
[project]
|
3
3
|
name = "quizzy"
|
4
|
-
version = "0.
|
4
|
+
version = "0.5.0"
|
5
5
|
description = "A Python TUI quiz app"
|
6
6
|
authors = [{ name = "Jonas Ehrlich", email = "jonas.ehrlich@gmail.com" }]
|
7
7
|
readme = "README.md"
|
8
8
|
requires-python = ">=3.10"
|
9
|
-
dependencies = [
|
9
|
+
dependencies = [
|
10
|
+
"pydantic>=2.10.3",
|
11
|
+
"pyyaml>=6.0.2",
|
12
|
+
"textual-serve>=1.1.1",
|
13
|
+
"textual>=1.0.0",
|
14
|
+
]
|
10
15
|
license = "MIT"
|
11
16
|
|
12
17
|
[project.scripts]
|
@@ -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)
|
@@ -0,0 +1,103 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import datetime
|
4
|
+
import json
|
5
|
+
import pathlib
|
6
|
+
from typing import Annotated, Any, Self
|
7
|
+
|
8
|
+
import pydantic
|
9
|
+
import yaml
|
10
|
+
from pydantic import Field, model_validator
|
11
|
+
from pydantic.dataclasses import dataclass
|
12
|
+
|
13
|
+
|
14
|
+
@dataclass
|
15
|
+
class Team:
|
16
|
+
name: str = Field(min_length=2)
|
17
|
+
score: int = Field(default=0, ge=0)
|
18
|
+
id: str = Field(init=False)
|
19
|
+
|
20
|
+
def __post_init__(self) -> None:
|
21
|
+
self.id = self.name.replace(" ", "-").lower()
|
22
|
+
|
23
|
+
def __str__(self) -> str:
|
24
|
+
return self.name
|
25
|
+
|
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
|
+
|
33
|
+
@dataclass
|
34
|
+
class Question:
|
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.")
|
41
|
+
|
42
|
+
def __str__(self) -> str:
|
43
|
+
return self.question
|
44
|
+
|
45
|
+
|
46
|
+
@dataclass
|
47
|
+
class Category:
|
48
|
+
name: str = Field(description="The name of the category.")
|
49
|
+
questions: list[Question] = Field(max_length=5, description="The questions in this category.")
|
50
|
+
|
51
|
+
@model_validator(mode="after")
|
52
|
+
def sort_questions(self) -> Self:
|
53
|
+
"""Sort the questions in this category by their value."""
|
54
|
+
|
55
|
+
self.questions.sort(key=lambda q: q.value)
|
56
|
+
return self
|
57
|
+
|
58
|
+
|
59
|
+
@dataclass
|
60
|
+
class Config:
|
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.")
|
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
|
70
|
+
|
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
|
+
"""
|
79
|
+
with path.open() as f:
|
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}")
|
85
|
+
|
86
|
+
|
87
|
+
def load_config(path: pathlib.Path) -> Config:
|
88
|
+
raw = _get_dict(path)
|
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))
|
@@ -840,12 +840,13 @@ wheels = [
|
|
840
840
|
|
841
841
|
[[package]]
|
842
842
|
name = "quizzy"
|
843
|
-
version = "0.
|
843
|
+
version = "0.5.0"
|
844
844
|
source = { editable = "." }
|
845
845
|
dependencies = [
|
846
846
|
{ name = "pydantic" },
|
847
847
|
{ name = "pyyaml" },
|
848
848
|
{ name = "textual" },
|
849
|
+
{ name = "textual-serve" },
|
849
850
|
]
|
850
851
|
|
851
852
|
[package.dev-dependencies]
|
@@ -863,6 +864,7 @@ requires-dist = [
|
|
863
864
|
{ name = "pydantic", specifier = ">=2.10.3" },
|
864
865
|
{ name = "pyyaml", specifier = ">=6.0.2" },
|
865
866
|
{ name = "textual", specifier = ">=1.0.0" },
|
867
|
+
{ name = "textual-serve", specifier = ">=1.1.1" },
|
866
868
|
]
|
867
869
|
|
868
870
|
[package.metadata.requires-dev]
|
quizzy-0.3.1/quizzy/__main__.py
DELETED
quizzy-0.3.1/quizzy/models.py
DELETED
@@ -1,59 +0,0 @@
|
|
1
|
-
from __future__ import annotations
|
2
|
-
|
3
|
-
import pathlib
|
4
|
-
from typing import Self
|
5
|
-
|
6
|
-
import yaml
|
7
|
-
from pydantic import Field, model_validator
|
8
|
-
from pydantic.dataclasses import dataclass
|
9
|
-
|
10
|
-
|
11
|
-
@dataclass
|
12
|
-
class Team:
|
13
|
-
name: str = Field(min_length=2)
|
14
|
-
score: int = Field(default=0, ge=0)
|
15
|
-
id: str = Field(init=False)
|
16
|
-
|
17
|
-
def __post_init__(self) -> None:
|
18
|
-
self.id = self.name.replace(" ", "-").lower()
|
19
|
-
|
20
|
-
def __str__(self) -> str:
|
21
|
-
return self.name
|
22
|
-
|
23
|
-
|
24
|
-
@dataclass
|
25
|
-
class Question:
|
26
|
-
question: str
|
27
|
-
answer: str
|
28
|
-
value: int = Field(gt=0)
|
29
|
-
answered: bool = False
|
30
|
-
|
31
|
-
def __str__(self) -> str:
|
32
|
-
return self.question
|
33
|
-
|
34
|
-
|
35
|
-
@dataclass
|
36
|
-
class Category:
|
37
|
-
name: str
|
38
|
-
questions: list[Question] = Field(max_length=5)
|
39
|
-
|
40
|
-
@model_validator(mode="after")
|
41
|
-
def sort_questions(self) -> Self:
|
42
|
-
"""Sort the questions in this category by their value."""
|
43
|
-
|
44
|
-
self.questions.sort(key=lambda q: q.value)
|
45
|
-
return self
|
46
|
-
|
47
|
-
|
48
|
-
@dataclass
|
49
|
-
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
|
53
|
-
|
54
|
-
|
55
|
-
def load_config(path: pathlib.Path) -> Config:
|
56
|
-
with path.open() as f:
|
57
|
-
raw = yaml.safe_load(f)
|
58
|
-
|
59
|
-
return Config(**raw)
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|