quizzy 0.1.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/__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.