Repository: fniessink/toisto (language: python)

Issue:
Allow for practicing concepts at a specific CEFR level



Discussion / hints:

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5d98dcf5..8fd11730 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
 ### Added
 
 - Allow for specifying the target and source language in the configuration file. Note: because this change makes the target and source language parameters optional, this changes the command-line interface. You need to use: `toisto practice --target fi --source en` instead of `toisto practice fi en` when specifying the languages on the command-line.
+= Allow for practicing a specific language level with `--level {level}`. Also allow for specifying the language level in the configuration file. Closes [#186](https://github.com/fniessink/toisto/issues/186).
 - Show the language level in the output of the topics command.
 - More colorful help information.
 
diff --git a/README.md b/README.md
index 9c3294bb..656afb5a 100644
--- a/README.md
+++ b/README.md
@@ -64,6 +64,17 @@ target = nl
 source = en
 ```
 
+#### How to configure your language level
+
+To prevent having to pass your language level as command-line argument each time you run Toisto, put the language levels you want to practice in Toisto's configuration file. Create a file `.toisto.cfg` in your home directory if it doesn't exist, add the `languages` section if it doesn't exist, and add the levels:
+
+```ini
+[languages]
+levels = A1 A2 B1
+```
+
+Note that not all words and phrases have a language level associated with them. This means that specifying `levels = A1 A2 B1 B2 C1 C2` will cause Toisto to load only words and phrases with a language level.
+
 #### How to configure a different mp3 player
 
 By default, Toisto uses `afplay` on MacOS, `mpg123` on Linux, and the PlaySound function on Windows to play mp3 files. You can configure Toisto to use a different mp3 player. Create a file `.toisto.cfg` in your home directory if it doesn't exist, add the `commands` section if it doesn't exist, and add the mp3 player:
diff --git a/src/toisto/app.py b/src/toisto/app.py
index 6735adea..3b78e3cd 100644
--- a/src/toisto/app.py
+++ b/src/toisto/app.py
@@ -22,7 +22,14 @@ def main() -> None:
     config = read_config()
     argument_parser = create_argument_parser(config)
     args = argument_parser.parse_args()
-    topics = load_topics(args.target_language, args.source_language, args.topic, args.topic_file, argument_parser)
+    topics = load_topics(
+        args.target_language,
+        args.source_language,
+        args.levels,
+        args.topic,
+        args.topic_file,
+        argument_parser,
+    )
     progress = load_progress(topics, argument_parser)
     if args.command == "practice":
         show_welcome(latest_version())
diff --git a/src/toisto/persistence/topics.py b/src/toisto/persistence/topics.py
index 1e3befcf..ad15b87a 100644
--- a/src/toisto/persistence/topics.py
+++ b/src/toisto/persistence/topics.py
@@ -5,6 +5,7 @@
 from typing import NoReturn
 
 from ..metadata import NAME, TOPIC_JSON_FILES, Language
+from ..model.language.cefr import CommonReferenceLevel
 from ..model.language.concept_factory import ConceptFactory
 from ..model.quiz.quiz_factory import QuizFactory
 from ..model.quiz.topic import Topic, Topics
@@ -14,6 +15,7 @@
 def load_topics(
     target_language: Language,
     source_language: Language,
+    levels: list[CommonReferenceLevel],
     builtin_topics_to_load: list[str],
     topic_files_to_load: list[str],
     argument_parser: ArgumentParser,
@@ -33,6 +35,8 @@ def load_topics(
         try:
             for concept_key, concept_value in load_json(topic_file).items():
                 concept = ConceptFactory(concept_key, concept_value).create_concept()
+                if levels and concept.level not in levels:
+                    continue
                 concepts.append(concept)
                 topic_quizzes.update(quiz_factory.create_quizzes(concept))
         except Exception as reason:  # noqa: BLE001
diff --git a/src/toisto/ui/cli.py b/src/toisto/ui/cli.py
index 63479bb0..7a8b3381 100644
--- a/src/toisto/ui/cli.py
+++ b/src/toisto/ui/cli.py
@@ -2,10 +2,12 @@
 
 from argparse import ArgumentParser, _SubParsersAction
 from configparser import ConfigParser
+from typing import get_args
 
 from rich_argparse import RichHelpFormatter
 
 from ..metadata import HOMEPAGE_URL, SUMMARY, SUPPORTED_LANGUAGES, TOPICS, VERSION, latest_version
+from ..model.language.cefr import CommonReferenceLevel
 
 
 def add_language_arguments(parser: ArgumentParser, config: ConfigParser) -> None:
@@ -47,6 +49,23 @@ def add_topic_arguments(parser: ArgumentParser) -> None:
     )
 
 
+def add_level_arguments(parser: ArgumentParser, config: ConfigParser) -> None:
+    """Add the language level arguments to the parser."""
+    levels = get_args(CommonReferenceLevel)
+    default = [level for level in levels if level in config.get("languages", "levels", fallback="")]
+    default_help = ", ".join(default) if default else "all"
+    parser.add_argument(
+        "-l",
+        "--level",
+        action="append",
+        default=default,
+        dest="levels",
+        metavar="{level}",
+        choices=levels,
+        help=f"language levels to use, can be repeated; default: {default_help}; available levels: %(choices)s",
+    )
+
+
 def add_practice_command(subparser: _SubParsersAction, config: ConfigParser) -> None:
     """Add a practice command."""
     command_help = (
@@ -94,6 +113,7 @@ def add_command(
         formatter_class=RichHelpFormatter,
     )
     add_language_arguments(parser, config)
+    add_level_arguments(parser, config)
     add_topic_arguments(parser)
     return parser
 


Test patch:
diff --git a/tests/toisto/persistence/test_topics.py b/tests/toisto/persistence/test_topics.py
index 91b0672e..8ae52878 100644
--- a/tests/toisto/persistence/test_topics.py
+++ b/tests/toisto/persistence/test_topics.py
@@ -2,8 +2,10 @@
 
 from argparse import ArgumentParser
 from itertools import permutations
+from typing import get_args
 from unittest.mock import Mock, patch
 
+from toisto.model.language.cefr import CommonReferenceLevel
 from toisto.model.model_types import ConceptId
 from toisto.persistence.topics import load_topics
 
@@ -17,14 +19,15 @@ def setUp(self) -> None:
         """Override to set up test fixtures."""
         concept = self.create_concept(ConceptId("welcome"))
         self.quiz = self.create_quiz(concept, "fi", "nl", "Tervetuloa", ["Welkom"])
+        self.levels = get_args(CommonReferenceLevel)
 
     def test_load_topics(self):
         """Test that the topics can be loaded."""
-        self.assertIn(self.quiz, load_topics("fi", "nl", [], [], ArgumentParser()).quizzes)
+        self.assertIn(self.quiz, load_topics("fi", "nl", self.levels, [], [], ArgumentParser()).quizzes)
 
     def test_uses_exist(self):
         """Test that all uses relations use existing concept ids."""
-        all_topics = load_topics("fi", "nl", [], [], ArgumentParser())
+        all_topics = load_topics("fi", "nl", self.levels, [], [], ArgumentParser())
         all_concept_ids = {concept.concept_id for topic in all_topics for concept in topic.concepts}
         for topic in all_topics:
             for concept in topic.concepts:
@@ -34,19 +37,26 @@ def test_uses_exist(self):
     def test_instructions(self):
         """Test that an instruction can be created for all quizzes."""
         for language1, language2 in permutations(["en", "fi", "nl"], r=2):
-            for quiz in load_topics(language1, language2, [], [], ArgumentParser()).quizzes:
+            for quiz in load_topics(language1, language2, self.levels, [], [], ArgumentParser()).quizzes:
                 quiz.instruction()  # This raises KeyError if types of the quiz are not present in the instructions
 
     def test_load_topic_by_name(self):
         """Test that a subset of the builtin topics can be loaded by name."""
-        self.assertNotIn(self.quiz, load_topics("fi", "nl", ["family"], [], ArgumentParser()).quizzes)
+        self.assertNotIn(self.quiz, load_topics("fi", "nl", self.levels, ["family"], [], ArgumentParser()).quizzes)
+
+    def test_load_topic_by_level(self):
+        """Test that a subset of the builtin concepts can be loaded by level."""
+        all_topics = load_topics("fi", "nl", self.levels[0:1], [], [], ArgumentParser())
+        for topic in all_topics:
+            for concept in topic.concepts:
+                self.assertEqual(self.levels[0], concept.level)
 
     @patch("pathlib.Path.exists", Mock(return_value=False))
     @patch("sys.stderr.write")
     def test_load_topic_by_filename(self, stderr_write: Mock):
         """Test that an error message is given when the topic file does not exist."""
-        self.assertRaises(SystemExit, load_topics, "fi", "nl", [], ["file-does-not-exist"], ArgumentParser())
+        self.assertRaises(SystemExit, load_topics, "fi", "nl", self.levels, [], ["file-doesnt-exist"], ArgumentParser())
         self.assertIn(
-            "cannot read topic file-does-not-exist: [Errno 2] No such file or directory: 'file-does-not-exist'.\n",
+            "cannot read topic file-doesnt-exist: [Errno 2] No such file or directory: 'file-doesnt-exist'.\n",
             stderr_write.call_args_list[1][0][0],
         )