quizzy 0.1.0__py3-none-any.whl

Sign up to get free protection for your applications and to get access to all the features.
quizzy/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ import importlib.metadata
2
+
3
+ __version__ = importlib.metadata.version(__name__)
quizzy/__main__.py ADDED
@@ -0,0 +1,9 @@
1
+ from quizzy.app import QuizzyApp
2
+
3
+
4
+ def main() -> None:
5
+ QuizzyApp().run()
6
+
7
+
8
+ if __name__ == "__main__":
9
+ main()
quizzy/app.py ADDED
@@ -0,0 +1,208 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import pathlib
5
+
6
+ from textual import app, containers, log, message, reactive, screen, widgets
7
+
8
+ from quizzy import __version__, models
9
+
10
+
11
+ def get_arg_parser() -> argparse.ArgumentParser:
12
+ parser = argparse.ArgumentParser(prog=__name__.split(".")[0], description="A terminal quiz app")
13
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
14
+ parser.add_argument("quizfile", type=pathlib.Path, help="Quiz file")
15
+ return parser
16
+
17
+
18
+ class AnswerScreen(screen.ModalScreen[models.Team | None]):
19
+ NOONE_ANSWERED_ID = "__noone-answered"
20
+
21
+ def __init__(self, category: str, question: models.Question, teams: list[models.Team]) -> None:
22
+ super().__init__(classes="question-answer-screen")
23
+ self.category = category
24
+ self.question = question
25
+ self.teams = {team.id: team for team in teams}
26
+ self.border_title = f"{category} - {question.value} points"
27
+
28
+ def compose(self) -> app.ComposeResult:
29
+ question_widget = widgets.Static(self.question.question, id="question")
30
+ question_widget.border_title = "Question"
31
+
32
+ answer_widget = widgets.Static(self.question.answer, id="answer")
33
+ answer_widget.border_title = "Answer"
34
+
35
+ whoanswered = containers.HorizontalGroup(
36
+ *[
37
+ containers.Vertical(widgets.Button(team.name, id=team_id, variant="primary"))
38
+ for team_id, team in self.teams.items()
39
+ ],
40
+ id="who-answered",
41
+ classes="horizontal-100",
42
+ )
43
+ whoanswered.border_title = "Who Answered Correctly?"
44
+
45
+ container = containers.Grid(
46
+ question_widget,
47
+ answer_widget,
48
+ whoanswered,
49
+ containers.Horizontal(
50
+ widgets.Button(
51
+ "😭 No one answered correctly 😭", id=self.NOONE_ANSWERED_ID, variant="error", classes="button-100"
52
+ ),
53
+ classes="horizontal-100",
54
+ ),
55
+ classes="question-answer-dialog",
56
+ id="dialog",
57
+ )
58
+
59
+ container.border_title = f"{self.category} - {self.question.value} points"
60
+ yield container
61
+
62
+ def on_button_pressed(self, event: widgets.Button.Pressed) -> None:
63
+ if event.button.id == self.NOONE_ANSWERED_ID:
64
+ self.dismiss(None)
65
+ elif event.button.id in self.teams:
66
+ team = self.teams[event.button.id]
67
+ self.dismiss(team)
68
+
69
+
70
+ class QuestionScreen(screen.ModalScreen[models.Team | None]):
71
+ SHOW_ANSWER_ID = "show-answer"
72
+
73
+ def __init__(self, category: str, question: models.Question, teams: list[models.Team]) -> None:
74
+ super().__init__(classes="question-answer-screen")
75
+ self.category = category
76
+ self.question = question
77
+ self.teams = teams
78
+
79
+ def compose(self) -> app.ComposeResult:
80
+ question_widget = widgets.Static(self.question.question, id="question")
81
+ question_widget.border_title = "Question"
82
+
83
+ container = containers.Grid(
84
+ question_widget,
85
+ containers.Horizontal(
86
+ widgets.Button("Show Answer", id=self.SHOW_ANSWER_ID, variant="primary", classes="button-100"),
87
+ classes="horizontal-100",
88
+ ),
89
+ classes="question-answer-dialog",
90
+ id="dialog",
91
+ )
92
+
93
+ container.border_title = f"{self.category} - {self.question.value} points"
94
+ yield container
95
+
96
+ def on_button_pressed(self, event: widgets.Button.Pressed) -> None:
97
+ def dismiss(team: models.Team | None) -> None:
98
+ self.dismiss(team)
99
+
100
+ if event.button.id == self.SHOW_ANSWER_ID:
101
+ self.app.push_screen(AnswerScreen(self.category, self.question, self.teams), dismiss)
102
+
103
+
104
+ class TeamScore(containers.Vertical):
105
+ score = reactive.reactive(0, recompose=True)
106
+
107
+ def __init__(self, team: models.Team) -> None:
108
+ self.team = team
109
+ super().__init__()
110
+ self.border_title = self.team.name
111
+ self.score = team.score
112
+
113
+ def compose(self) -> app.ComposeResult:
114
+ yield widgets.Static(str(self.score))
115
+
116
+ def watch_score(self, score: int) -> None:
117
+ """
118
+ Watch the reactive score and update the team's score in the data object. This allows dumping the quiz state
119
+ to YAML.
120
+ """
121
+ self.team.score = score
122
+
123
+
124
+ class Scoreboard(containers.Vertical):
125
+ def __init__(self, teams: list[models.Team]) -> None:
126
+ super().__init__()
127
+ self.teams = teams
128
+ self.team_score_widgets = {team.id: TeamScore(team) for team in teams}
129
+
130
+ def compose(self) -> app.ComposeResult:
131
+ yield containers.HorizontalGroup(*self.team_score_widgets.values())
132
+
133
+ def update_team_score(self, team_id: str, value: int) -> None:
134
+ log(f"scoreboard: Updating team score {team_id} to {value}")
135
+ self.team_score_widgets[team_id].score += value
136
+
137
+
138
+ class QuestionButton(widgets.Button):
139
+ class Answered(message.Message):
140
+ def __init__(self, team: models.Team, question_value: int) -> None:
141
+ self.team = team
142
+ self.value = question_value
143
+ super().__init__()
144
+
145
+ def __init__(self, category: str, question: models.Question, teams: list[models.Team]) -> None:
146
+ self.question = question
147
+ self.teams = teams
148
+ self.category = category
149
+ super().__init__(str(question.value), variant="warning", classes="button-100")
150
+ # Disable the button if the question has already been answered on init. This might be useful when starting with
151
+ # a state
152
+ self.disabled = question.answered
153
+
154
+ def on_click(self) -> None:
155
+ # First, disable the button to prevent multiple clicks
156
+ self.disabled = True
157
+ self.question.answered = True
158
+
159
+ def wait_for_result(team: models.Team | None) -> None:
160
+ if team is None:
161
+ log("question-button: No-one answered the question")
162
+ else:
163
+ log(f"question-button: {team.id} answered the question")
164
+ self.post_message(self.Answered(team, self.question.value))
165
+
166
+ self.app.push_screen(QuestionScreen(self.category, self.question, self.teams), wait_for_result)
167
+
168
+
169
+ class CategoryColumn(containers.VerticalGroup):
170
+ def __init__(self, category: models.Category, teams: list[models.Team]) -> None:
171
+ self.category = category
172
+ self.teams = teams
173
+ super().__init__()
174
+
175
+ def compose(self) -> app.ComposeResult:
176
+ yield widgets.Static(self.category.name)
177
+ for question in self.category.questions:
178
+ yield QuestionButton(self.category.name, question, self.teams)
179
+
180
+
181
+ class QuestionBoard(containers.HorizontalGroup):
182
+ def __init__(self, config: models.Config) -> None:
183
+ self.config = config
184
+ super().__init__()
185
+
186
+ def compose(self) -> app.ComposeResult:
187
+ for category in self.config.categories:
188
+ yield CategoryColumn(category, self.config.teams)
189
+
190
+
191
+ class QuizzyApp(app.App[None]):
192
+ CSS_PATH = "quizzy.tcss"
193
+
194
+ def __init__(self) -> None:
195
+ super().__init__()
196
+ parser = get_arg_parser()
197
+ namespace = parser.parse_args()
198
+
199
+ self.config = models.load_config(namespace.quizfile)
200
+ self.scoreboard_widget = Scoreboard(self.config.teams)
201
+
202
+ def compose(self) -> app.ComposeResult:
203
+ yield widgets.Header()
204
+ yield widgets.Footer()
205
+ yield containers.Grid(QuestionBoard(self.config), self.scoreboard_widget, id="app-grid")
206
+
207
+ def on_question_button_answered(self, event: QuestionButton.Answered) -> None:
208
+ self.scoreboard_widget.update_team_score(event.team.id, event.value)
quizzy/models.py ADDED
@@ -0,0 +1,59 @@
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)
quizzy/quizzy.tcss ADDED
@@ -0,0 +1,101 @@
1
+ TeamScore {
2
+ border: solid $primary;
3
+ align: center top;
4
+ height: 3;
5
+ margin: 1;
6
+ }
7
+
8
+
9
+ .question-answer-screen {
10
+ align: center middle;
11
+ }
12
+
13
+ .question-answer-dialog {
14
+ width: 80%;
15
+ height: 80%;
16
+ min-height: 40;
17
+ padding: 0 2;
18
+ border: solid $primary;
19
+ background: $surface;
20
+ content-align: center middle;
21
+
22
+ #question {
23
+ content-align: center middle;
24
+ padding: 2;
25
+ border: solid $foreground 80%;
26
+ }
27
+
28
+ #answer {
29
+ content-align: center middle;
30
+ padding: 2;
31
+ border: solid $success 80%;
32
+
33
+ }
34
+ }
35
+
36
+ QuestionScreen {
37
+ #dialog {
38
+ grid-size: 1;
39
+ grid-rows: 90% 6;
40
+ }
41
+ }
42
+
43
+ .horizontal-100 {
44
+ width: 100%;
45
+ }
46
+
47
+ Button.button-100 {
48
+ width: 100%;
49
+
50
+ &:focus {
51
+ /* Disable reversing for text of focussed full-width buttons */
52
+ text-style: bold;
53
+ }
54
+ }
55
+
56
+ AnswerScreen {
57
+ #dialog {
58
+ grid-size: 1;
59
+ grid-rows: 40% 40% 5 4;
60
+
61
+ #who-answered {
62
+ border: solid $foreground 80%;
63
+ Button {
64
+ margin: 0 1;
65
+ width: 100%;
66
+ &:focus {
67
+ /* Disable reversing for text of focussed buttons in the who-answered row */
68
+ text-style: bold;
69
+ }
70
+ }
71
+ }
72
+ }
73
+
74
+ }
75
+
76
+ QuestionBoard {
77
+ width: 100%;
78
+ padding: 5 0 0 0;
79
+ min-width: 50%;
80
+ align: center top;
81
+ }
82
+
83
+ CategoryColumn {
84
+ width: auto;
85
+ min-width: 30;
86
+ border: solid $secondary;
87
+ align: center middle;
88
+
89
+ Button {
90
+ margin: 1;
91
+
92
+ &:disabled {
93
+ opacity: 0.5;
94
+ }
95
+ }
96
+ }
97
+
98
+ #app-grid {
99
+ grid-size: 1 2;
100
+ grid-rows: 1fr 5;
101
+ }
@@ -0,0 +1,69 @@
1
+ Metadata-Version: 2.4
2
+ Name: quizzy
3
+ Version: 0.1.0
4
+ Summary: A Python TUI quiz app
5
+ Author-email: Jonas Ehrlich <jonas.ehrlich@gmail.com>
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.10
9
+ Requires-Dist: pydantic>=2.10.3
10
+ Requires-Dist: pyyaml>=6.0.2
11
+ Requires-Dist: textual>=1.0.0
12
+ Description-Content-Type: text/markdown
13
+
14
+ # Quizzy
15
+
16
+ A quiz app using [textual](https://textual.textualize.io/).
17
+
18
+ ![Question board](assets/question-board.png)
19
+
20
+
21
+ ## Configuration and Questions
22
+
23
+ Create a YAML file to define the teams participating, the questions and their answers.
24
+
25
+ ```yaml
26
+ teams:
27
+ - name: "Team 1"
28
+ - name: "Team 2"
29
+ categories:
30
+ - name: "General Knowledge"
31
+ questions:
32
+ - question: "What is the capital of France?"
33
+ answer: "Paris"
34
+ value: 100
35
+ - question: "What is the capital of Germany?"
36
+ answer: "Berlin"
37
+ value: 200
38
+ - name: "Science"
39
+ questions:
40
+ - question: "What is the chemical symbol for gold?"
41
+ answer: "Au"
42
+ value: 100
43
+ - question: "What is the chemical symbol for silver?"
44
+ answer: "Ag"
45
+ value: 200
46
+ ```
47
+
48
+ See [examples/quizzy.yaml](examples/quizzy.yaml) for an example.
49
+
50
+
51
+ ## Running it
52
+
53
+ Run normally using *uv*:
54
+
55
+ ``` sh
56
+ uv run quizzy examples/quizzy.yaml
57
+ ```
58
+
59
+ Serve using textual:
60
+
61
+ ``` sh
62
+ uv run textual serve "uv run quizzy examples/quizzy.yaml"
63
+ ```
64
+
65
+ Run in development mode:
66
+
67
+ ``` sh
68
+ uv run textual run --dev quizzy.app:QuizzyApp examples/quizzy.yaml
69
+ ```
@@ -0,0 +1,10 @@
1
+ quizzy/__init__.py,sha256=IILIKzO2OO60Rhsh3SqhdKHDf7XI_RLRwzztBGZZmHY,78
2
+ quizzy/__main__.py,sha256=TxTz5dT__4--aF49xzShPppphmYLCNYKMraPhp6scIc,117
3
+ quizzy/app.py,sha256=B9fIHy9L5HILbuDoY4ief0wCcVY8_X9Uqb0ByK_ihQk,7687
4
+ quizzy/models.py,sha256=JrAc2IvtXByIKNg_lX7uQMK19zwi2WsYcNbttXccbOs,1265
5
+ quizzy/quizzy.tcss,sha256=ixFfm8pT_JLdL0XZ-4n8u0fs3l9lAHWQa3-q9d4bRRU,1455
6
+ quizzy-0.1.0.dist-info/METADATA,sha256=l3qoY9aRtg1Cpx-QvLHckMP7LmpjsviO9qg15o0oeT4,1464
7
+ quizzy-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
8
+ quizzy-0.1.0.dist-info/entry_points.txt,sha256=2RiVMgcS4h7TM59u9dyBQFm53cG6Eyekmb8fqZ5rXHM,48
9
+ quizzy-0.1.0.dist-info/licenses/LICENSE,sha256=JWN3MACgsucm6y_rlL_2MUzst0-wNh-Wab3XkxtfVzM,1070
10
+ quizzy-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ quizzy = quizzy.__main__:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Jonas Ehrlich
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.