quizzy 0.1.0__tar.gz

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,19 @@
1
+ name: test
2
+
3
+ on: [push]
4
+ jobs:
5
+ lint:
6
+ runs-on: ubuntu-latest
7
+ strategy:
8
+ matrix:
9
+ python-version: ["3.10", "3.11", "3.12", "3.13"]
10
+
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+ - uses: astral-sh/setup-uv@v4
14
+ with:
15
+ version: "0.5.9"
16
+ - name: Ruff lint
17
+ run: uv run ruff check
18
+ - name: Ruff format
19
+ run: uv run ruff format --diff --check
@@ -0,0 +1,26 @@
1
+ name: Publish Python distributions to PyPI
2
+ on:
3
+ push:
4
+ tags:
5
+ - v[0-9]+.[0-9]+.[0-9]+
6
+ jobs:
7
+ pypi-publish:
8
+ name: Upload to PyPI
9
+ runs-on: ubuntu-latest
10
+ permissions:
11
+ # For PyPI's trusted publishing.
12
+ id-token: write
13
+ steps:
14
+ - name: "Install uv"
15
+ uses: astral-sh/setup-uv@v4
16
+ with:
17
+ # Install a specific version of uv.
18
+ version: "0.5.9"
19
+ - name: Checkout the repository
20
+ uses: actions/checkout@v3
21
+ - name: Lint with ruff
22
+ run: uv run ruff check
23
+ - name: Build Python distributions
24
+ run: uv build
25
+ - name: Publish to PyPI
26
+ run: uv publish -v --trusted-publishing always
@@ -0,0 +1,138 @@
1
+ # Virtual environments
2
+ .venv
3
+ # IDE directories
4
+ .vscode/
5
+ .idea/
6
+ *.code-workspace
7
+
8
+ # Byte-compiled / optimized / DLL files
9
+ __pycache__/
10
+ *.py[cod]
11
+ *$py.class
12
+
13
+ # C extensions
14
+ *.so
15
+
16
+ # Distribution / packaging
17
+ .Python
18
+ build/
19
+ develop-eggs/
20
+ dist/
21
+ downloads/
22
+ eggs/
23
+ .eggs/
24
+ lib/
25
+ lib64/
26
+ parts/
27
+ sdist/
28
+ var/
29
+ wheels/
30
+ pip-wheel-metadata/
31
+ share/python-wheels/
32
+ *.egg-info/
33
+ .installed.cfg
34
+ *.egg
35
+ MANIFEST
36
+
37
+ # PyInstaller
38
+ # Usually these files are written by a python script from a template
39
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
40
+ *.manifest
41
+ *.spec
42
+
43
+ # Installer logs
44
+ pip-log.txt
45
+ pip-delete-this-directory.txt
46
+
47
+ # Unit test / coverage reports
48
+ htmlcov/
49
+ .tox/
50
+ .nox/
51
+ .coverage
52
+ .coverage.*
53
+ .cache
54
+ nosetests.xml
55
+ coverage.xml
56
+ *.cover
57
+ *.py,cover
58
+ .hypothesis/
59
+ .pytest_cache/
60
+
61
+ # Translations
62
+ *.mo
63
+ *.pot
64
+
65
+ # Django stuff:
66
+ *.log
67
+ local_settings.py
68
+ db.sqlite3
69
+ db.sqlite3-journal
70
+
71
+ # Flask stuff:
72
+ instance/
73
+ .webassets-cache
74
+
75
+ # Scrapy stuff:
76
+ .scrapy
77
+
78
+ # Sphinx documentation
79
+ docs/_build/
80
+
81
+ # PyBuilder
82
+ target/
83
+
84
+ # Jupyter Notebook
85
+ .ipynb_checkpoints
86
+
87
+ # IPython
88
+ profile_default/
89
+ ipython_config.py
90
+
91
+ # pyenv
92
+ .python-version
93
+
94
+ # pipenv
95
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
96
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
97
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
98
+ # install all needed dependencies.
99
+ #Pipfile.lock
100
+
101
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow
102
+ __pypackages__/
103
+
104
+ # Celery stuff
105
+ celerybeat-schedule
106
+ celerybeat.pid
107
+
108
+ # SageMath parsed files
109
+ *.sage.py
110
+
111
+ # Environments
112
+ .env
113
+ .venv
114
+ env/
115
+ venv/
116
+ ENV/
117
+ env.bak/
118
+ venv.bak/
119
+
120
+ # Spyder project settings
121
+ .spyderproject
122
+ .spyproject
123
+
124
+ # Rope project settings
125
+ .ropeproject
126
+
127
+ # mkdocs documentation
128
+ /site
129
+
130
+ # mypy
131
+ .mypy_cache/
132
+ .dmypy.json
133
+ dmypy.json
134
+
135
+ # Pyre type checker
136
+ .pyre/
137
+
138
+ playground/
@@ -0,0 +1,20 @@
1
+ repos:
2
+ - repo: https://github.com/pre-commit/pre-commit-hooks
3
+ rev: v5.0.0
4
+ hooks:
5
+ - id: check-yaml
6
+ - id: end-of-file-fixer
7
+ - id: trailing-whitespace
8
+ - repo: https://github.com/psf/black
9
+ rev: "24.10.0"
10
+ hooks:
11
+ - id: black
12
+ - repo: https://github.com/charliermarsh/ruff-pre-commit
13
+ # Ruff version.
14
+ rev: "v0.8.3"
15
+ hooks:
16
+ - id: ruff
17
+ - repo: https://github.com/pycqa/isort
18
+ rev: "5.13.2"
19
+ hooks:
20
+ - id: isort
quizzy-0.1.0/LICENSE ADDED
@@ -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.
quizzy-0.1.0/PKG-INFO ADDED
@@ -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
+ ```
quizzy-0.1.0/README.md ADDED
@@ -0,0 +1,56 @@
1
+ # Quizzy
2
+
3
+ A quiz app using [textual](https://textual.textualize.io/).
4
+
5
+ ![Question board](assets/question-board.png)
6
+
7
+
8
+ ## Configuration and Questions
9
+
10
+ Create a YAML file to define the teams participating, the questions and their answers.
11
+
12
+ ```yaml
13
+ teams:
14
+ - name: "Team 1"
15
+ - name: "Team 2"
16
+ categories:
17
+ - name: "General Knowledge"
18
+ questions:
19
+ - question: "What is the capital of France?"
20
+ answer: "Paris"
21
+ value: 100
22
+ - question: "What is the capital of Germany?"
23
+ answer: "Berlin"
24
+ value: 200
25
+ - name: "Science"
26
+ questions:
27
+ - question: "What is the chemical symbol for gold?"
28
+ answer: "Au"
29
+ value: 100
30
+ - question: "What is the chemical symbol for silver?"
31
+ answer: "Ag"
32
+ value: 200
33
+ ```
34
+
35
+ See [examples/quizzy.yaml](examples/quizzy.yaml) for an example.
36
+
37
+
38
+ ## Running it
39
+
40
+ Run normally using *uv*:
41
+
42
+ ``` sh
43
+ uv run quizzy examples/quizzy.yaml
44
+ ```
45
+
46
+ Serve using textual:
47
+
48
+ ``` sh
49
+ uv run textual serve "uv run quizzy examples/quizzy.yaml"
50
+ ```
51
+
52
+ Run in development mode:
53
+
54
+ ``` sh
55
+ uv run textual run --dev quizzy.app:QuizzyApp examples/quizzy.yaml
56
+ ```
Binary file
@@ -0,0 +1,21 @@
1
+ teams:
2
+ - name: "Team 1"
3
+ - name: "Team 2"
4
+ categories:
5
+ - name: "General Knowledge"
6
+ questions:
7
+ - question: "What is the capital of France?"
8
+ answer: "Paris"
9
+ value: 100
10
+ - question: "What is the capital of Germany?"
11
+ answer: "Berlin"
12
+ value: 200
13
+ - name: "Science"
14
+ questions:
15
+ - question: "What is the chemical symbol for gold?"
16
+ answer: "Au"
17
+ value: 100
18
+ - question: "What is the chemical symbol for silver?"
19
+ answer: "Ag"
20
+ value: 200
21
+
@@ -0,0 +1,55 @@
1
+
2
+ [project]
3
+ name = "quizzy"
4
+ version = "0.1.0"
5
+ description = "A Python TUI quiz app"
6
+ authors = [{ name = "Jonas Ehrlich", email = "jonas.ehrlich@gmail.com" }]
7
+ readme = "README.md"
8
+ requires-python = ">=3.10"
9
+ dependencies = ["pydantic>=2.10.3", "pyyaml>=6.0.2", "textual>=1.0.0"]
10
+ license = "MIT"
11
+
12
+ [project.scripts]
13
+ quizzy = "quizzy.__main__:main"
14
+
15
+ [build-system]
16
+ requires = ["hatchling"]
17
+ build-backend = "hatchling.build"
18
+
19
+
20
+ [dependency-groups]
21
+ dev = [
22
+ "black>=24.10.0",
23
+ "isort>=5.13.2",
24
+ "pytest>=8.3.4",
25
+ "pytest-sugar>=1.0.0",
26
+ "ruff>=0.8.3",
27
+ "textual-dev>=1.7.0",
28
+ ]
29
+
30
+ [tool.isort]
31
+ profile = "black"
32
+
33
+ [tool.black]
34
+ line-length = 120
35
+
36
+ [tool.mypy]
37
+ strict = true
38
+
39
+ [tool.ruff]
40
+ line-length = 120
41
+ src = ["quizzy"]
42
+
43
+ [tool.ruff.lint]
44
+ select = [
45
+ "E", # pycodestyle error rules
46
+ "F", # pycodestyle warning rules
47
+ "B", # flake8-bugbear rules
48
+ "S", # flake8-bandit rules
49
+ "PTH", # flake8-use-pathlib
50
+ "PLC", # pylint convention rules
51
+ "PLR", # pylint refactor rules
52
+ "PLE", # pylint error rules
53
+ "PLW", # pylint warning rules
54
+ "C90", # mccabe complexity rules
55
+ ]
@@ -0,0 +1,3 @@
1
+ import importlib.metadata
2
+
3
+ __version__ = importlib.metadata.version(__name__)
@@ -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()
@@ -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)