quizzy 0.1.0__tar.gz

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.
@@ -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)