quizzy 0.4.0__tar.gz → 0.5.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.
@@ -136,3 +136,5 @@ dmypy.json
136
136
  .pyre/
137
137
 
138
138
  playground/
139
+
140
+ /*.json
@@ -1,6 +1,10 @@
1
1
 
2
2
  # Changelog
3
3
 
4
+ ## v0.5.0
5
+
6
+ * Save state of unfinished quizzes to continue at a later point
7
+
4
8
  ## v0.4.0
5
9
 
6
10
  * Add `--serve` flag to serve the application in a browser
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: quizzy
3
- Version: 0.4.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
@@ -1,7 +1,7 @@
1
1
 
2
2
  [project]
3
3
  name = "quizzy"
4
- version = "0.4.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"
@@ -189,7 +189,7 @@ class QuestionButton(widgets.Button):
189
189
  def wait_for_result(team: QuestionScreenResult) -> None:
190
190
  if team is None:
191
191
  return
192
- # First, disable the button to prevent multiple clicks
192
+
193
193
  self.disabled = True
194
194
  self.question.answered = True
195
195
  if isinstance(team, NoCorrectAnswerType):
@@ -244,7 +244,15 @@ class QuizzyApp(app.App[None]):
244
244
 
245
245
 
246
246
  def get_arg_parser() -> argparse.ArgumentParser:
247
- parser = argparse.ArgumentParser(prog=__name__.split(".")[0], description="A terminal quiz app")
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
+
248
256
  parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
249
257
 
250
258
  serve_group = parser.add_argument_group("Serve options")
@@ -256,18 +264,44 @@ def get_arg_parser() -> argparse.ArgumentParser:
256
264
  return parser
257
265
 
258
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
+
259
299
  def main() -> NoReturn:
260
300
  parser = get_arg_parser()
261
301
  namespace = parser.parse_args()
262
302
 
263
303
  if namespace.serve:
264
- # The --serve flag is set, drop it from the args and serve the app instead through textual-serve
265
- args = list(sys.argv)
266
- args.remove("--serve")
267
-
268
- server.Server(shlex.join(args), host=namespace.host, port=namespace.port).serve()
304
+ serve_app(namespace.host, namespace.port)
269
305
  else:
270
- config = models.load_config(namespace.quizfile)
271
- app = QuizzyApp(config)
272
- app.run()
306
+ run_app(namespace.quizfile)
273
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,7 +840,7 @@ wheels = [
840
840
 
841
841
  [[package]]
842
842
  name = "quizzy"
843
- version = "0.4.0"
843
+ version = "0.5.0"
844
844
  source = { editable = "." }
845
845
  dependencies = [
846
846
  { name = "pydantic" },
@@ -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
File without changes