quizzy 0.4.0__tar.gz → 0.5.0__tar.gz

Sign up to get free protection for your applications and to get access to all the features.
@@ -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