Repository: MasterMedo/typetest (language: python)

Issue:
Instead of throwing an error when trying to read a file, print a friendly message!
If you've never run `typetest` and successfully finished a typing test before using `typetest-analyse`, analysing will fail because there are no results files to read from.
If there is no file to read from, `typetest-analyse` should fail and print a friendly message that more tests need to be completed!

There are two challenges when making this work correctly:
1. Reading the file in the appropriate function instead of while parsing arguments
2. Not duplicating code

The justification for the first rule is the command `typetest-analyse wpm`. This requires only the existence of the `results.csv` file, but it will fail even if any other results files doesn't exist, because the default arguments are trying to open all results files. This should be avoided since they don't need to exist for all functions to work properly.

To appease to the second challenge, and avoid:
- duplicating the error message
- duplicating the file opening
a decorator needs to be created that will open the file and pass it to the appropriate argument, and if opening the file fails it will print the error message e.g. `f'Results file {filename} not found. Please do more typing tests with "typetest" before analysing the results'`.


Discussion / hints:
Hi, I would be interested on working on this!
You're welcome to! 🎉
If you have any questions, feel free to ask away!
I noticed there is an error when you try to run `typetest-analyse` after only completing one test. This should be fixed, too :)

Fix patch:
diff --git a/.gitignore b/.gitignore
index 99ddba0..65f77f6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,7 @@
 __pycache__/
 .vim/
 dist/
+env/
 .vscode/
 
 *.csv


Test patch:
diff --git a/typetest/analyse.py b/typetest/analyse.py
index 983d9cd..0a5e452 100755
--- a/typetest/analyse.py
+++ b/typetest/analyse.py
@@ -17,7 +17,7 @@
 import seaborn as sns
 import matplotlib.pyplot as plt
 
-from typetest.utils import damerau_levenshtein_distance
+from typetest.utils import damerau_levenshtein_distance, check_files
 
 warnings.simplefilter("ignore", np.RankWarning)
 warnings.simplefilter("error", UserWarning)
@@ -72,6 +72,7 @@ def main(graphs, output, mistyped, char_speeds, word_speeds, help):
         plot_mistypes_distribution(mistyped, filter_func=is_word)
 
 
+@check_files
 def plot_wpm(output):
     """Reads `output` and plots typing speeds uniformly apart.
     Adds a trendline.
@@ -89,13 +90,6 @@ def plot_wpm(output):
         ],
     )
 
-    if len(df) < 2:
-        print(
-            "More data is needed, before analysing is possible. "
-            + "A minimum of 2 tests is required."
-        )
-        return
-
     df.timestamp = pd.to_datetime(df.timestamp)
     # df = df.set_index(df.timestamp)
 
@@ -114,6 +108,12 @@ def plot_wpm(output):
     #                  reverse=True)
     grouped = gdf.items()
 
+    if any(len(group[1][0]) < 2 for group in grouped):
+        exit(
+            "More data is needed, before analysing is possible. "
+            + "A minimum of 2 tests is required."
+        )
+
     fig, ax = plt.subplots()
     colors = cycle(sns.color_palette())
     for h, (indexes, hdf) in grouped:
@@ -153,6 +153,7 @@ def plot_wpm(output):
     show_diagram()
 
 
+@check_files
 def plot_char_speeds(char_speeds, size=10000, filter_func=lambda c: True):
     """Reads last `size` lines of `char_speeds` and groups them by characters.
     Removes lowest and highest 10% and boxplots the data.
@@ -160,7 +161,9 @@ def plot_char_speeds(char_speeds, size=10000, filter_func=lambda c: True):
     filter_func: function taking a `char` returning `True` if char should be
     plotted, `False` otherwise. By default plots all characters.
     """
-    q = deque(char_speeds, maxlen=size)
+    with open(char_speeds) as f:
+        q = deque(f, maxlen=size)
+
     df = pd.read_csv(
         StringIO("".join(q)),
         header=None,
@@ -181,7 +184,8 @@ def plot_char_speeds(char_speeds, size=10000, filter_func=lambda c: True):
             typing_speed_in_wpm = df.query("@q1 <= wpm <= @q3")["wpm"]
             chars.append(char)
             typing_speeds_in_wpm.append(typing_speed_in_wpm)
-            means.append(typing_speed_in_wpm.mean())
+            mean = typing_speed_in_wpm.mean()
+            means.append(mean if mean > 0 else 0)
 
     assert chars, "Not enough data"
     fig, ax = plt.subplots()
@@ -201,6 +205,7 @@ def plot_char_speeds(char_speeds, size=10000, filter_func=lambda c: True):
     show_diagram()
 
 
+@check_files
 def plot_n_best_word_speeds(word_speeds, n, filter_func=lambda w: True):
     """Loads all words from `word_speeds` and groups them by word."""
 
@@ -255,6 +260,7 @@ def plot_n_best_word_speeds(word_speeds, n, filter_func=lambda w: True):
     show_diagram()
 
 
+@check_files
 def plot_word_wpm_distribution(word_speeds, filter_func=lambda c: True):
     """Plots a distribution over average speeds of unique words."""
     df = pd.read_csv(
@@ -273,6 +279,7 @@ def plot_word_wpm_distribution(word_speeds, filter_func=lambda c: True):
     show_diagram()
 
 
+@check_files
 def plot_mistypes_distribution(mistyped, filter_func=lambda c: True):
     """Plots a pie chart representing the shares of numbers of mistakes in
     mistyped words.
@@ -296,6 +303,12 @@ def plot_mistypes_distribution(mistyped, filter_func=lambda c: True):
 
             mistakes[distance] += 1
 
+    if not mistakes:
+        exit(
+            "No incorrectly written words found. Do more typing tests"
+            + "with `typetest` to increase the chances of making a mistake."
+        )
+
     fig, ax = plt.subplots()
     labels, sizes = zip(*sorted(mistakes.items()))
     explode = [0] + [0.2] * (len(mistakes) - 1)
@@ -406,28 +419,28 @@ def parse_args():
     parser.add_argument(
         "-o",
         "--output",
-        type=FileType("r"),
+        type=str,
         default=f"{base_directory}/{results_directory}/results.csv",
         help="file to store results in\n" + default,
     )
     parser.add_argument(
         "-m",
         "--mistyped",
-        type=FileType("r"),
+        type=str,
         default=f"{base_directory}/{results_directory}/mistyped_words.csv",
         help="file to store mistyped words in\n" + default,
     )
     parser.add_argument(
         "-c",
         "--char_speeds",
-        type=FileType("r"),
+        type=str,
         default=f"{base_directory}/{results_directory}/char_speeds.csv",
         help="file to store character speeds in\n" + default,
     )
     parser.add_argument(
         "-w",
         "--word_speeds",
-        type=FileType("r"),
+        type=str,
         default=f"{base_directory}/{results_directory}/word_speeds.csv",
         help="file to store word speeds in\n" + default,
     )
diff --git a/typetest/utils.py b/typetest/utils.py
index 2c74541..9e4ee00 100644
--- a/typetest/utils.py
+++ b/typetest/utils.py
@@ -1,4 +1,7 @@
 """Various utility functions."""
+from functools import wraps
+from os.path import dirname, isfile
+from sys import exit
 
 
 def damerau_levenshtein_distance(word_1: str, word_2: str) -> int:
@@ -41,3 +44,24 @@ def damerau_levenshtein_distance(word_1: str, word_2: str) -> int:
         last_encountered_cols[char_1] = col
 
     return table[len(word_2) + 1][len(word_1) + 1]
+
+
+def check_files(func):
+    """Wrapper function that checks if the first argument of the
+    decorated function is a filename of a file that exists.
+    """
+
+    @wraps(func)
+    def wrapper(*args, **kwargs):
+        file_to_open = args[0]
+
+        if not isfile(file_to_open):
+            exit(
+                f"The file {file_to_open} does not exist. Please run"
+                + "`typetest` to generate more test results, or provide"
+                + "a custom path to results file/directory"
+            )
+
+        func(*args, **kwargs)
+
+    return wrapper