From 045deffec47cf123a8fc7fb54a7ac7e5e5c1f20f Mon Sep 17 00:00:00 2001 From: "Anastasiia.Birillo" Date: Mon, 17 May 2021 13:18:39 +0300 Subject: [PATCH 1/9] Update version to 1.2.0 --- VERSION.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION.md b/VERSION.md index 3eefcb9d..26aaba0e 100644 --- a/VERSION.md +++ b/VERSION.md @@ -1 +1 @@ -1.0.0 +1.2.0 From 16937baef9af6e8adf87b12a2406d9441e491177 Mon Sep 17 00:00:00 2001 From: "Anastasiia.Birillo" Date: Mon, 17 May 2021 15:22:25 +0300 Subject: [PATCH 2/9] Add possibility to evaluate csv files --- src/python/evaluation/README.md | 16 +++-- src/python/evaluation/common/csv_util.py | 10 +++ src/python/evaluation/common/util.py | 5 +- src/python/evaluation/evaluation_config.py | 19 ++++-- ...lsx_run_tool.py => evaluation_run_tool.py} | 64 ++++++++++++------- src/python/review/common/file_system.py | 7 ++ test/python/evaluation/test_data_path.py | 7 +- test/python/evaluation/test_output_results.py | 7 +- test/python/evaluation/test_tool_path.py | 12 ++-- .../evaluation/test_xlsx_file_structure.py | 8 +-- test/python/evaluation/testing_config.py | 4 +- 11 files changed, 106 insertions(+), 53 deletions(-) create mode 100644 src/python/evaluation/common/csv_util.py rename src/python/evaluation/{xlsx_run_tool.py => evaluation_run_tool.py} (77%) diff --git a/src/python/evaluation/README.md b/src/python/evaluation/README.md index 67e1d45e..356c6a3d 100644 --- a/src/python/evaluation/README.md +++ b/src/python/evaluation/README.md @@ -1,25 +1,29 @@ # Hyperstyle evaluation -This tool allows running the `Hyperstyle` tool on an xlsx table to get code quality for all code fragments. Please, note that your input file should consist of at least 2 obligatory columns to run xlsx-tool on its code fragments: +This tool allows running the `Hyperstyle` tool on a xlsx or csv table to get code quality for all code fragments. +Please, note that your input file should consist of at least 2 obligatory columns to run the tool on its code fragments: - `code` - `lang` Possible values for column `lang` are: `python3`, `kotlin`, `java8`, `java11`. -Output file is a new `xlsx` file with 3 columns: +Output file is a new `xlsx` or `csv` file with 3 columns: - `code` - `lang` - `grade` -Grade assessment is conducted by [`run_tool.py`](https://github.com/hyperskill/hyperstyle/blob/main/README.md) with default arguments. Avaliable values for column `grade` are: BAD, MODERATE, GOOD, EXCELLENT. It is also possible add fourth column: `traceback` to get full inspectors feedback on each code fragment. More details on enabling traceback column in **Optional Arguments** table. +Grade assessment is conducted by [`run_tool.py`](https://github.com/hyperskill/hyperstyle/blob/main/README.md) with default arguments. + Avaliable values for column `grade` are: BAD, MODERATE, GOOD, EXCELLENT. + It is also possible add fourth column: `traceback` to get full inspectors feedback on each code fragment. + More details on enabling traceback column in **Optional Arguments** table. ## Usage -Run the [xlsx_run_tool.py](xlsx_run_tool.py) with the arguments from command line. +Run the [evaluation_run_tool.py](evaluation_run_tool.py) with the arguments from command line. Required arguments: -`xlsx_file_path` — path to xlsx-file with code samples to inspect. +`solutions_file_path` — path to xlsx-file with code samples to inspect. Optional arguments: Argument | Description @@ -28,4 +32,4 @@ Argument | Description |**‑tp**, **‑‑tool_path**| Path to run-tool. Default is `src/python/review/run_tool.py` .| |**‑tr**, **‑‑traceback**| To include a column with errors traceback into an output file. Default is `False`.| |**‑ofp**, **‑‑output_folder_path**| An explicit folder path to store file with results. Default is a parent directory of a folder with xlsx-file sent for inspection. | -|**‑ofn**, **‑‑output_file_name**| A name of an output file where evaluation results will be stored. Default is `results.xlsx`.| +|**‑ofn**, **‑‑output_file_name**| A name of an output file where evaluation results will be stored. Default is `results.xlsx` or `results.csv`.| diff --git a/src/python/evaluation/common/csv_util.py b/src/python/evaluation/common/csv_util.py new file mode 100644 index 00000000..ecfac1bb --- /dev/null +++ b/src/python/evaluation/common/csv_util.py @@ -0,0 +1,10 @@ +from pathlib import Path +from typing import Union + +import pandas as pd + +from src.python.review.common.file_system import Encoding + + +def write_dataframe_to_csv(csv_file_path: Union[str, Path], df: pd.DataFrame) -> None: + df.to_csv(csv_file_path, encoding=Encoding.ISO_ENCODING.value, index=False) \ No newline at end of file diff --git a/src/python/evaluation/common/util.py b/src/python/evaluation/common/util.py index c306d3b7..5ec5b2ed 100644 --- a/src/python/evaluation/common/util.py +++ b/src/python/evaluation/common/util.py @@ -16,11 +16,12 @@ class ColumnName(Enum): class EvaluationArgument(Enum): TRACEBACK = "traceback" RESULT_FILE_NAME = "results" - RESULT_FILE_NAME_EXT = f"{RESULT_FILE_NAME}{Extension.XLSX.value}" + RESULT_FILE_NAME_XLSX = f"{RESULT_FILE_NAME}{Extension.XLSX.value}" + RESULT_FILE_NAME_CSV = f"{RESULT_FILE_NAME}{Extension.CSV.value}" script_structure_rule = ("Please, make sure your XLSX-file matches following script standards: \n" - "1. Your XLSX-file should have 2 obligatory columns named:" + "1. Your XLSX-file or CSV-file should have 2 obligatory columns named:" f"'{ColumnName.CODE.value}' & '{ColumnName.LANG.value}'. \n" f"'{ColumnName.CODE.value}' column -- relates to the code-sample. \n" f"'{ColumnName.LANG.value}' column -- relates to the language of a " diff --git a/src/python/evaluation/evaluation_config.py b/src/python/evaluation/evaluation_config.py index 5cee71dc..e3f9d12a 100644 --- a/src/python/evaluation/evaluation_config.py +++ b/src/python/evaluation/evaluation_config.py @@ -6,7 +6,7 @@ from src.python.common.tool_arguments import RunToolArgument from src.python.evaluation.common.util import EvaluationArgument from src.python.review.application_config import LanguageVersion -from src.python.review.common.file_system import create_directory +from src.python.review.common.file_system import create_directory, Extension logger = logging.getLogger(__name__) @@ -15,10 +15,21 @@ class EvaluationConfig: def __init__(self, args: Namespace): self.tool_path: Union[str, Path] = args.tool_path self.output_format: str = args.format - self.xlsx_file_path: Union[str, Path] = args.xlsx_file_path + self.solutions_file_path: Union[str, Path] = args.solutions_file_path self.traceback: bool = args.traceback self.output_folder_path: Union[str, Path] = args.output_folder_path self.output_file_name: str = args.output_file_name + self.extension: Extension = self.__get_extension() + + def __get_extension(self) -> Extension: + if self.solutions_file_path is None: + return Extension.EMPTY + ext = Extension.get_extension_from_file(self.solutions_file_path) + available_values = [Extension.XLSX, Extension.CSV] + if ext not in available_values: + raise ValueError(f'Invalid extension. ' + f'Available values are: {list(map(lambda e: e.value, available_values))}.') + return ext def build_command(self, inspected_file_path: Union[str, Path], lang: str) -> List[str]: command = [LanguageVersion.PYTHON_3.value, @@ -34,10 +45,10 @@ def get_output_file_path(self) -> Path: if self.output_folder_path is None: try: self.output_folder_path = ( - Path(self.xlsx_file_path).parent.parent / EvaluationArgument.RESULT_FILE_NAME.value + Path(self.solutions_file_path).parent.parent / EvaluationArgument.RESULT_FILE_NAME.value ) create_directory(self.output_folder_path) except FileNotFoundError as e: - logger.error('XLSX-file with the specified name does not exists.') + logger.error('XLSX-file or CSV-file with the specified name does not exists.') raise e return Path(self.output_folder_path) / self.output_file_name diff --git a/src/python/evaluation/xlsx_run_tool.py b/src/python/evaluation/evaluation_run_tool.py similarity index 77% rename from src/python/evaluation/xlsx_run_tool.py rename to src/python/evaluation/evaluation_run_tool.py index b2200f19..158e5e72 100644 --- a/src/python/evaluation/xlsx_run_tool.py +++ b/src/python/evaluation/evaluation_run_tool.py @@ -7,6 +7,8 @@ from pathlib import Path from typing import Type +from src.python.evaluation.common.csv_util import write_dataframe_to_csv + sys.path.append('') sys.path.append('../../..') @@ -20,7 +22,7 @@ ) from src.python.evaluation.evaluation_config import EvaluationConfig from src.python.review.application_config import LanguageVersion -from src.python.review.common.file_system import create_file +from src.python.review.common.file_system import create_file, Extension from src.python.review.common.subprocess_runner import run_in_subprocess from src.python.review.reviewers.perform_review import OutputFormat @@ -28,10 +30,10 @@ def configure_arguments(parser: argparse.ArgumentParser, run_tool_arguments: Type[RunToolArgument]) -> None: - parser.add_argument('xlsx_file_path', + parser.add_argument('solutions_file_path', type=lambda value: Path(value).absolute(), - help='Local XLSX-file path. ' - 'Your XLSX-file must include column-names: ' + help='Local XLSX-file or CSV-file path. ' + 'Your file must include column-names: ' f'"{ColumnName.CODE.value}" and ' f'"{ColumnName.LANG.value}". Acceptable values for ' f'"{ColumnName.LANG.value}" column are: ' @@ -51,15 +53,15 @@ def configure_arguments(parser: argparse.ArgumentParser, run_tool_arguments: Typ parser.add_argument('-ofp', '--output-folder-path', help='An absolute path to the folder where file with evaluation results' 'will be stored.' - 'Default is the path to a directory, where is the folder with xlsx_file.', + 'Default is the path to a directory, where is the folder with xlsx or csv file.', # if None default path will be specified based on xlsx_file_path. default=None, type=str) parser.add_argument('-ofn', '--output-file-name', help='Filename for that will be created to store inspection results.' - f'Default is "{EvaluationArgument.RESULT_FILE_NAME_EXT.value}"', - default=f'{EvaluationArgument.RESULT_FILE_NAME_EXT.value}', + f'Default is "{EvaluationArgument.RESULT_FILE_NAME_XLSX.value}"', + default=f'{EvaluationArgument.RESULT_FILE_NAME_XLSX.value}', type=str) parser.add_argument(run_tool_arguments.FORMAT.value.short_name, @@ -81,7 +83,7 @@ def get_language(lang_key: str) -> LanguageVersion: raise KeyError(e) -def create_dataframe(config: EvaluationConfig) -> pd.DataFrame: +def inspect_solutions_df(config: EvaluationConfig, inspect_solutions_df: pd.DataFrame) -> pd.DataFrame: report = pd.DataFrame( { ColumnName.LANGUAGE.value: [], @@ -94,20 +96,13 @@ def create_dataframe(config: EvaluationConfig) -> pd.DataFrame: report[EvaluationArgument.TRACEBACK.value] = [] try: - lang_code_dataframe = pd.read_excel(config.xlsx_file_path) - - except FileNotFoundError as e: - logger.error('XLSX-file with the specified name does not exists.') - raise e - - try: - for lang, code in zip(lang_code_dataframe[ColumnName.LANG.value], - lang_code_dataframe[ColumnName.CODE.value]): + for lang, code in zip(inspect_solutions_df[ColumnName.LANG.value], + inspect_solutions_df[ColumnName.CODE.value]): # Tool does not work correctly with tmp files from module on macOS # thus we create a real file in the file system extension = get_language(lang).extension_by_language().value - tmp_file_path = config.xlsx_file_path.parent.absolute() / f'inspected_code{extension}' + tmp_file_path = config.solutions_file_path.parent.absolute() / f'inspected_code{extension}' temp_file = next(create_file(tmp_file_path, code)) command = config.build_command(temp_file, lang) @@ -140,6 +135,29 @@ def create_dataframe(config: EvaluationConfig) -> pd.DataFrame: raise e +def get_solutions_df(config: EvaluationConfig) -> pd.DataFrame: + try: + if config.extension == Extension.XLSX: + lang_code_dataframe = pd.read_excel(config.solutions_file_path) + else: + lang_code_dataframe = pd.read_csv(config.solutions_file_path) + except FileNotFoundError as e: + logger.error('XLSX-file or CSV-file with the specified name does not exists.') + raise e + + return lang_code_dataframe + + +def write_df_to_file(df: pd.DataFrame, config: EvaluationConfig) -> None: + if config.extension == Extension.CSV: + write_dataframe_to_csv(config.get_output_file_path(), df) + elif config.extension == Extension.XLSX: + workbook_path = create_and_get_workbook_path(config) + write_dataframe_to_xlsx_sheet(workbook_path, df, 'inspection_results') + # remove empty sheet that was initially created with the workbook + remove_sheet(workbook_path, 'Sheet') + + def main() -> int: parser = argparse.ArgumentParser() configure_arguments(parser, RunToolArgument) @@ -147,15 +165,13 @@ def main() -> int: try: args = parser.parse_args() config = EvaluationConfig(args) - workbook_path = create_and_get_workbook_path(config) - results = create_dataframe(config) - write_dataframe_to_xlsx_sheet(workbook_path, results, 'inspection_results') - # remove empty sheet that was initially created with the workbook - remove_sheet(workbook_path, 'Sheet') + lang_code_dataframe = get_solutions_df(config) + results = inspect_solutions_df(config, lang_code_dataframe) + write_df_to_file(results, config) return 0 except FileNotFoundError: - logger.error('XLSX-file with the specified name does not exists.') + logger.error('XLSX-file or CSV-file with the specified name does not exists.') return 2 except KeyError: diff --git a/src/python/review/common/file_system.py b/src/python/review/common/file_system.py index d0b3dca0..12183ab0 100644 --- a/src/python/review/common/file_system.py +++ b/src/python/review/common/file_system.py @@ -30,6 +30,13 @@ class Extension(Enum): JS = '.js' KTS = '.kts' XLSX = '.xlsx' + CSV = '.csv' + + # Not empty extensions are returned with a dot, for example, '.txt' + # If file has no extensions, an empty one ('') is returned + @classmethod + def get_extension_from_file(cls, file: str) -> 'Extension': + return Extension(os.path.splitext(file)[1]) ItemCondition = Callable[[str], bool] diff --git a/test/python/evaluation/test_data_path.py b/test/python/evaluation/test_data_path.py index 0d8e3502..9ab19b5b 100644 --- a/test/python/evaluation/test_data_path.py +++ b/test/python/evaluation/test_data_path.py @@ -3,12 +3,13 @@ import pytest from src.python.evaluation.evaluation_config import EvaluationConfig -from src.python.evaluation.xlsx_run_tool import create_dataframe +from src.python.evaluation.evaluation_run_tool import inspect_solutions_df, get_solutions_df def test_incorrect_data_path(): with pytest.raises(FileNotFoundError): testing_arguments_dict = get_testing_arguments(to_add_traceback=True, to_add_tool_path=True) - testing_arguments_dict.xlsx_file_path = XLSX_DATA_FOLDER / 'do_not_exist.xlsx' + testing_arguments_dict.solutions_file_path = XLSX_DATA_FOLDER / 'do_not_exist.xlsx' config = EvaluationConfig(testing_arguments_dict) - assert create_dataframe(config) + lang_code_dataframe = get_solutions_df(config) + assert inspect_solutions_df(config, lang_code_dataframe) diff --git a/test/python/evaluation/test_output_results.py b/test/python/evaluation/test_output_results.py index 519652e5..7ed3c730 100644 --- a/test/python/evaluation/test_output_results.py +++ b/test/python/evaluation/test_output_results.py @@ -4,7 +4,7 @@ import pandas as pd import pytest from src.python.evaluation.evaluation_config import EvaluationConfig -from src.python.evaluation.xlsx_run_tool import create_dataframe +from src.python.evaluation.evaluation_run_tool import inspect_solutions_df, get_solutions_df FILE_NAMES = [ ('test_sorted_order.xlsx', 'target_sorted_order.xlsx', False), @@ -18,11 +18,12 @@ def test_correct_output(test_file: str, target_file: str, output_type: bool): testing_arguments_dict = get_testing_arguments(to_add_tool_path=True) - testing_arguments_dict.xlsx_file_path = XLSX_DATA_FOLDER / test_file + testing_arguments_dict.solutions_file_path = XLSX_DATA_FOLDER / test_file testing_arguments_dict.traceback = output_type config = EvaluationConfig(testing_arguments_dict) - test_dataframe = create_dataframe(config) + lang_code_dataframe = get_solutions_df(config) + test_dataframe = inspect_solutions_df(config, lang_code_dataframe) sheet_name = 'grades' if output_type: diff --git a/test/python/evaluation/test_tool_path.py b/test/python/evaluation/test_tool_path.py index 0581caad..e0cb12b7 100644 --- a/test/python/evaluation/test_tool_path.py +++ b/test/python/evaluation/test_tool_path.py @@ -4,15 +4,16 @@ import pytest from src.python import MAIN_FOLDER from src.python.evaluation.evaluation_config import EvaluationConfig -from src.python.evaluation.xlsx_run_tool import create_dataframe +from src.python.evaluation.evaluation_run_tool import inspect_solutions_df, get_solutions_df def test_correct_tool_path(): try: testing_arguments_dict = get_testing_arguments(to_add_traceback=True, to_add_tool_path=True) - testing_arguments_dict.xlsx_file_path = XLSX_DATA_FOLDER / 'test_unsorted_order.xlsx' + testing_arguments_dict.solutions_file_path = XLSX_DATA_FOLDER / 'test_unsorted_order.xlsx' config = EvaluationConfig(testing_arguments_dict) - create_dataframe(config) + lang_code_dataframe = get_solutions_df(config) + inspect_solutions_df(config, lang_code_dataframe) except Exception: pytest.fail("Unexpected error") @@ -20,7 +21,8 @@ def test_correct_tool_path(): def test_incorrect_tool_path(): with pytest.raises(Exception): testing_arguments_dict = get_testing_arguments(to_add_traceback=True) - testing_arguments_dict.xlsx_file_path = XLSX_DATA_FOLDER / 'test_unsorted_order.xlsx' + testing_arguments_dict.solutions_file_path = XLSX_DATA_FOLDER / 'test_unsorted_order.xlsx' testing_arguments_dict.tool_path = MAIN_FOLDER.parent / 'review/incorrect_path.py' config = EvaluationConfig(testing_arguments_dict) - assert create_dataframe(config) + lang_code_dataframe = get_solutions_df(config) + assert inspect_solutions_df(config, lang_code_dataframe) diff --git a/test/python/evaluation/test_xlsx_file_structure.py b/test/python/evaluation/test_xlsx_file_structure.py index 9965992e..5230f2c9 100644 --- a/test/python/evaluation/test_xlsx_file_structure.py +++ b/test/python/evaluation/test_xlsx_file_structure.py @@ -3,8 +3,7 @@ import pytest from src.python.evaluation.evaluation_config import EvaluationConfig -from src.python.evaluation.xlsx_run_tool import create_dataframe - +from src.python.evaluation.evaluation_run_tool import inspect_solutions_df, get_solutions_df FILE_NAMES = [ 'test_wrong_column_name.xlsx', @@ -18,6 +17,7 @@ def test_wrong_column(file_name: str): with pytest.raises(KeyError): testing_arguments_dict = get_testing_arguments(to_add_traceback=True, to_add_tool_path=True) - testing_arguments_dict.xlsx_file_path = XLSX_DATA_FOLDER / file_name + testing_arguments_dict.solutions_file_path = XLSX_DATA_FOLDER / file_name config = EvaluationConfig(testing_arguments_dict) - assert create_dataframe(config) + lang_code_dataframe = get_solutions_df(config) + assert inspect_solutions_df(config, lang_code_dataframe) diff --git a/test/python/evaluation/testing_config.py b/test/python/evaluation/testing_config.py index 1e8fc5a9..8d144534 100644 --- a/test/python/evaluation/testing_config.py +++ b/test/python/evaluation/testing_config.py @@ -7,7 +7,7 @@ def get_testing_arguments(to_add_traceback=None, to_add_tool_path=None) -> Namespace: testing_arguments = Namespace(format=OutputFormat.JSON.value, - output_file_name=EvaluationArgument.RESULT_FILE_NAME_EXT.value, + output_file_name=EvaluationArgument.RESULT_FILE_NAME_XLSX.value, output_folder_path=None) if to_add_traceback: testing_arguments.traceback = True @@ -15,6 +15,6 @@ def get_testing_arguments(to_add_traceback=None, to_add_tool_path=None) -> Names if to_add_tool_path: testing_arguments.tool_path = MAIN_FOLDER.parent / 'review/run_tool.py' - testing_arguments.xlsx_file_path = None + testing_arguments.solutions_file_path = None return testing_arguments From 136ed890f70388a72db75df13d256a33ef9582d2 Mon Sep 17 00:00:00 2001 From: "Anastasiia.Birillo" Date: Mon, 17 May 2021 16:23:16 +0300 Subject: [PATCH 3/9] Filter solutions by language --- src/python/common/tool_arguments.py | 10 +++ src/python/evaluation/common/csv_util.py | 8 ++- src/python/evaluation/common/pandas_util.py | 41 +++++++++++++ src/python/evaluation/common/util.py | 1 + src/python/evaluation/common/xlsx_util.py | 7 +-- src/python/evaluation/evaluation_config.py | 19 ++---- src/python/evaluation/evaluation_run_tool.py | 52 ++++------------ .../evaluation/inspectors/filter_solutions.py | 57 +++++++++++++++++ src/python/review/common/file_system.py | 32 +++++++++- src/python/review/run_tool.py | 61 +++++++++---------- 10 files changed, 195 insertions(+), 93 deletions(-) create mode 100644 src/python/evaluation/common/pandas_util.py create mode 100644 src/python/evaluation/inspectors/filter_solutions.py diff --git a/src/python/common/tool_arguments.py b/src/python/common/tool_arguments.py index 5038653b..b285ac23 100644 --- a/src/python/common/tool_arguments.py +++ b/src/python/common/tool_arguments.py @@ -2,6 +2,7 @@ from enum import Enum, unique from typing import List, Optional +from src.python.evaluation.common.util import ColumnName from src.python.review.application_config import LanguageVersion from src.python.review.inspectors.inspector_type import InspectorType @@ -76,3 +77,12 @@ class RunToolArgument(Enum): HISTORY = ArgumentsInfo(None, '--history', 'Json string, which contains lists of issues in the previous submissions ' 'for other tasks for one user.') + + SOLUTIONS_FILE_PATH = ArgumentsInfo(None, 'solutions_file_path', + 'Local XLSX-file or CSV-file path. ' + 'Your file must include column-names: ' + f'"{ColumnName.CODE.value}" and ' + f'"{ColumnName.LANG.value}". Acceptable values for ' + f'"{ColumnName.LANG.value}" column are: ' + f'{LanguageVersion.PYTHON_3.value}, {LanguageVersion.JAVA_8.value}, ' + f'{LanguageVersion.JAVA_11.value}, {LanguageVersion.KOTLIN.value}.') diff --git a/src/python/evaluation/common/csv_util.py b/src/python/evaluation/common/csv_util.py index ecfac1bb..052184a4 100644 --- a/src/python/evaluation/common/csv_util.py +++ b/src/python/evaluation/common/csv_util.py @@ -7,4 +7,10 @@ def write_dataframe_to_csv(csv_file_path: Union[str, Path], df: pd.DataFrame) -> None: - df.to_csv(csv_file_path, encoding=Encoding.ISO_ENCODING.value, index=False) \ No newline at end of file + # Get error with this encoding=ENCODING on several fragments: + # "UnicodeEncodeError: 'latin-1' codec can't encode character '\u1ea3' in position 41: ordinal not in range(256) + # So change it then to 'utf-8' + try: + df.to_csv(csv_file_path, encoding=Encoding.ISO_ENCODING.value, index=False) + except UnicodeEncodeError: + df.to_csv(csv_file_path, encoding=Encoding.UTF_ENCODING.value, index=False) \ No newline at end of file diff --git a/src/python/evaluation/common/pandas_util.py b/src/python/evaluation/common/pandas_util.py new file mode 100644 index 00000000..7969566f --- /dev/null +++ b/src/python/evaluation/common/pandas_util.py @@ -0,0 +1,41 @@ +import logging +from pathlib import Path +from typing import List, Union, Set + +import pandas as pd + +from src.python.evaluation.common.csv_util import write_dataframe_to_csv +from src.python.evaluation.common.util import ColumnName +from src.python.evaluation.common.xlsx_util import create_workbook, write_dataframe_to_xlsx_sheet, remove_sheet +from src.python.review.application_config import LanguageVersion +from src.python.review.common.file_system import Extension + +logger = logging.getLogger(__name__) + + +def filter_df_by_language(df: pd.DataFrame, languages: Set[LanguageVersion], + column: str = ColumnName.LANG.value) -> pd.DataFrame: + return df.loc[df[column].isin(set(map(lambda l: l.value, languages)))] + + +def get_solutions_df(ext: Extension, file_path: Union[str, Path]) -> pd.DataFrame: + try: + if ext == Extension.XLSX: + lang_code_dataframe = pd.read_excel(file_path) + else: + lang_code_dataframe = pd.read_csv(file_path) + except FileNotFoundError as e: + logger.error('XLSX-file or CSV-file with the specified name does not exists.') + raise e + + return lang_code_dataframe + + +def write_df_to_file(df: pd.DataFrame, output_file_path: Path, extension: Extension) -> None: + if extension == Extension.CSV: + write_dataframe_to_csv(output_file_path, df) + elif extension == Extension.XLSX: + create_workbook(output_file_path) + write_dataframe_to_xlsx_sheet(output_file_path, df, 'inspection_results') + # remove empty sheet that was initially created with the workbook + remove_sheet(output_file_path, 'Sheet') diff --git a/src/python/evaluation/common/util.py b/src/python/evaluation/common/util.py index 5ec5b2ed..d540d903 100644 --- a/src/python/evaluation/common/util.py +++ b/src/python/evaluation/common/util.py @@ -33,3 +33,4 @@ class EvaluationArgument(Enum): f"Acceptable language-names are: {LanguageVersion.PYTHON_3.value}, " f"{LanguageVersion.JAVA_8.value} ," f"{LanguageVersion.JAVA_11.value} and {LanguageVersion.KOTLIN.value}.") + diff --git a/src/python/evaluation/common/xlsx_util.py b/src/python/evaluation/common/xlsx_util.py index 032a5ce6..c6630717 100644 --- a/src/python/evaluation/common/xlsx_util.py +++ b/src/python/evaluation/common/xlsx_util.py @@ -24,11 +24,10 @@ def remove_sheet(workbook_path: Union[str, Path], sheet_name: str, to_raise_erro logger.info(message) -def create_and_get_workbook_path(config: EvaluationConfig) -> Path: +def create_workbook(output_file_path: Path) -> Workbook: workbook = Workbook() - workbook_path = config.get_output_file_path() - workbook.save(workbook_path) - return workbook_path + workbook.save(output_file_path) + return workbook def write_dataframe_to_xlsx_sheet(xlsx_file_path: Union[str, Path], df: pd.DataFrame, sheet_name: str, diff --git a/src/python/evaluation/evaluation_config.py b/src/python/evaluation/evaluation_config.py index e3f9d12a..692c44e2 100644 --- a/src/python/evaluation/evaluation_config.py +++ b/src/python/evaluation/evaluation_config.py @@ -6,7 +6,8 @@ from src.python.common.tool_arguments import RunToolArgument from src.python.evaluation.common.util import EvaluationArgument from src.python.review.application_config import LanguageVersion -from src.python.review.common.file_system import create_directory, Extension +from src.python.review.common.file_system import create_directory, Extension, get_restricted_extension, \ + get_parent_folder logger = logging.getLogger(__name__) @@ -19,17 +20,7 @@ def __init__(self, args: Namespace): self.traceback: bool = args.traceback self.output_folder_path: Union[str, Path] = args.output_folder_path self.output_file_name: str = args.output_file_name - self.extension: Extension = self.__get_extension() - - def __get_extension(self) -> Extension: - if self.solutions_file_path is None: - return Extension.EMPTY - ext = Extension.get_extension_from_file(self.solutions_file_path) - available_values = [Extension.XLSX, Extension.CSV] - if ext not in available_values: - raise ValueError(f'Invalid extension. ' - f'Available values are: {list(map(lambda e: e.value, available_values))}.') - return ext + self.extension: Extension = get_restricted_extension(self.solutions_file_path, [Extension.XLSX, Extension.CSV]) def build_command(self, inspected_file_path: Union[str, Path], lang: str) -> List[str]: command = [LanguageVersion.PYTHON_3.value, @@ -44,9 +35,7 @@ def build_command(self, inspected_file_path: Union[str, Path], lang: str) -> Lis def get_output_file_path(self) -> Path: if self.output_folder_path is None: try: - self.output_folder_path = ( - Path(self.solutions_file_path).parent.parent / EvaluationArgument.RESULT_FILE_NAME.value - ) + self.output_folder_path = get_parent_folder(Path(self.solutions_file_path)) create_directory(self.output_folder_path) except FileNotFoundError as e: logger.error('XLSX-file or CSV-file with the specified name does not exists.') diff --git a/src/python/evaluation/evaluation_run_tool.py b/src/python/evaluation/evaluation_run_tool.py index 158e5e72..7f31d35d 100644 --- a/src/python/evaluation/evaluation_run_tool.py +++ b/src/python/evaluation/evaluation_run_tool.py @@ -8,6 +8,7 @@ from typing import Type from src.python.evaluation.common.csv_util import write_dataframe_to_csv +from src.python.evaluation.common.pandas_util import get_solutions_df, write_df_to_file sys.path.append('') sys.path.append('../../..') @@ -16,7 +17,7 @@ from src.python.common.tool_arguments import RunToolArgument from src.python.evaluation.common.util import ColumnName, EvaluationArgument, script_structure_rule from src.python.evaluation.common.xlsx_util import ( - create_and_get_workbook_path, + create_workbook, remove_sheet, write_dataframe_to_xlsx_sheet, ) @@ -29,16 +30,10 @@ logger = logging.getLogger(__name__) -def configure_arguments(parser: argparse.ArgumentParser, run_tool_arguments: Type[RunToolArgument]) -> None: - parser.add_argument('solutions_file_path', +def configure_arguments(parser: argparse.ArgumentParser) -> None: + parser.add_argument(RunToolArgument.SOLUTIONS_FILE_PATH.value.long_name, type=lambda value: Path(value).absolute(), - help='Local XLSX-file or CSV-file path. ' - 'Your file must include column-names: ' - f'"{ColumnName.CODE.value}" and ' - f'"{ColumnName.LANG.value}". Acceptable values for ' - f'"{ColumnName.LANG.value}" column are: ' - f'{LanguageVersion.PYTHON_3.value}, {LanguageVersion.JAVA_8.value}, ' - f'{LanguageVersion.JAVA_11.value}, {LanguageVersion.KOTLIN.value}.') + help=RunToolArgument.SOLUTIONS_FILE_PATH.value.description) parser.add_argument('-tp', '--tool-path', default=Path('src/python/review/run_tool.py').absolute(), @@ -54,7 +49,7 @@ def configure_arguments(parser: argparse.ArgumentParser, run_tool_arguments: Typ help='An absolute path to the folder where file with evaluation results' 'will be stored.' 'Default is the path to a directory, where is the folder with xlsx or csv file.', - # if None default path will be specified based on xlsx_file_path. + # if None default path will be specified based on solutions_file_path. default=None, type=str) @@ -64,12 +59,12 @@ def configure_arguments(parser: argparse.ArgumentParser, run_tool_arguments: Typ default=f'{EvaluationArgument.RESULT_FILE_NAME_XLSX.value}', type=str) - parser.add_argument(run_tool_arguments.FORMAT.value.short_name, - run_tool_arguments.FORMAT.value.long_name, + parser.add_argument(RunToolArgument.FORMAT.value.short_name, + RunToolArgument.FORMAT.value.long_name, default=OutputFormat.JSON.value, choices=OutputFormat.values(), type=str, - help=f'{run_tool_arguments.FORMAT.value.description}' + help=f'{RunToolArgument.FORMAT.value.description}' f'Use this argument when {EvaluationArgument.TRACEBACK.value} argument' 'is enabled argument will not be used otherwise.') @@ -135,39 +130,16 @@ def inspect_solutions_df(config: EvaluationConfig, inspect_solutions_df: pd.Data raise e -def get_solutions_df(config: EvaluationConfig) -> pd.DataFrame: - try: - if config.extension == Extension.XLSX: - lang_code_dataframe = pd.read_excel(config.solutions_file_path) - else: - lang_code_dataframe = pd.read_csv(config.solutions_file_path) - except FileNotFoundError as e: - logger.error('XLSX-file or CSV-file with the specified name does not exists.') - raise e - - return lang_code_dataframe - - -def write_df_to_file(df: pd.DataFrame, config: EvaluationConfig) -> None: - if config.extension == Extension.CSV: - write_dataframe_to_csv(config.get_output_file_path(), df) - elif config.extension == Extension.XLSX: - workbook_path = create_and_get_workbook_path(config) - write_dataframe_to_xlsx_sheet(workbook_path, df, 'inspection_results') - # remove empty sheet that was initially created with the workbook - remove_sheet(workbook_path, 'Sheet') - - def main() -> int: parser = argparse.ArgumentParser() - configure_arguments(parser, RunToolArgument) + configure_arguments(parser) try: args = parser.parse_args() config = EvaluationConfig(args) - lang_code_dataframe = get_solutions_df(config) + lang_code_dataframe = get_solutions_df(config.extension, config.solutions_file_path) results = inspect_solutions_df(config, lang_code_dataframe) - write_df_to_file(results, config) + write_df_to_file(results, config.get_output_file_path(), config.extension) return 0 except FileNotFoundError: diff --git a/src/python/evaluation/inspectors/filter_solutions.py b/src/python/evaluation/inspectors/filter_solutions.py new file mode 100644 index 00000000..33956fcb --- /dev/null +++ b/src/python/evaluation/inspectors/filter_solutions.py @@ -0,0 +1,57 @@ +import argparse +import logging +import sys +from pathlib import Path +from typing import Set + +from src.python.common.tool_arguments import RunToolArgument +from src.python.evaluation.common.pandas_util import get_solutions_df, filter_df_by_language, write_df_to_file +from src.python.review.application_config import LanguageVersion +from src.python.review.common.file_system import get_restricted_extension, Extension, get_parent_folder + +logger = logging.getLogger(__name__) + + +def parse_languages(value: str) -> Set[LanguageVersion]: + passed_names = value.lower().split(',') + allowed_names = {lang.value for lang in LanguageVersion} + if not all(name in allowed_names for name in passed_names): + raise argparse.ArgumentError('--languages', 'Incorrect --languages\' names') + + return {LanguageVersion(name) for name in passed_names} + + +def configure_arguments(parser: argparse.ArgumentParser) -> None: + parser.add_argument(RunToolArgument.SOLUTIONS_FILE_PATH.value.long_name, + type=lambda value: Path(value).absolute(), + help=RunToolArgument.SOLUTIONS_FILE_PATH.value.description) + + parser.add_argument('-l', '--languages', + help='Set of languages to keep in the dataset', + type=parse_languages, + default=set(LanguageVersion)) + + +# TODO: add readme +def main() -> int: + try: + parser = argparse.ArgumentParser() + configure_arguments(parser) + args = parser.parse_args() + + solutions_file_path = args.solutions_file_path + ext = get_restricted_extension(solutions_file_path, [Extension.XLSX, Extension.CSV]) + solutions_df = get_solutions_df(ext, args.solutions_file_path) + + filtered_df = filter_df_by_language(solutions_df, args.languages) + output_path = get_parent_folder(Path(solutions_file_path)) + write_df_to_file(filtered_df, output_path / f'filtered_solutions{ext.value}', ext) + return 0 + + except Exception: + logger.exception('An unexpected error.') + return 2 + + +if __name__ == '__main__': + sys.exit(main()) \ No newline at end of file diff --git a/src/python/review/common/file_system.py b/src/python/review/common/file_system.py index 12183ab0..6531a022 100644 --- a/src/python/review/common/file_system.py +++ b/src/python/review/common/file_system.py @@ -4,7 +4,7 @@ from contextlib import contextmanager from enum import Enum, unique from pathlib import Path -from typing import Callable, List, Tuple, Union +from typing import Callable, List, Tuple, Union, Optional @unique @@ -83,7 +83,7 @@ def create_file(file_path: Union[str, Path], content: str): yield Path(file_path) -def create_directory(directory: str) -> None: +def create_directory(directory: Union[str, Path]) -> None: os.makedirs(directory, exist_ok=True) @@ -105,3 +105,31 @@ def get_content_from_file(file_path: Path, encoding: str = Encoding.ISO_ENCODING # If file has no extensions, an empty one ('') is returned def get_extension_from_file(file: Path) -> Extension: return Extension(os.path.splitext(file)[1]) + + +def get_restricted_extension(file_path: Optional[Union[str, Path]] = None, available_values: List[Extension] = None) -> Extension: + if file_path is None: + return Extension.EMPTY + ext = Extension.get_extension_from_file(file_path) + if available_values is not None and ext not in available_values: + raise ValueError(f'Invalid extension. ' + f'Available values are: {list(map(lambda e: e.value, available_values))}.') + return ext + + +def remove_slash(path: str) -> str: + return path.rstrip('/') + + +def add_slash(path: str) -> str: + if not path.endswith('/'): + path += '/' + return path + + +def get_parent_folder(path: Path, to_add_slash: bool = False) -> Path: + path = remove_slash(str(path)) + parent_folder = '/'.join(path.split('/')[:-1]) + if to_add_slash: + parent_folder = add_slash(parent_folder) + return Path(parent_folder) diff --git a/src/python/review/run_tool.py b/src/python/review/run_tool.py index fc74774d..13d45ce7 100644 --- a/src/python/review/run_tool.py +++ b/src/python/review/run_tool.py @@ -6,8 +6,7 @@ import traceback from json import JSONDecodeError from pathlib import Path -from typing import Set - +from typing import Set, Type sys.path.append('') sys.path.append('../../..') @@ -43,69 +42,69 @@ def positive_int(value: str) -> int: return value_int -def configure_arguments(parser: argparse.ArgumentParser, tool_arguments: enum.EnumMeta) -> None: - parser.add_argument(tool_arguments.VERBOSITY.value.short_name, - tool_arguments.VERBOSITY.value.long_name, - help=tool_arguments.VERBOSITY.value.description, +def configure_arguments(parser: argparse.ArgumentParser) -> None: + parser.add_argument(RunToolArgument.VERBOSITY.value.short_name, + RunToolArgument.VERBOSITY.value.long_name, + help=RunToolArgument.VERBOSITY.value.description, default=VerbosityLevel.DISABLE.value, choices=VerbosityLevel.values(), type=str) # Usage example: -d Flake8,Intelli - parser.add_argument(tool_arguments.DISABLE.value.short_name, - tool_arguments.DISABLE.value.long_name, - help=tool_arguments.DISABLE.value.description, + parser.add_argument(RunToolArgument.DISABLE.value.short_name, + RunToolArgument.DISABLE.value.long_name, + help=RunToolArgument.DISABLE.value.description, type=parse_disabled_inspectors, default=set()) - parser.add_argument(tool_arguments.DUPLICATES.value.long_name, + parser.add_argument(RunToolArgument.DUPLICATES.value.long_name, action='store_true', - help=tool_arguments.DUPLICATES.value.description) + help=RunToolArgument.DUPLICATES.value.description) # TODO: deprecated argument: language_version. Delete after several releases. parser.add_argument('--language_version', - tool_arguments.LANG_VERSION.value.long_name, - help=tool_arguments.LANG_VERSION.value.description, + RunToolArgument.LANG_VERSION.value.long_name, + help=RunToolArgument.LANG_VERSION.value.description, default=None, choices=LanguageVersion.values(), type=str) # TODO: deprecated argument: --n_cpu. Delete after several releases. parser.add_argument('--n_cpu', - tool_arguments.CPU.value.long_name, - help=tool_arguments.CPU.value.description, + RunToolArgument.CPU.value.long_name, + help=RunToolArgument.CPU.value.description, default=1, type=positive_int) - parser.add_argument(tool_arguments.PATH.value.long_name, + parser.add_argument(RunToolArgument.PATH.value.long_name, type=lambda value: Path(value).absolute(), - help=tool_arguments.PATH.value.description) + help=RunToolArgument.PATH.value.description) - parser.add_argument(tool_arguments.FORMAT.value.short_name, - tool_arguments.FORMAT.value.long_name, + parser.add_argument(RunToolArgument.FORMAT.value.short_name, + RunToolArgument.FORMAT.value.long_name, default=OutputFormat.JSON.value, choices=OutputFormat.values(), type=str, - help=tool_arguments.FORMAT.value.description) + help=RunToolArgument.FORMAT.value.description) - parser.add_argument(tool_arguments.START_LINE.value.short_name, - tool_arguments.START_LINE.value.long_name, + parser.add_argument(RunToolArgument.START_LINE.value.short_name, + RunToolArgument.START_LINE.value.long_name, default=1, type=positive_int, - help=tool_arguments.START_LINE.value.description) + help=RunToolArgument.START_LINE.value.description) - parser.add_argument(tool_arguments.END_LINE.value.short_name, - tool_arguments.END_LINE.value.long_name, + parser.add_argument(RunToolArgument.END_LINE.value.short_name, + RunToolArgument.END_LINE.value.long_name, default=None, type=positive_int, - help=tool_arguments.END_LINE.value.description) + help=RunToolArgument.END_LINE.value.description) - parser.add_argument(tool_arguments.NEW_FORMAT.value.long_name, + parser.add_argument(RunToolArgument.NEW_FORMAT.value.long_name, action='store_true', - help=tool_arguments.NEW_FORMAT.value.description) + help=RunToolArgument.NEW_FORMAT.value.description) - parser.add_argument(tool_arguments.HISTORY.value.long_name, - help=tool_arguments.HISTORY.value.description, + parser.add_argument(RunToolArgument.HISTORY.value.long_name, + help=RunToolArgument.HISTORY.value.description, type=str) @@ -124,7 +123,7 @@ def configure_logging(verbosity: VerbosityLevel) -> None: def main() -> int: parser = argparse.ArgumentParser() - configure_arguments(parser, RunToolArgument) + configure_arguments(parser) try: args = parser.parse_args() From 78e7201bfc343aef65ddf6cbc3bff315016cc5c6 Mon Sep 17 00:00:00 2001 From: "Anastasiia.Birillo" Date: Tue, 18 May 2021 08:12:38 +0300 Subject: [PATCH 4/9] Optimize evaluation script, fix tests --- requirements-evaluation.txt | 3 +- src/python/evaluation/README.md | 4 +- src/python/evaluation/common/csv_util.py | 7 +- src/python/evaluation/common/pandas_util.py | 9 +- src/python/evaluation/common/util.py | 4 +- src/python/evaluation/common/xlsx_util.py | 1 - src/python/evaluation/evaluation_config.py | 18 +++- src/python/evaluation/evaluation_run_tool.py | 96 +++++++++--------- src/python/evaluation/inspectors/__init__.py | 0 .../evaluation/inspectors/filter_solutions.py | 17 +++- src/python/evaluation/statistics/__init__.py | 0 src/python/review/common/file_system.py | 5 +- src/python/review/run_tool.py | 3 +- test/python/evaluation/test_data_path.py | 4 +- test/python/evaluation/test_output_results.py | 6 +- test/python/evaluation/test_tool_path.py | 6 +- .../evaluation/test_xlsx_file_structure.py | 4 +- .../target_sorted_order.xlsx | Bin 44906 -> 88236 bytes .../target_unsorted_order.xlsx | Bin 44509 -> 88077 bytes whitelist.txt | 5 +- 20 files changed, 106 insertions(+), 86 deletions(-) create mode 100644 src/python/evaluation/inspectors/__init__.py create mode 100644 src/python/evaluation/statistics/__init__.py diff --git a/requirements-evaluation.txt b/requirements-evaluation.txt index 11910373..8df291f4 100644 --- a/requirements-evaluation.txt +++ b/requirements-evaluation.txt @@ -1,2 +1,3 @@ openpyxl==3.0.7 -pandas==1.2.3 \ No newline at end of file +pandas==1.2.3 +pandarallel \ No newline at end of file diff --git a/src/python/evaluation/README.md b/src/python/evaluation/README.md index 356c6a3d..1198bf3a 100644 --- a/src/python/evaluation/README.md +++ b/src/python/evaluation/README.md @@ -30,6 +30,6 @@ Argument | Description --- | --- |**‑f**, **‑‑format**| The output format. Available values: `json`, `text`. The default value is `json` . Use this argument when `traceback` is enabled, otherwise it will not be used.| |**‑tp**, **‑‑tool_path**| Path to run-tool. Default is `src/python/review/run_tool.py` .| -|**‑tr**, **‑‑traceback**| To include a column with errors traceback into an output file. Default is `False`.| -|**‑ofp**, **‑‑output_folder_path**| An explicit folder path to store file with results. Default is a parent directory of a folder with xlsx-file sent for inspection. | +|**‑‑traceback**| To include a column with errors traceback into an output file. Default is `False`.| +|**‑ofp**, **‑‑output_folder_path**| An explicit folder path to store file with results. Default is a parent directory of a folder with xlsx-file or csv-file sent for inspection. | |**‑ofn**, **‑‑output_file_name**| A name of an output file where evaluation results will be stored. Default is `results.xlsx` or `results.csv`.| diff --git a/src/python/evaluation/common/csv_util.py b/src/python/evaluation/common/csv_util.py index 052184a4..c2956e57 100644 --- a/src/python/evaluation/common/csv_util.py +++ b/src/python/evaluation/common/csv_util.py @@ -2,15 +2,12 @@ from typing import Union import pandas as pd - from src.python.review.common.file_system import Encoding def write_dataframe_to_csv(csv_file_path: Union[str, Path], df: pd.DataFrame) -> None: - # Get error with this encoding=ENCODING on several fragments: - # "UnicodeEncodeError: 'latin-1' codec can't encode character '\u1ea3' in position 41: ordinal not in range(256) - # So change it then to 'utf-8' + # Get error with this encoding=ENCODING on several fragments. So change it then to 'utf8' try: df.to_csv(csv_file_path, encoding=Encoding.ISO_ENCODING.value, index=False) except UnicodeEncodeError: - df.to_csv(csv_file_path, encoding=Encoding.UTF_ENCODING.value, index=False) \ No newline at end of file + df.to_csv(csv_file_path, encoding=Encoding.UTF_ENCODING.value, index=False) diff --git a/src/python/evaluation/common/pandas_util.py b/src/python/evaluation/common/pandas_util.py index 7969566f..8d627ead 100644 --- a/src/python/evaluation/common/pandas_util.py +++ b/src/python/evaluation/common/pandas_util.py @@ -1,12 +1,11 @@ import logging from pathlib import Path -from typing import List, Union, Set +from typing import Set, Union import pandas as pd - from src.python.evaluation.common.csv_util import write_dataframe_to_csv from src.python.evaluation.common.util import ColumnName -from src.python.evaluation.common.xlsx_util import create_workbook, write_dataframe_to_xlsx_sheet, remove_sheet +from src.python.evaluation.common.xlsx_util import create_workbook, remove_sheet, write_dataframe_to_xlsx_sheet from src.python.review.application_config import LanguageVersion from src.python.review.common.file_system import Extension @@ -18,6 +17,10 @@ def filter_df_by_language(df: pd.DataFrame, languages: Set[LanguageVersion], return df.loc[df[column].isin(set(map(lambda l: l.value, languages)))] +def drop_duplicates(df: pd.DataFrame, column: str = ColumnName.CODE.value) -> pd.DataFrame: + return df.drop_duplicates(column, keep='last') + + def get_solutions_df(ext: Extension, file_path: Union[str, Path]) -> pd.DataFrame: try: if ext == Extension.XLSX: diff --git a/src/python/evaluation/common/util.py b/src/python/evaluation/common/util.py index d540d903..6ec0da59 100644 --- a/src/python/evaluation/common/util.py +++ b/src/python/evaluation/common/util.py @@ -10,12 +10,13 @@ class ColumnName(Enum): LANG = "lang" LANGUAGE = "language" GRADE = "grade" + ID = "id" @unique class EvaluationArgument(Enum): TRACEBACK = "traceback" - RESULT_FILE_NAME = "results" + RESULT_FILE_NAME = "evaluation_results" RESULT_FILE_NAME_XLSX = f"{RESULT_FILE_NAME}{Extension.XLSX.value}" RESULT_FILE_NAME_CSV = f"{RESULT_FILE_NAME}{Extension.CSV.value}" @@ -33,4 +34,3 @@ class EvaluationArgument(Enum): f"Acceptable language-names are: {LanguageVersion.PYTHON_3.value}, " f"{LanguageVersion.JAVA_8.value} ," f"{LanguageVersion.JAVA_11.value} and {LanguageVersion.KOTLIN.value}.") - diff --git a/src/python/evaluation/common/xlsx_util.py b/src/python/evaluation/common/xlsx_util.py index c6630717..e4a3dcf4 100644 --- a/src/python/evaluation/common/xlsx_util.py +++ b/src/python/evaluation/common/xlsx_util.py @@ -4,7 +4,6 @@ import pandas as pd from openpyxl import load_workbook, Workbook -from src.python.evaluation.evaluation_config import EvaluationConfig logger = logging.getLogger(__name__) diff --git a/src/python/evaluation/evaluation_config.py b/src/python/evaluation/evaluation_config.py index 692c44e2..a987fa8b 100644 --- a/src/python/evaluation/evaluation_config.py +++ b/src/python/evaluation/evaluation_config.py @@ -1,13 +1,17 @@ import logging.config from argparse import Namespace from pathlib import Path -from typing import List, Union +from typing import List, Optional, Union from src.python.common.tool_arguments import RunToolArgument from src.python.evaluation.common.util import EvaluationArgument from src.python.review.application_config import LanguageVersion -from src.python.review.common.file_system import create_directory, Extension, get_restricted_extension, \ - get_parent_folder +from src.python.review.common.file_system import ( + create_directory, + Extension, + get_parent_folder, + get_restricted_extension, +) logger = logging.getLogger(__name__) @@ -19,8 +23,14 @@ def __init__(self, args: Namespace): self.solutions_file_path: Union[str, Path] = args.solutions_file_path self.traceback: bool = args.traceback self.output_folder_path: Union[str, Path] = args.output_folder_path - self.output_file_name: str = args.output_file_name self.extension: Extension = get_restricted_extension(self.solutions_file_path, [Extension.XLSX, Extension.CSV]) + self.__init_output_file_name(args.output_file_name) + + def __init_output_file_name(self, output_file_name: Optional[str]): + if output_file_name is None: + self.output_file_name = f'{EvaluationArgument.RESULT_FILE_NAME.value}{self.extension.value}' + else: + self.output_file_name = output_file_name def build_command(self, inspected_file_path: Union[str, Path], lang: str) -> List[str]: command = [LanguageVersion.PYTHON_3.value, diff --git a/src/python/evaluation/evaluation_run_tool.py b/src/python/evaluation/evaluation_run_tool.py index 7f31d35d..4a8d5029 100644 --- a/src/python/evaluation/evaluation_run_tool.py +++ b/src/python/evaluation/evaluation_run_tool.py @@ -3,27 +3,21 @@ import os import re import sys +import time import traceback from pathlib import Path -from typing import Type - -from src.python.evaluation.common.csv_util import write_dataframe_to_csv -from src.python.evaluation.common.pandas_util import get_solutions_df, write_df_to_file sys.path.append('') sys.path.append('../../..') import pandas as pd +from pandarallel import pandarallel from src.python.common.tool_arguments import RunToolArgument +from src.python.evaluation.common.pandas_util import get_solutions_df, write_df_to_file from src.python.evaluation.common.util import ColumnName, EvaluationArgument, script_structure_rule -from src.python.evaluation.common.xlsx_util import ( - create_workbook, - remove_sheet, - write_dataframe_to_xlsx_sheet, -) from src.python.evaluation.evaluation_config import EvaluationConfig from src.python.review.application_config import LanguageVersion -from src.python.review.common.file_system import create_file, Extension +from src.python.review.common.file_system import create_file from src.python.review.common.subprocess_runner import run_in_subprocess from src.python.review.reviewers.perform_review import OutputFormat @@ -36,11 +30,11 @@ def configure_arguments(parser: argparse.ArgumentParser) -> None: help=RunToolArgument.SOLUTIONS_FILE_PATH.value.description) parser.add_argument('-tp', '--tool-path', - default=Path('src/python/review/run_tool.py').absolute(), + default=Path(f'{os.path.dirname(os.path.abspath(__file__))}/../review/run_tool.py'), type=lambda value: Path(value).absolute(), help='Path to script to run on files.') - parser.add_argument('-tr', '--traceback', + parser.add_argument('--traceback', help='If True, column with the full inspector feedback will be added ' 'to the output file with results.', action='store_true') @@ -55,8 +49,9 @@ def configure_arguments(parser: argparse.ArgumentParser) -> None: parser.add_argument('-ofn', '--output-file-name', help='Filename for that will be created to store inspection results.' - f'Default is "{EvaluationArgument.RESULT_FILE_NAME_XLSX.value}"', - default=f'{EvaluationArgument.RESULT_FILE_NAME_XLSX.value}', + f'Default is "{EvaluationArgument.RESULT_FILE_NAME.value}" ' + f'with the same extension as the input file has', + default=None, type=str) parser.add_argument(RunToolArgument.FORMAT.value.short_name, @@ -78,51 +73,49 @@ def get_language(lang_key: str) -> LanguageVersion: raise KeyError(e) -def inspect_solutions_df(config: EvaluationConfig, inspect_solutions_df: pd.DataFrame) -> pd.DataFrame: - report = pd.DataFrame( - { - ColumnName.LANGUAGE.value: [], - ColumnName.CODE.value: [], - ColumnName.GRADE.value: [], - }, - ) - - if config.traceback: - report[EvaluationArgument.TRACEBACK.value] = [] +def __inspect_row(lang: str, code: str, fragment_id: int, config: EvaluationConfig) -> str: + print(f'current id: {fragment_id}') + # Tool does not work correctly with tmp files from module on macOS + # thus we create a real file in the file system + extension = get_language(lang).extension_by_language().value + tmp_file_path = config.solutions_file_path.parent.absolute() / f'inspected_code_{fragment_id}{extension}' + temp_file = next(create_file(tmp_file_path, code)) + command = config.build_command(temp_file, lang) + results = run_in_subprocess(command) + os.remove(temp_file) + return results - try: - for lang, code in zip(inspect_solutions_df[ColumnName.LANG.value], - inspect_solutions_df[ColumnName.CODE.value]): - # Tool does not work correctly with tmp files from module on macOS - # thus we create a real file in the file system - extension = get_language(lang).extension_by_language().value - tmp_file_path = config.solutions_file_path.parent.absolute() / f'inspected_code{extension}' - temp_file = next(create_file(tmp_file_path, code)) +def __get_grade_from_traceback(traceback: str) -> str: + # this regular expression matches final tool grade: EXCELLENT, GOOD, MODERATE or BAD + return re.match(r'^.*{"code":\s"([A-Z]+)"', traceback).group(1) - command = config.build_command(temp_file, lang) - results = run_in_subprocess(command) - os.remove(temp_file) - # this regular expression matches final tool grade: EXCELLENT, GOOD, MODERATE or BAD - grades = re.match(r'^.*{"code":\s"([A-Z]+)"', results).group(1) - output_row_values = [lang, code, grades] - column_indices = [ColumnName.LANGUAGE.value, - ColumnName.CODE.value, - ColumnName.GRADE.value] +# TODO: calculate grade after it +def inspect_solutions_df(config: EvaluationConfig, lang_code_dataframe: pd.DataFrame) -> pd.DataFrame: + report = pd.DataFrame(columns=lang_code_dataframe.columns) + report[EvaluationArgument.TRACEBACK.value] = [] - if config.traceback: - output_row_values.append(results) - column_indices.append(EvaluationArgument.TRACEBACK.value) + pandarallel.initialize() + if config.traceback: + report[EvaluationArgument.TRACEBACK.value] = [] + try: + lang_code_dataframe[EvaluationArgument.TRACEBACK.value] = lang_code_dataframe.parallel_apply( + lambda row: __inspect_row(row[ColumnName.LANG.value], + row[ColumnName.CODE.value], + row[ColumnName.ID.value], config), axis=1) - new_file_report_row = pd.Series(data=output_row_values, index=column_indices) - report = report.append(new_file_report_row, ignore_index=True) + lang_code_dataframe[ColumnName.GRADE.value] = lang_code_dataframe.parallel_apply( + lambda row: __get_grade_from_traceback(row[EvaluationArgument.TRACEBACK.value]), axis=1) - return report + if not config.traceback: + del lang_code_dataframe[EvaluationArgument.TRACEBACK.value] + return lang_code_dataframe - except KeyError as e: + except ValueError as e: logger.error(script_structure_rule) - raise e + # parallel_apply can raise ValueError but it connected to KeyError: not all columns exist in df + raise KeyError(e) except Exception as e: traceback.print_exc() @@ -135,11 +128,14 @@ def main() -> int: configure_arguments(parser) try: + start = time.time() args = parser.parse_args() config = EvaluationConfig(args) lang_code_dataframe = get_solutions_df(config.extension, config.solutions_file_path) results = inspect_solutions_df(config, lang_code_dataframe) write_df_to_file(results, config.get_output_file_path(), config.extension) + end = time.time() + print(f'All time: {end - start}') return 0 except FileNotFoundError: diff --git a/src/python/evaluation/inspectors/__init__.py b/src/python/evaluation/inspectors/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/python/evaluation/inspectors/filter_solutions.py b/src/python/evaluation/inspectors/filter_solutions.py index 33956fcb..14cfd810 100644 --- a/src/python/evaluation/inspectors/filter_solutions.py +++ b/src/python/evaluation/inspectors/filter_solutions.py @@ -5,9 +5,14 @@ from typing import Set from src.python.common.tool_arguments import RunToolArgument -from src.python.evaluation.common.pandas_util import get_solutions_df, filter_df_by_language, write_df_to_file +from src.python.evaluation.common.pandas_util import ( + drop_duplicates, + filter_df_by_language, + get_solutions_df, + write_df_to_file, +) from src.python.review.application_config import LanguageVersion -from src.python.review.common.file_system import get_restricted_extension, Extension, get_parent_folder +from src.python.review.common.file_system import Extension, get_parent_folder, get_restricted_extension logger = logging.getLogger(__name__) @@ -31,6 +36,10 @@ def configure_arguments(parser: argparse.ArgumentParser) -> None: type=parse_languages, default=set(LanguageVersion)) + parser.add_argument('--duplicates', + help='If True, drop duplicates in the "code" column.', + action='store_true') + # TODO: add readme def main() -> int: @@ -44,6 +53,8 @@ def main() -> int: solutions_df = get_solutions_df(ext, args.solutions_file_path) filtered_df = filter_df_by_language(solutions_df, args.languages) + if args.duplicates: + filtered_df = drop_duplicates(filtered_df) output_path = get_parent_folder(Path(solutions_file_path)) write_df_to_file(filtered_df, output_path / f'filtered_solutions{ext.value}', ext) return 0 @@ -54,4 +65,4 @@ def main() -> int: if __name__ == '__main__': - sys.exit(main()) \ No newline at end of file + sys.exit(main()) diff --git a/src/python/evaluation/statistics/__init__.py b/src/python/evaluation/statistics/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/python/review/common/file_system.py b/src/python/review/common/file_system.py index 6531a022..245bc92e 100644 --- a/src/python/review/common/file_system.py +++ b/src/python/review/common/file_system.py @@ -4,7 +4,7 @@ from contextlib import contextmanager from enum import Enum, unique from pathlib import Path -from typing import Callable, List, Tuple, Union, Optional +from typing import Callable, List, Optional, Tuple, Union @unique @@ -107,7 +107,8 @@ def get_extension_from_file(file: Path) -> Extension: return Extension(os.path.splitext(file)[1]) -def get_restricted_extension(file_path: Optional[Union[str, Path]] = None, available_values: List[Extension] = None) -> Extension: +def get_restricted_extension(file_path: Optional[Union[str, Path]] = None, + available_values: List[Extension] = None) -> Extension: if file_path is None: return Extension.EMPTY ext = Extension.get_extension_from_file(file_path) diff --git a/src/python/review/run_tool.py b/src/python/review/run_tool.py index 13d45ce7..9731e4f0 100644 --- a/src/python/review/run_tool.py +++ b/src/python/review/run_tool.py @@ -1,12 +1,11 @@ import argparse -import enum import logging.config import os import sys import traceback from json import JSONDecodeError from pathlib import Path -from typing import Set, Type +from typing import Set sys.path.append('') sys.path.append('../../..') diff --git a/test/python/evaluation/test_data_path.py b/test/python/evaluation/test_data_path.py index 9ab19b5b..bae12c11 100644 --- a/test/python/evaluation/test_data_path.py +++ b/test/python/evaluation/test_data_path.py @@ -3,7 +3,7 @@ import pytest from src.python.evaluation.evaluation_config import EvaluationConfig -from src.python.evaluation.evaluation_run_tool import inspect_solutions_df, get_solutions_df +from src.python.evaluation.evaluation_run_tool import get_solutions_df, inspect_solutions_df def test_incorrect_data_path(): @@ -11,5 +11,5 @@ def test_incorrect_data_path(): testing_arguments_dict = get_testing_arguments(to_add_traceback=True, to_add_tool_path=True) testing_arguments_dict.solutions_file_path = XLSX_DATA_FOLDER / 'do_not_exist.xlsx' config = EvaluationConfig(testing_arguments_dict) - lang_code_dataframe = get_solutions_df(config) + lang_code_dataframe = get_solutions_df(config.extension, config.solutions_file_path) assert inspect_solutions_df(config, lang_code_dataframe) diff --git a/test/python/evaluation/test_output_results.py b/test/python/evaluation/test_output_results.py index 7ed3c730..2573613e 100644 --- a/test/python/evaluation/test_output_results.py +++ b/test/python/evaluation/test_output_results.py @@ -4,7 +4,7 @@ import pandas as pd import pytest from src.python.evaluation.evaluation_config import EvaluationConfig -from src.python.evaluation.evaluation_run_tool import inspect_solutions_df, get_solutions_df +from src.python.evaluation.evaluation_run_tool import get_solutions_df, inspect_solutions_df FILE_NAMES = [ ('test_sorted_order.xlsx', 'target_sorted_order.xlsx', False), @@ -22,7 +22,7 @@ def test_correct_output(test_file: str, target_file: str, output_type: bool): testing_arguments_dict.traceback = output_type config = EvaluationConfig(testing_arguments_dict) - lang_code_dataframe = get_solutions_df(config) + lang_code_dataframe = get_solutions_df(config.extension, config.solutions_file_path) test_dataframe = inspect_solutions_df(config, lang_code_dataframe) sheet_name = 'grades' @@ -30,4 +30,4 @@ def test_correct_output(test_file: str, target_file: str, output_type: bool): sheet_name = 'traceback' target_dataframe = pd.read_excel(TARGET_XLSX_DATA_FOLDER / target_file, sheet_name=sheet_name) - assert test_dataframe.reset_index(drop=True).equals(target_dataframe.reset_index(drop=True)) + assert target_dataframe.reset_index(drop=True).equals(test_dataframe.reset_index(drop=True)) diff --git a/test/python/evaluation/test_tool_path.py b/test/python/evaluation/test_tool_path.py index e0cb12b7..8ee4cd07 100644 --- a/test/python/evaluation/test_tool_path.py +++ b/test/python/evaluation/test_tool_path.py @@ -4,7 +4,7 @@ import pytest from src.python import MAIN_FOLDER from src.python.evaluation.evaluation_config import EvaluationConfig -from src.python.evaluation.evaluation_run_tool import inspect_solutions_df, get_solutions_df +from src.python.evaluation.evaluation_run_tool import get_solutions_df, inspect_solutions_df def test_correct_tool_path(): @@ -12,7 +12,7 @@ def test_correct_tool_path(): testing_arguments_dict = get_testing_arguments(to_add_traceback=True, to_add_tool_path=True) testing_arguments_dict.solutions_file_path = XLSX_DATA_FOLDER / 'test_unsorted_order.xlsx' config = EvaluationConfig(testing_arguments_dict) - lang_code_dataframe = get_solutions_df(config) + lang_code_dataframe = get_solutions_df(config.extension, config.solutions_file_path) inspect_solutions_df(config, lang_code_dataframe) except Exception: pytest.fail("Unexpected error") @@ -24,5 +24,5 @@ def test_incorrect_tool_path(): testing_arguments_dict.solutions_file_path = XLSX_DATA_FOLDER / 'test_unsorted_order.xlsx' testing_arguments_dict.tool_path = MAIN_FOLDER.parent / 'review/incorrect_path.py' config = EvaluationConfig(testing_arguments_dict) - lang_code_dataframe = get_solutions_df(config) + lang_code_dataframe = get_solutions_df(config.extension, config.solutions_file_path) assert inspect_solutions_df(config, lang_code_dataframe) diff --git a/test/python/evaluation/test_xlsx_file_structure.py b/test/python/evaluation/test_xlsx_file_structure.py index 5230f2c9..f043772d 100644 --- a/test/python/evaluation/test_xlsx_file_structure.py +++ b/test/python/evaluation/test_xlsx_file_structure.py @@ -3,7 +3,7 @@ import pytest from src.python.evaluation.evaluation_config import EvaluationConfig -from src.python.evaluation.evaluation_run_tool import inspect_solutions_df, get_solutions_df +from src.python.evaluation.evaluation_run_tool import get_solutions_df, inspect_solutions_df FILE_NAMES = [ 'test_wrong_column_name.xlsx', @@ -19,5 +19,5 @@ def test_wrong_column(file_name: str): testing_arguments_dict = get_testing_arguments(to_add_traceback=True, to_add_tool_path=True) testing_arguments_dict.solutions_file_path = XLSX_DATA_FOLDER / file_name config = EvaluationConfig(testing_arguments_dict) - lang_code_dataframe = get_solutions_df(config) + lang_code_dataframe = get_solutions_df(config.extension, config.solutions_file_path) assert inspect_solutions_df(config, lang_code_dataframe) diff --git a/test/resources/evaluation/xlsx_target_files/target_sorted_order.xlsx b/test/resources/evaluation/xlsx_target_files/target_sorted_order.xlsx index 8c24f18b3c91c9ba9dfbaf208ec7dd0b42ce4612..a6ad5df7ba204af53b90b3aaac0ba96b66ca7517 100644 GIT binary patch literal 88236 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 NT$IxW!yQ(@{{iZq6JG!T literal 44906 zcmeFac|6pAyFWgN$`VB;*%K+tWQoc$B54z)qAVk8WXZnH3?(XsLKGQP6lEzpV?=|J zb&@c~zB6MRV`j|E?``^izxREg-}#(#-{*Wj=W+f}k7l{v@7MErUDxZnp6}Pp#nA&x!|K1DelYeq_x;%e6Q>0CO-sr2bKL#9TmI2_*UqhS z&SO$fi(gIeEDM11ttV#nb6ZH-yrK?iU4KasWOo$@5gXAyrrrIjHtTSiupN8GpMu-&bY@i-I9;l8@n1%rYWY(BUH7y2;+ml* zqOxveYhU+P^MA<87TKS>tAN@;j(>{QQN}4 zs*Cc+RuX;u3iS!7`~ydJV8x7L^t*1YL}u?9Q71_R?FlWtAGIUGM>fB*EvjWaH~#9m zIG^au#T?f=7C7BI@|}sI0+{+k5~l(zF9a(#SlR@AjxtcX=3C$KQ)Ru_uyYA>7k#Yx zaqEdUNi!=&e$c(xh~^zno4L-vw0Umy>T;LT+OaE@jd-hvhzW_&?B?sa zH}*4DBHqUZz%LbPWO7(UODkd=J}qM2$}*@aM4Xb785t}7kI`7{nT!Gf(})B92?Egs zdq9G`eQ!B=*bcs0lf)l4dN5X93pAK0`6s$S_%r#C;Z>yr^LiugG_2-fk9 z3Y~e-dm}I~N7pXEthO`6*_1mifM<(9x#9D&JMom17>PJ?p2UY))t`a;zdeT7aH~gU zW&ZGc@Pb+@+3^L_c)Zn6!uFgePBE_i8PZKN((BWlf#j<47Yn<*&ke2fP&>r!=BSkW zCn(xVtx1tp>6hS#B;attV~@u|ub%o*wmDk2Lg^b~-87AdllKEiZrIfAam4 zF4y%GO*ND^1fq7{k=t9C0D4}Tct6d+CTrjA1I1|)mjY%}4Nz~(AB3_jB_&3KZ`7O< zl}fzVAaVPQaSD+<1OCSZrt}a>=7CaZ-~xg6{!ZX;nfUuO{-I=`6I%hv5Q-v&@D@|9 zm*Ju>?VG&BTUj>EwJpT+`LCxPUdW}@D;9lUgw6{p98i;+|9&9qXIgs}n6&5J@`V#m z#$`IhU_k{}7OKLkDq~Dbt)PyD$0TL@xp%oWKH+SUkb1q}(WfJc8lgI`slVez;-e4z zeRBb^AHOdrKbjOXf2@uzyDC&3z~K!*9{X+YlqwelRXH4{^*@FZY^msq}dKwexEDwyz7rqZ!KkP)C{XNj+y< z_J1EpzVYo~YIpLDXPCpjE8u+Wze!wZOL_bpHwd(L1q2fQojCuS&YsQ?_M_S#Ro}dg zr$zr!AtnJfyx5cW)5jww%57?)>%4dLs(Mr!{ryp2j|6AbYxAL=!Lta{Igm<+uZFlw z{n^kOr4Ldcvabw{=JqFD)4trif7GI%akxn3TZ2dP0{I7%?z@WcKpqdJJdwyt&H2Hp znG)mV-gI_6CQi=it-_MgGm9Ob7ZomLXDa?|JDWhtly`2seR$8YgJ&vi^&uHc;iAT8 zPPc!&op`B$SN@KAc4n^n1z$Jo^w<9G5QnqLCGmL|5H6RYvH`};U-oJ0k?bvd3YhZP z(2#FY0Y<-mNTsICOThizK^C_7?=@#_wp-Z>>Qsz!F)U`qMQK$PbvS6LK9|gmZLg;K zR%1r(CsKZiz7?vjf=bgfz#hZaG&J$og$oz9_S4Wjq@aZiq;3J7z`5I@W3;-nw{D<* zs{rDMBx!1F&cEcIc4n&ldQ}k5?DKcB7aMC=>RmsH)|@z|G9l9LR~m0}u=3j%evpfA zWCs3H(6Ps-w^cxr2F#PAzR*r-U+$NUNz&!qHhvf6+T`+L0X7)fn1c(9_qkftV|lUu zm*Id~sH&;AbK}!){zImpbuXKlO5L@3(O>_8TkWH~I@v6H#Q41L;nBT(x31;col|Px zFH}#+??3q{D&JS&>N)9-lknE7^5}lqtgDV^Z}n=8xpA8vnUK_NPqhZ2Zng^0K5z?O z_q$Gra=x(>Eno5K#*MzGvN!zSFzgiH=8W&oZI&#VP*hOrvl!Tm(n+tI#iGU5%@5c$ ze!Ux)_>$=E5MJ#BNA)Gz@A-B0^wK-Y6CNP7))T0)TmAby1i}z+A9Co|iUyt1opw#u zS)hc#TTZIJJ}#%OnxSsm&C{I56y$=@hn;zb);%CK#dPto;kgSckN54((>r3ivyWFE z{Q)8vNDD2qX(!z7pdj5lwW8Ohw@V;8z(*AlCj*8Le$IFH`UM@iA*6fc{P;=iGXz)H zfqhOTgg(e8zEpUA+>>W7`bbUp6lcE4uY}I``2AZ*<-dj+wh3ImK099J#Y-djl>v$q%uwz|@Z)6}qc7!#mh2NHDNQ zSD7Z(*3h=qz*XGm(i2F!(zT2&O8O<%@p5EPfrqVE#PyW&3$hU6t&7sZGxg<gyJSGF8NC_S;U7$`fu>qi8atwwy>%#&Hu7q5G- zEbi|)v+Efi2YFn$qSLN9+n-Lr@RaY8v@V(tDC+DUPY8;ChJ3xQXQ5|ucEI3F`^8Jz zQ6C@01Wh}}e6LSSJ__j#X~3^1ADrGNAEtN=aVrU*bNbAqr*cMP+k9Wg%QZyHN%?L$ z*44DLaQJ?_#EG~SpD*4=cgdI}$iXGH?~QwuAlLjvKkm`qIEml3V>gegpNf+)p&eEC zI;wu+_p7f;mB}N?$#rYokKa25SRb#baQXE?=}|z!&_q?+(abqlxP}!>=y0u{g}Dv~ z1QZAX*YjXth^g_t2&oB#w~uZ zq{+K)RD0g%RsBhQ0D;=Pb8MT!(i6GVW|KJkebUG7JP7}L11sdgx;m2N&oVV57ly|X z82@I~sm^0=C9ehoUG3)p?fKhM#qXxGv%epN{q%=djh6$qi7%T1GKT$FydpcoleSf3 zs+}LI-uiH9?xAD8guH(A{jB@y_isdGO|8@Ud6LezG4wkVry@ALh|kM8_JNziaQZFn(Pnz=B;oGlzg z1f)=4aMt_)J;st8m{zEdM~5*M*51bt)-Z^pE$ygQs*N;;ew#vT#qF$xQ>YejI&Gew z0z)iL%#XUV{MW(K#9C%|3q}d^{nO7DOW?rD42!x-jERR*7h}yZNrQufto~fodP5ov z9lpLBxjw;+VJ!{Mry;}XOya0Th#!SKOB&P*f9S&^G8a><_2G?{Ye|@pAV<>Ko!GV=%$3;fS^RhS~aoF2?7l+zt|aR(YN}yomm^VC`6~2MyQQ(e9o3;eJEdpkd1-DQkHe zIuF;MUEZZg#Vsx8h7NMJ)LCrJIKJ>BD`N)Er6g?jGI-{TMQ&&wWjZ3pPB8< zJA!un$HgCybG>u}@^eF9VI*|PL+`el>5aVWPU|PHuD?@^d8BJ;Fw-}-F9mvMdJ zM|OrgGbQ$6?p58=k#|@0y2X7;hu;a9c?P>%n2}v9upWlnlis^)UGdhs@?9ax;A@_X z#lL1=cCl#K0P%3wI^wN$gbkA8V)6IpWn^XDe{#wnjQsb_0p3~xY@Pxa3yOmyx3EF#m&7{!>8Qf(wovgM_=5fVY+an?B3spD{19d_Qk^_%Dw%_0}?F^SpDx zt+?R6Y=ES@YyFGnW!86g^#9z*B^O-%29J%qmWsEQ3Y-3o3+}&e_A}k`X}i3+@4McT zn%~aJGy^?SOvTEnP|0Xt?|)bZXEwEUasNotGDbzbR|?|hKxo}JXU(4UHK|RBSq62 z-&Nydy4X_!Il(A#2yt&tg zHI;l{R4Nhg;oh?k?#q2%ooRRUZ--{b!Sr!3-vn5EA{2`sP{UZMV}7Y)tTcMUCWzTo zYz`HhN5vLY+Gz!qw?cllLRMQLqHW~H%4%Oku^*z?A5k1o(9<@6UXH~s$F?rVcKcO^ z*?k{huAleIvH!LY+!0$DX;*QlP2~KwG11U>s=tOcNB14K5UqS@_uY1--r0ZSEJ|l= zWrAJB6ZTn`LeTI}T zJ&(1y1;O89yHbiyt)9QvGQZ&pu^jC88GXBf?TS3b!K)HnP|0>>+V1-mO1*6mn`9ui zl4Mu0z$R%6n^twIP52H@qSmXoxVd=Mf(vTdLl}17$&`8qdx+&wY$X@0VoUo*BBjFB zz{7mn!)(8zAro5R!^d?u{1RWp+B|^ZAF%yWsZ9-AycfI3_Nx)>cdYidPR2%xxTy|a z&ESG&Hi54SjAIi+=&JNn=C% z)u!ez->Y8UfG%hU`yp#@N3x+MX%4ELV6RR#^l8|4Q(FC%(2bDGr(-KGz$(nx{-1@b z1zD^Xl4z|2$i_Z%1 zdGHKKO(v<+z0S+Jiq=C&;x`Z2?yU=B>2V?&q0 zzM~oSSontjL6q3a8dwFI?LQ|{O?!>ca*dsu5y-?|fa%GgCfuH;&Xh2lGon-6f!8 z(Fh>ZXZg(kGP5Wg{wWN(jOA_vYafN#3@hh8cjGvuvf*vAXyyyi%r4Hu+!j~eIP(7c zX1E|aY$pS2x&pTBgyD?~A+LhGUtucZm?QeMSiWf%M7yinvI~YkgY-Gbz`PWdrwJu`4?210y?sd&pA z$q9vDL5Eo~u+C&~c{j|)vXGP?1bL6CC}xg)qs8jXlHZVtAIRXIpD?^N(#Hq=pXXbP zp&(AI_OPjGZN$=nr_ShfmmKc5B_Lq>uUwCCzaDXxuMUu$H{5UkGIKRzP=@mOt}5Z! z)W`?mN$2zh4IJm?>mZD z((*r11AkXv0kf2$z`-c`h;0Rg5KSL}SBz_F{7=-tAJ&&9j#HCejS4CTfG4jus?np1 zbJM)hp_@6`EQrl+_zM-nAIIs>Rv%Rr18o(9+zs`a>*CxrZ>Y~^PBsf-vm5?Gh2Y0= z^0U=PR;BK+N?qoL`bczfZkjjLXEP_81+m!;f1yI0$8nx#tIt7|I!%?j*Bk1S-o?3T z-cX;-oNN}vW;gtW3ZabSRA#G>yh@$AN?rDb`q*@FZkjjLXEP_81+m!;f1yHn;5a?l z>Z77kr=?Ptv!OotF3wH!hWc#gWV0YPyWuZX2nigg1Y3RNRO-}J>asS}N4JY})4ZWR zn>pDmh`+WQN`Sk;H}1htD3rECQn5jWtlN}9JLFnJKSdf=Q%$EWJ}=Mp3ttP5XorSW zu!y*8cI!;$@ZxPV==ubaoL1e`&RR1g_E0E-DCFAA@+6%UBshqqjLeox>+7$tF6OdU z808q&3Pl<^Gg~f*sb81A)|r$~HV-X)JW^hLX*zsNi_rrjQNb{{dw^TSlc z7O%P9fj!@s?_8 zcGko!W^HLQrkuqj(&=AZyJMVpl_-pwp%8YUv}xO?2&e%<4cx zKkyd0KHWcv4qhWxTMpt`0}a|x#%^q@*eVGy(y zjik=wL=nM^#Se&J>P+i$A!dEGd$bz)i}|UXG6*NHd$+)*=cw!09+*CwhQw5{r~|0= zwceP)Zx}t@b$SeHz1$lU%3RYyP#6vEEb@H5H*2^%jUHq+-cKjlF;{4en71)Kg7KJv zrC~|fkP33SqzQ?jx0vYG(qJ$+ok?#JoPs@q)^uWY*VieOLI8-~#I(Xtr-98P(oFLV zXxQ2`W3~y)8fF5ArD61v7^r8wH$6+wYd9^Yhm^sj&c#@IXXvlSq_7rONGNDQH6}HG zjqg3M?8d1TTzvU)*@(YG+@ujWUSsderPS6wze7*Ul_!KN)IA?SsvA_ zVLg2W(F^@KHXap|3%!d8qcX>PV72fyYA+6j`;^=6Mt86a&z81iE@Wfi;oYO{(io65 ztAgUlVDx-IP)8`z1PbmPvjuw&!Qf>~E44LQt}n_%q$tZVrrl;~RZ7}HS6E{LEdw~6 zxb{GDeo9hq%J=p%;Kh%4`b-bq9kqI8rWIZag*OFO7Q)c4p=c*$Ss@hub*N=YOZ7KF5dT{GlgLSM?^Bz8~v!Sj!^h5n%aUGPWho2*P8S+WedoCRR z3q_53X%CxpfT7@*0!H9^`cL)stm*SHv$jR0Fw7K=K$|rCRUPh!l3Mq~6Rd{`Gwb2r z;||^^WENw_xIFh$cqp?6x*U@Kl`w-Pv`vQPQwjPEN%NL@OxoFVT0x6F21+RJg7Gx^ zxc8k!Y*lGFbH&b#)k$%aLxJIk;#p+!$g=UNvgW#dT(SJs&EG|zTRAyAaRXoL-NzTJ zto(6qpN_SY{?}+tl zhbIq>^K4%`$7(8f6`lSR>m&?QTU5*o9J!^nxLY_r+x0Z4B&lV;7E|xqBsEWc>wVz znwU<)NWlB!fg@&Gi(3JB#{+++sTS`n$6BdsXL=*|GoGq~4-FgMPJE~hmp=)=mluBqTV1=WzSgj}>Gg;wY`YIEi|BZ1v9>U( zuF!l>Ud2eO|4zsHs zV@L(y=3(0xVOdN7j#(H5Dl!MDzYFHFGTdVI7WDjb4VSX)7UiS}0FLvS5SOUC0RYEw zZW||`u?zqgDStBZ-k}5lu5MR-ouODW;GO+;AN#BwPL~$-i=qsQ%(nq>a#o?V4^Z$R z-EXlK%KR#1_}TCvaOwy9?Zx(4qQI$kMN#%e=KR2^8dipyR&RL#W^rX%3FV}_0P~J# zLV}_*CjjQH=eBL*(uWutT#LZz^UBWu683!!ReBib}i&Eqp>cQ z#9y=a(}3l)?c$BBLjX-dQB+ZpxhOzmZDnX<^_CByfhfx!Ru+i{X!bl45)(aS1kmuG z+a|#G2B0aAmj4ocuMeOJYuI(C!O#bwX}z(%^G4Qj0FGD`wNPX(rT*@__~4OrME?So zA^-~?EkdeCfr`y#0Ghj2hEY~;g#a2OW!aO;N%sMoeb0pCMBPsUG&|32+r?+R4~XW0 z{K*IR4m|<#_+?l97sKZpd34zBBag*V2F2!z0Ng7p!`D`CMV?>&`B;Oy%ZOX` z!tRQrSL>i+hVQN3?gm&am1V7zMPdOK%K>1CD~?JiHdhB&TC5DeTfG$n;9QkuZz_vCK7WYb_&{#@i5%E9 z&PL++2#R0ULpcdB6SSTQX^YOB0$9Y)ZIj@;CkMn5Cx0^T-Zy|nr)igAli@VL^1*R? zv13*sz(OdF8Y(u|1z47?3|Fk)N&zfk%Ch0gN$~)j{xcz{=!}U%vfrp~_KHDvtndTX zxcyZ=3Kv5aJF)Lhf}3_x{T>){U2~IpeiT*KFjs>^^Aa%fPmARNT*9=o{!NyAG-|H--U8rGy1o1 z5i#VPQgA;hp`utqp4K9|;jFBk&U(Miu$nv)d>GMv%mo60@?z49Z>(dPA!oN08|0K)brd?9|!dbKU zvzVhwjFBRLKo?d2sn*TbqWlKeti-#`6E1?)DpvE?aq67OT^z zs_ItlbIv-{v>&VI2?m!^)A|g#dT+`+|4|d^krn9ysDeAepsd3{3s8k4-ZIuVWyAqh zc%hx3qM7g*P=!|UX3@RPrvO!$5OCrXaC!x(!ZOdtdpsXI0aXa$>iz%0jW|?%JRnZy|m+p`)I6S zyF310Z2$3u{Pf1}K-mc*eW2@D?nSmL{44YHPqO^8)4mD!UvHWJk=*HNCcFe1dO*Bc zYH#xeprMHZPJ#kXxj;ixcs|DPe8d3_9mdsb#MKL|8h)T7=dvQ_0DYJt7_4>}1OoaH z;4Kq&Q$`2ShbrxaO}Ne6Z5GP^j;=Un$aM_3rfb$jN@PV!06n{xU=Y_~umkW;z+1-T zrp#8LXQQI-t%;6I z&x%Y3B;W zTd{unD)ByUjxR-_aEz zhFl@QW!t|dQa3A77wFmJ1cQtY11Z2eNpBgmn=*nx&&Fvd9MDXN0VJSFyjgf}vk@Qx zqXJ{w1;(-f30UM=isD%UK5b72b8W(H=5Di4HoBtnZ~}w?MU06H97-(pq z$3w#x7a7b+1|5Y6UmeIVClOf7qh_p)pQ)a2D1;(@0HCbZ;do4PJe1XAIateLPUn(p zL#Q<7qKWiayBUdyT&C*iPQ;WK5(jILRAzTWT44qTL7nJtil;6zsh`d(yhv+8wPON=*$vC@DEr#8eRKjb2r<1Fg*FayS7FlGk_F-UT2 ziz_Rbg<~LTDfB_c3T?a}xWRMf<0TZ6JP6$CNLd+0GMBK@gNrR-jNL4@UC^7wm`-Gn zVe8O;yyf+z$gNvXH9#N&`44Y-)%fFEURyuf_5ioMA__R~MO!}zo?xYG8K*46-yGfD zbhXzPAB{EI>HO3lWbRMYU(0gZxh!eY;C1>-{$un{{X0C~M59-nxqqEu& z7@X2FISv+VN9iFb{g$xWbz%@EoY{_{b|d@SS+rQz5;eZij@6%5C@npxA4Z{(2qGe+~J zS*wfVq*?^M0F#el47c`M4l;{%k*hN?<%2cVYlC`B=3-h+0nvX6kCr>_!45%iff zZD}UCw;Y0@FN9&>izG}qAp=TZG%KV}%(Lhd48dE>VFzSIK8i3pN+LoTL}t^V9vw1q zs0n^|5htG18^~(M*SktXn_^HmYJ=$`vF$JnZLv27wfspCnz$Of9r`vX1;NV3gi!K= zFbK+4;sDat8Qp?mWp}p&cRka!nbSvD4A(*+f6L1$ThL>PJ!9>5e)_d8ti>ZJboD%x z5XP7$w9v4@BNMZz8b|orLfmL#7_0Z(x0sagW<-W7^g|Dp*{Wj3xV3gh3M3b5JU=VzX z*aYugnwF+=l(VK$)Eju_f;W=sPhTGJ)eWHIDj3_#SznPx93-4R6?LkfzVfXc!(1?N zjXU6t6f4IJt@a!!7X%K5m;%Qd%Q0vuRh-BS_odKl-5D$KOf8}mAvdU&zPw0eeUE4E z(MAqUK&gcX;(V4D^x+GwDKRL-s-u1godlz}l30@v+BDaS8Ow`oAI=$?tB*BVoAxf7 z*JW(~1|K*MWo+Tr|D21YO}{Ky%nG_RYmrCUp0~Vxi1dscPTP1jgB%fFU(qJ?p_nnz zUFq>4B+g?T7vGT{Z-AuRj>!3wP`$J!?OAE&_N7sV-9cg!^c)nkv{0Z+REnp68?9YR zu*@JNPr}DTU?Bv{jHPca)k{@{HAJOC`nPj-Az_vTN-_mL4u^%bC`psPP1Y_|7KRg* zqyxrj_O?_;i+<(O%aIGzPb*?hC8LTdFR7>#p`i7ut}Ndg$M-*j0vD zdQp=5;p2g@%CAac;#Wxm)fV`J`S_w4Pb;+c+y$T9_r48zFqjr~Jn9Z~Dsz0@j4+55F1YwLL<_%YFx)f-f5P{8hzv$;P+g&l?aXUiLfa za(vOe=dW5e1Kzixto#Q3w@WPo{evGKvKf||1#}3}FMl)0Y0m|V z2mTH^9$z%-`KyxclCE#VpElsVZm`_yZ~#g_EwDV|8PpqPLs(H4bt09uoVtHF(=9N5&m^NBn2~)b`K%b$5#d7xvF_>GIMuQTO17rK8~yxi_ST ze1J2hR!=~M==S7e*8`gIz`f(zzzckR#)1$L3$N=2dER|}`Ql_q9(TcJ7gE$zt< z|D!{C6dz72+pIR>jo{^a09hS0}fe`N~Uw?>CDu#nIPc^(Tz<2YSCRi~CtO(-CRNg zJDm5~@RBFgWKVFMVZ*0proPAo9bm&>G(UgIyiSA-ucVlxqzK|;!;6XtgGD;H+3@@i z5AyR0ak0JU!8-A9r*mw0Z*kY*`d!XnYt{nX ztRcroHoSXQsz+9Y^#=T-1GV287^;FabwQezAnmvyI#j`4QDzUDLQTX%O~js`O(FL1 zj2N#K&j!T=KX!$m+kk6>A}!h>JDM|(O>x6W&Dn^fk4;gPm0FV(;mwX<#QglIIp`QW z0zX9se?=KdHoT6Ag^tM34gmfc^-KAlN|@uxLC2FYr;~$DuPxjC1-XaV6#X#{BQcyb zHU-B?HSUufoE#g~!JD1RpB-_RO~HHdJl{o-5t|}LQQ^L#%sw`Sv51ASi2Y7>1ac40 z$npN##*Sboc4a4b#FmW+#2+~9d%$^?6G+m>j5SVeia35s2|uNSpE`$MnR#xL9g)JO zP``Ly<08nEO_8Rkkgh0mfK6d0VqqpSB+RBze0WBYcMmUn9z?MIBHS|E?0I22#q&MEAH-)4(BvLnQ=vLn&UM(<US+CM?6n)r)_X{YrT4*_3BRR2B&-4Mbos4 zpItUMOIzROw7#|1065?Gxm?D%TrLs=IIntYG4iYAX6bo7ZhY?@XNd)Edum%>yyeV>rZ)T!I#?fz|^;#l#_+{cGrjEuOX!E-*=$|Pp zIdyvLaoq3&BT2l4T;z2^S8&cH||`JpT)e^N$>z z6wdur)%2;0`rh9_>19WrW&3|t=6{6r-{_*MjH9W2de2|P<>Y*NH+|b39r|bV<6q$D z|Dc)x-UIaz$rdZOw9fNsopl>x)mzefGpF??TdX|#Tr_bmnryMs^3-_Yrm>eTR%vBo zttDatY_T%!f8^5lh=;9(Ii9C9JWp}4wGg)nAMtE6IlyIZvvM}u>c1=sRc;!`*_u;b zCN@waCdt+u+x|yheUEmqHK)Myl(y%ot!&Nd(R!7p_3AEL(r%@7nx=KK*I&J*ty4Lz zQ*23_>vQqPx%jgsEznbA6Mi!}8!PwC$^kxiY_=6}wffgZ!7)|KF=9h=d~c?yoljF^ zFQbl?wC>4i-NV)#i9Q#8oC`l&b9Q)YM7wG5uq7>~Ost_qjEgO4M*WX&^gZHWOIns^ z<6+N6(1xUqcW7k23OQ~X3T(~EEfd3)hzYYb$D;p{Ti>JY zY|VM+*{JE+xP`4bKeQZQXgRXK#`1MfQ!`CdV=tqQmA0nlw5GBpExpg>JkI4jThcCg zYHY%9CTF8CH!BBNMs2ngaJBj`h{AI%NA|Z-zHVv7=hKSW%c#ndR;!#=E4Jp?^tmYG zT$I_Gqw1;g%uPd-EomubVl5?Nd~8WO*Z;`5?-4g!(%yJBs(UtavL&rc%W)HaGdVzE zZdMMkjM{9gjjPqaE(&FC8d_}4DK8W2FA)=GYmRmQBhS7^0&LCs;Mu6<*~r7zoS#~b zXGVU`v{pr^Y7yW^y(PbF*@QWz=R{ z0avSkT@*%BwMN>5b!u(GKifLS)h=X?8X?yI&yEUdV8>25q&8%Ip&^7h7X$o;I&DRg zFm720(??KNvHdB9z_yZQ67c);bQ-p$yd8zc%t33Iw_&ha+G6!!IAd)9y3SlA({*UU z7{FN+ur*|+1%jltV1gItDahTxehUbbBv?pbb`Rn9 z5GZ?o=FBZ4r7K!O3h?8HzXseI4IuT55F3{ncqvDTV_(+ChKy!}2z_r!UNWg?t!_Df zp`L`KTth}R@hEj}SLo1qz4~it0avT~>A~a29O5^=H(jl~{>~$%Df*1oDkZfN(?n_g zZ1s!@!lwaei+)*C+`iW2X!`1gzk;v+|mpYG_7S8M5i#aQ=yI?lj*imAh zZQkWnDW3N1^PW)dkFOxdwxqbb;)iZmXH9?}B#&3)c?4>! z4Wi+#yoY~Ds;DO3_ILc12dD%vk0!ZEc}E zv3#3U`|$zdVr*idJtA8rdzRURJwUD5B z-|^O?nBl;WvsD?BZax9ySr{hoeZHmR&vxftl1EKlu&LRbLfRc3b#D3g16X=HMz2%& z=TT0RXV*Xs3#yy__xa{6;*-*6KGh00xZX-D{N<_Ix@6EIZ*||gdL$)?WVW-yM%mh| z+P4B$do)|$d3WjC_5o+}kM>D#&4LokiuZueT&~@-y=1HU_EH|b-G>job28f&nHBN& z^8pvfvCGuA?G<0%o$34L_mEohe3%!Uaoa2G*D=SE<#SuzdHN-WZr?t$|L)Ob!JWxp zC%|!T0!1nzJZY~Fae7h>(-rWi?+qs_;7>ND{5UJB&=bDpu^FoIpiywj!@Mu{FAu89 zoc)?=5TenXHq@}IXiD|owzcQe{5N8Ulc$XMOU5)bL$ljG%7vyMw#a2KIqQlgUm1yt z7i`-T@M>Szvz}~`Nxf_D(fm6lj1WY4>`+73QJZ?`mmjtv@79woPxtKCN7U=lv#$o4 z4b8qfw@d+N;;w4$eC+kFt$(>A{<84)NE*tB; zf#8{pB0-dU8OD4ANX36c})wEm3J(zIo$-kzm{Z)nA@ucA6P7P zC@LLW?vu}Nw&IYgcyZn$8n!jwMp9R+F)@dZf`Q{j_VH(&%C!QZ_KmEl0}m9 zPR6S$U&iXYW7WVtBm1M-R?URUyLybU8yCBOUnCF>_tT+`7-ZK8x2)NL60RIbxl3~^J@j? zw-b*f1mmxS9CJNl^!b|AyX3=GT(^^NAKiJeG1Gpcq3)^QMGNPgII9;Gh>o<2$234S z=3Uj!33FOqW#qmByK4)jLnhx;67hnA`W$tK{qOVh?&PZq7Zdlt=KV4|Bef?*b`Yca z_04-SxGCY>kj2~gI@xjeBky=esThtWe>Ra{Oh-YB&F(2}#GwqZhJH`PR9D=7!qgy2A=&0{FXeYtOf2|6{W{p|6V1 zqL;?f(k#LS_o`2A*Bp2C!^(_2!iE*4<2e+54J9n`a=e|rZXu-P^yq$LoL$d|oP+wf z+*$YMmv0)$;1;(gEb#X4Yp?15p;g}DQhJD4*zG=YHRc>%e59|Y`Ai}YY)3Nlb>@o9 zvD-hBjOS)bX}fs9oM!_e#m*P0sm+cig*U6NTHo2aRhUye@&0~T^T4GrnO_elcBNV6 z+!P^c8*hE&OFCaGxE2FCtzf@hzg`v=`REYOGkc}7%3wc@rIG2@w5Pmay(ys(wG&lb zWuk2uG}_j!cI|BX^!U;tO}o`l*MZ$`ZwtFEcgu-ykDf%s&sLm~NROo#45$^z4Hm=t zPpyouu1d$uCnm1CLGX)ten0e~Q>6{_kR5ULrmPwNeV%w8N{u>AT7yaiR|W!KPKD2) zQCC0I%c2!RP}}FxQO|;gXFf;9)Tivel7y+pD^4kD(%aI!)FJFh|XB(d^s`JD-$DM z%Zz_|X}ax|6@^Co;oG?XM}BMQiPc-~W_Rz@+zbknZfg_V5!Mtl@0E~pCfeNXsXo)- zYs_|q=p*Gm*?Am9m}+%d5Q3>T@q}HD7Kkw_N5j9@hT)LvX*&oImAhsFwG1%_1)k=Py## zRwt9-n@2_4$9iW@o6boO-I_vsNS@}MNeK=TAYaI9KNUZDiE#y&mmlmwU$61ok!CWv za?3f|g66ViJY$w)t5Mv%2Q{nWf&C|mOA%A$LL^Iz#kNyVD$U+QjyJbtb_|@nr!_Np zw43(B%_8DJ@5;Ms+WsW2BrIGP^s-85(xiK)jdjswcP8C#+D(5k=F5@NC;`j-9NM|5 z^uyf;@=8$xUGEEq>%O{GMF$}i1t2i=gOqi_OEoIUu7tcf*JR9m~NZ9^+;@p?|WME^-m^)6S>~DUtQp3go<6BecaDwtM&&@pFbRRxYXi9{?Jhe zUoCa~v{aQUhQfMDbMR|#-+Adn`bY4Uo~vzM<3>$oIW5=Mcm-v>!$g@+JL_v-J-IA= zK+gO_alyRCUV7`b@~Z=992xoI#pbKm6W(;bp|q{<62t4w)peYUA-*!6-rw5Qxtw^# zk(Q-3_WC;Uz4eEmAKl_C9yank&pzja@Oe<+ad*We^+fT0sKeVg#K2CAc*?TyYYHLD zBu`5G>l^Fq_?qK7ZY&>sSQx3|phhFbkHz(m8{=6OCvQn|fk28oPcsT#@ zs`;9=ciLeQ1gX#f0h29%c)_6C_*+?u`z0N>LLUBNCl9CexRsuE@UGLBMNaq2-!MAs zA&pw5hOZV2suOIV@0=A|iBMUgmE1m=_YW=dC#2#+6%y?e_HF zYvK_bzrKh8QBmMI?DnTGW5BkARY)4Y3dtNDlV?L>Yd{cMU z%lz;i$8qE1gYy3QhwIPsTHd*$;ijEgc6Z9~DW6#U_>NmVc{{nKPCQ7MkjOoEh4-0K zxWbkeC;7(1&LX>x)HPDgNNJgi=R_FSu^hh_%i)s@{LTE_PiOzy2J^?q$~{)md5&f#57 ze=mLY+Wj>{*Qx7O_KmgfUq=gx^z4}T{gf@anntE~rPH^IY-b!AIelR0lrcK%t%%Yv zze;P-`7u~xL>A6|U;BDXR#$s_XUN(y^ash8&3O{AD@}FkvN?NS5w@HP+j6Q&$Vxp9 zw!b1<=nTh5=83ajxvCmakYTGlt$pgQ*nFrrW8yH<@XfCpg<4q2F=YH#jPHfVkU(J@ ziB}EcuUe!~>7nsKk1PBR98MSy#xx%fSq;AB^pY>~hEtuRFV%Uh-mN9c459_Tur+0% ze@|=llL6Nh1W!9p@<98`R%{dQ_N9?FdHgEVbg*&WM#1aNGn(kw-L2!Q99N%T7HQb6 zz-fW|m{uOTOG9;*Pj5iU{&WXXkvw_W&3quLEnmURXc>L#YIwr_I7HHCM0|)H$?cPm z1UU?CLN0xbn>dm7_-Q$LWs-~hDmT=-Y_3(gkE}iT|Lwsme$>3>wpDlAJVpkF6YLBO z!oa*0?VO)ilA2c%A5vM6S{#e3UwU@x>AXV*0N?^b7H~uYK4`P z_?Eo3idUB7Uq0bt>B~jaxeiyy9(nwta{oE+-lSEfCxc%Z_|{Bgv1DGF%V<7HcIWFl zYwrnbuHR_NuD-rz!<7m0ngtJ|qhIY|ylCX}qhdw`hpbACjn5MsZXr84wHW4yQk-Wu zO}qIkXNGXrbB0@Q^?1Ye{&rqCwx`hby*eCA~b&+Hb{b zZ_4(@=j|$$=+rRorD5UT>}_*6PCezUjSgUbb6E0DeRZ+${;c_*9+s)zx#JM~rDfA$ zexo<*|E~V}+9*Az^6iC5zpm{syx#sx{FlTZVfpX%Z=0{!KP!CB9pKH#WCx5)1_llW z*Q_N$OJ;|zxeer5Gcho518GzcUzD0ttgip(1kqFof|61eunf&y&1gC(A~FaWK&48;5>x*r2| z14mHG^NX^R^7FH?X`X)l-j=sOO^1Q?h7^is6&A2&h_8{2z#0Nor?>aNVP#zF8&Y*}jTb6cd6EX7oc7SbYQ$ugKC zBuP;SW0Eb~jD4B)cV?c^XMFCx%zVC|`?O~uRE>COkZOTpSX{Jjgi=N)Ktu#{N1 zL)7Mj@!EM`6P^3GI|nLc9aeemV; zh^}dot2UjjIdrZ>&pNEgg`B>4*Jzrsyf6ZPGNjQ^veEQNeWpA=JjW*++uPIf}Sf%Ln+@|r_yEM5>#Lq_j0{nIk z=VtOvo5nwV+O(g!rE(pQ%KsLX>v>fEr&JsF?yJmw#V7m4`6cVtYg=m ztI?!n(wklJv<@>mb!Y_UH-L+!_tYS8Dg3RVpm zrOIGw!%t-b25^Xc+JFhY1(r{y)h7o~I28ws`=E; zXc(O&Q_-7@M|fW)(wbrUW7L{HB$l4+ilq&N(!C#dr-dYt zhokW*I!+i*EeXYrQIU8WWj8GEEFRT2gb$z(C5R9D_-x11Xm1rs!%vCy8Wgn^5#aL{ zOBq~4Yu7-L+Dhouj#JeB4Rk6nluV>kl3}nCETw-PjdlY??dYO+p-|+6`X($+%f=Vy zw;rGGwKb0fexlNSA7WIoSs0<1&OSw~cnzZ33`=jlnV&bj0ZX|=(F{k?dlC}5^XVga zTI*9f#fL&F8B@iLASh&4>}U~;THap{@HC}i>7RvB6hd;x2%d6})K-C@Z>Wr|rj9hx zDv0@H1d3iVCPOpEYL>T$2G9q0HTct?;^{TOMI+9`LmKq45*ZXpyal+ju!i3E8GtVN zj)6VHQ<_R(v?HNdZ@)a)|7zv^BKmYMiOMSsqGaer+pieQA0f%C|U^uOR7)6 z(uM|(QNGc8Z;)eUJm_VI33PkXhI&ig!leN@RRF*O&!}w30*i6OVgk&jX=FDRPB}4S)#K@M_Olr&SD)gouxVp zbtHAfbz}`@m$%$_A*-XyldF@YT7q4)?vbS{t>i*j?Q3W{aA&k${7Mg@Ct!u7JJ30fAEj zdjy;X-~#>vI|L33>=W=1FcNSSuoCbUC~4+*^v+3-N~j8r$_y%LSG6cxXq6>|kE2{$ z-h{cnazuo(#VDpA?|j&Wu)wgOpU?k%;pf1ggFc-9aN$GXhoH6R*Irl~xHc%|e9DEC zz?7hqn*J&1b|YVJZMx@Je}~aXf49V0{DzOP%_6y~mtt{7y^_k_^F~BCbqeK`jg3lA ztAbxw|6s9=jnt0b{N?@nqm8Xa*)y*DwD#%w@m4pIubi>=-k+0u4BHVR;PByrbg`>WjJ(=PXjsu zu}fHWBs6(WTl`XL+igFW>_ z=e)VN`^^=lHT$PF~^EMQ(BEWl}c6P+>ycsKe=i#8Jaq0S8aoX5B2g9c5rmatt zztj*MM>8n50-UkMh_2+Z8k*y@7t^ADaek?HNHJw`?K~%e7c)@T&k~a|zG6J)1b78t zFi67}jpSZ*~B*& zy5G2s>|K?QIe3|9{CG4W)-W`gMjzN}1TLLNioM;J5Ro@3>$lK=4<6ZZx+ROeYB?U>^#(~72v)^vHWR}HE`_&Tojs-PCHa`@ z3&a@nZL1cDEH7OO7dHr>QyTu$>hRf*!)G~!&+7>1TO2NUGkm5+_}nky%^sUozgqU> z`5xuFw^RR$Jo37wyzV;rU9;nMu8GrI5~sT*Zr7|MJ69ahTXaNM{m8DX6|K3yI8uiR zfiusG$f`v&EI@>dmWD~K4ikACw!|T9QAe2c;;;ob!xmeFiG2xMx|yK8CKXu|(T!6S z5>U{wlh1yTAh$X~cC+a+1yk9LmCKYXWwq8VTfRz~q z4bQ*Uci$zaI|WmZq8kxi4@VFegtp1a>qYpwuZ)me9I5=`0lXsdNx~7#{q@A%*zi^ur1K1Rp*^gI@i^%;7Mj`g8F7QgE=^9lN{&)g^C_X0%4iOEGpWPVn^ zOcw5(ykGQ3f{h5rvm+a}%QJ{e5S)TI)TbH5P@u*jjezEU$*cmZ0NPQ0~j zDktvgZL(bGcUMr35$MzR%%42an;xtfJ^4&f`@@WIMkbwEadFZt?(5yV&@ZcchRpG^ z`XCDXw@u+KuhB<>+SZigTOfL*DMzwplt@Mr~w$TfmzJ zlbd8U57;pRe>pWSTV~@(T3bMEgGn^-p$u>eKjcGA0Cv*slGZ+PRv0)1^q%1qX#jSr zI?2aHb7A1aAmBql7Q=_aQ~59m_^>cz#V8<);X}}i$$eP3i+dk>0UuUEJ`A7AhoiuU zxxj}YRvA9jpUQ_6;6oe7v!PJNdB;tWahd_ZTOi|hvAhLfP3|p_LZRZs?FJA%yNJs= zl;!h+;xxhsRU#VoD#`jj%MFO~9na+R&~X~q23;Z=jVs9pK06GEt2&ew@)F`SA_tQq zfKy6eVIRmtKo%npmrs?4eIO6H z^4~0a_T|0=<~LYIXo` zd4h}sv@ohB=*8q!a}Z=4x84F{Tm{Qp52x}LsE@HAaBL*tpPy)Zpd2z*5uwA0Q8Rm`awS#^aEIv)6blpao&6kc*_rX z3(&&w7U;#~-l70+<%8T})kn~aNoOQpG>}_ddJ6$^OC0i6?o^|h4ahB3;4LA@TQ*bW zmMZWT8h9%Z@|NmU-a-R!l>u+*K;8ndCihku@K!7E);-8u0M_K*Y6bMWKNHMnO)&th z$?4C&Ee>WR95?R=-pa0?fnZfa(2L2vHTFm_-`epkgBCD<^aj0{bmE=s_h|;V-bx1E zQe=5+s@zHj-l_rKiiEtCJe9XV_YRuAyj1ISyFQ+cZgcC|g1i8g&^IR~?z=KK% zXkmJ5s#!)OsD!Q{w*W1Sd41zlmCzOB7PsC4(#!p?2_Efp^}|JtN<*~k&s;aWdEF>* zzhUHlqmWOAcRm@N+hBNYgHdq0VRX7t$)&Sy)?IQ@k#&LCxg5V`VTf3q^0XWO*g=J-d-h9l5u^s!+O&^yZa0i#?8xCO=;rk6{d;x zEEARwrZh2ajA23~j%8xUlqMXnF-;h=Ow5j-(u5P!L_s{ugw2#D6mKz2IIv9QJe<-* zEz^X10?UN=lqS0FGEJOknK;Cq{k+EBR7K2m^#N1m zji!pHOjYMruG&+nq+Geixl(26%Gt$k&%+um^zZ3kj&IRRa5LAxxBG3p&YF0`C-HhG z;*C4ww=an|xD~H!8Na74zIo@XO)85jw+gKmHGpD6X!WFcKx`E0_#BfBlv^DYA$h0R zRyJ_mYV_>K*VjCbTJkt@%VYE`hwCdGq82$ssym>sE}Yq%Wv)+Diu+l<$khEo)h6(_ z6|JVSk){ftO_yhwt{kdd7E&o+UMcsiaz#(2+x$ho4l{flapG~f<>PBU4qXnf+fS zzum0?p%gbVj;Fo(7t_^K<7s3C1E+YbxC;hfE`!09%V1FEE*SE-42DeZg5d}G90mjD zwY!nBao^_sw28=K8VY!C6tl7jHWCuyu{g;7sK%7)-bfhEgtrfy+6=N-l%p2zSBo zgM1F>44jkiDP1y9q(gFI;qlj=w<>b#IZP~^|fB%1vgl2`7AO)0M^#xdoUuyK)y zXX1A>!9SX)_sv-tHtC1MIrRTG($C*pcLQ^1D$oJPr5_IG(8R*j;jOEI^cLP2&%tUr zPq?g(7zfmfojzzzbTMruN9;AAONw;BIhhuaQxd7h44p9!xtfCo!2c)UfoBWB>S%sc z2HpVZR+k1QXvuzH;iKRes7EUHyg%hNmaTrc4ZHTjQ zVhKk zlEK3pmJq{XAGt~yNJ8SUk1UCV!#;8~%CN*8&cZ2~t8jv3DGtNw521RZV8mG;UAgKb zq_%L_N0yYsSvcXjN*PFm;xL?83Kxgr#MOY#l864gRC_KdDMZ)I?f`q5+!%YBIH8-_ zylK;_IIt-OprOI8)M~g(7)+b-N_DCJH2I#$G|Q)SSc}}(^0+h?y=Z5!HER2B>>r8S zmlJ2@BWL)&FDZ=HUFq?&emrZl$?V_Qog#auQUO=FaIgDSWZ>X2bj&%OHRD@Z!hXH? zWnRa1m5!*GixD2Vig7T^pp>ZT_(qwtzp?8|Kf%)=b8zSN+s$xU>X-O-Aa${}Xo<%C zdf(2DE%8P<@hk7QND*guRH$h22ksx=?U7ln|7$TR$kPz*X=JjqbaSIvWh~qWh;`$; z(I%n%az$TXYFGgK?xe|qTj;|;%KUf;J2~M>aWWv!$9K|9j@o8Xr7+uLipkkl73yH$ zk~DdgpaTclHw;Zqb(s!{eJ|DIA!08}W#8#E3C|$3{~(&bc~E>T^iiOG{CEO0l?Qo^ z|CNY;1?r00N2#e{G_cP2v1x9qP~tWI*O>8Fpbpg0F_7S3Qpj-@!oWED$7KV{P1(S66V9&O zg!3Olvvx@=n5>0z<3V2IAFH9i1a*E_u^yv9FP z-+u|}K5o{g`!Y0!zRhtQjx2u_MS>>+O*s2BE&^$nl>F_a2_U)g6re4vg(_Gfb^*PJ ztI0aA@eeijuRxt>Z1V_6Lar7Kyv9FN?7swc+9mA($KOkuVAj!CZsueEU+A1xULj89YhhYO>C2%>EGS&u7E$D&t8oSI;wfjoBYU z{rPN+O2q;f@;0Zi)zMgP)?0tR0;FA11D4EON!b73%_)CF1qdi@U|S+@a|*kF{r6IU zzzWeDtPr``-OXz}VTJgoQwR12lfed8aLR|{ZF{`N6ZQuG>C}NbN(7HD!OL1XT;lT@ zV?6(L4(G4HhUlGUG8PF=8;Qs6`0;wl$#+XLTJz2(K`+1l&(0?Op&}4?2=(jDDTA=( z!y!`YBGdK`8XD~tDV1oPlY56ObL8puxi)794+Jfxg7-EroA9D%=4erLUb@}u-%ds` z->PgH#{L>+=qQ?z7%02luIsf;OI$bKq=*Z5Co1Go?mvM`yO zEaWw2&%!^Q4dB9mms6)4gV^QNDaRo8Z3I(|L0nA?5Nxvblw*)8+rgnJhkEu0O;c7G z_9K;34)yG+^?!@z`1Vn-YjMKyy{U$8JU7(2azhT<|(OTS4gL%j{TJ6lo85) zN^;5w@vCE!Mk+owb4v|4gU#encsdOvuJUvm*4NP{$Z}jwZFr4YauH9bVSe>* zf*8!r3?!DDVU(xSOi-}7n#%rz8}oD;=;M_Wf`hABC6q8XN+fST4dW1koepn5jl-|%>fcNO;^{Ozou)I+W|IAY|6qXx+VJ?N zcaW#k{Kg{xPnTYx;NTDjPpA0}7xHwP2|Jp3`)R<}OU5^j^Y+uQZzkjIr(t}mM(rL? zr}@=-;%}$ZwDb1U{Pw9Go=(HlX^bY{p!IKGgyM4BQYAg4w)DMJyX}RKRts|9tcyUs zzm&MAd_a?MBP{CDCi(4Zy*)u9Au|wnZ1yKVF|PULr^e6w3^JgdR}+tI9oyElDaK_{ z<@h&fC;c9@{;_Rnw+5v+6Ck)Tz9By8C&9zs|Dw{IWuYI-`p({@AF-Cb!v`;ROAFHm zb0@~<#wY!H_>L{pbFMTWmGx(Rf_Bo+K?ixx@?2a}u0vvdl6TS%jnC_t^L^db=)ghN zcXKEIhIQrQ5B5?TLLK8Dx1IEZ>T&DumTmf@h%Ru`!~mwbB9HY&L(cfP87Rv9PCG|k zJjPXc@IXUTfjO(BN(MmaWphJyJ6BY*zV*vla=FL`%B_wP;VeFQpbt7+JKX_lUV&RK zD0&F6?V9sC20lB;In{yO#D_RH@xcSlQu!IFSO!uto*SysTv5&XhA?NT2#%j}9v?i= zY^j(7Qjx0x2FMkhQys}oe3)?+A3V_i8Wp=hDrRs)bq!Zkb2Tc0Gftex2M;t`DrQ^g zz|sSe>LJ~X(A4<6_rqoPDfEQ-`&BS#Ie-4x2dNGoOZjDoO||Di9_%Vf;^ zUcTi&sFgxGvr#zF{}66^mrQN%%VK_Yt(37d%EC_mhw7)-%hdM0eE%Vz*j9q44b!MK zqdhg5SPEsVEGQI#sX)N|NTcBBE$17KtIDe7bVgtB|-G&Dm0zcoQ4`h{A|Q8uoZ^oTb4a-ng~o_+RsMQ zXNXP{5D=J_miXLs8ry%%!QZ`{RFSUkjvmMD?L9qI5$^U*9;(bA)Kt8jom3b<&(4%+WC0_cF{zG9WY5Bg7_0b{szZSH{I z{J=-dHeaDO=eqn!T3udY#IZ#?;g^yx4=rSH@a$37o;1+IHGI=1;XvY7958#oj?w!& zIJ`T&Jv^pQ@r~9auduWQeLY0%a)&h?1cQfb9%rq)*>Yotj!2o@D>;WUj}gA@zqDCa zxR9Q`du2}YKAa}bmwR3~=9@5mv_=E#KZ33wk+H$&QTr^$Xl-ARnXZV&JRj;XS|*m- zMW-d8%q#NAy?sUXW~u=gUtcnCC6?OTErTKn!zckGG!vPCygceCjw+c;?QKp&zykb6 zg4!!!xs;*d@?c>+J`dyXOUG0EMrh^1p(At>4c#u&n1}W6>ufsLNExQMd#sPadiRlf z(kh6(K_hQ{8>p0lE_7zVNPmT^3qF8CSwVC~`S$hED8ktMfRUySTq8xyb);>mYy>Go z2mpathS3NmdZJ#HwP&LIy~!s(Y>^sli+BGREl#8lwj=2$=}|;N@@QraHjg$+6h``c zM`J0Y-4#SUt*K9o)>O7QR=Z7j1WS{l^|xgvh|@DR^te!t657cpsR$!F-8aE~AoqHe zRfaev??Z)8Uw_%wypN4-Wyf0jj9L<8q#OG~i6~MNE*RlLZyj#N=98Pr?J#ODB^X92 zq54u(4`wozL+Ub6MH9>R=wF~J-3kl8E40zXCR$@&flzb`)eT>jP z>JKZ^fW>JA=#%?p{ZZIr4cJ3iz;1edvnF**1&nU2Nts0s4pj{xHBoUyd>(4FHVB_W zqEdI=L6s0u&rFiXDn?XEy}gyb)RW1mv5vN&cGTElQfTt1s-N!W{DRTTWqo3_F|n?v zW}m8}Uq_-TJ@6PjtP8sVBGm^GPl(m#MSUCsB!@K9R0n}f#8it1jcu0f}eiWepo)uT?WgF?EP{*zQv z;ZQcn zG5O9;G^%U9ck7rjqFrsM-KsJ@v8p|};iTmv8RB5MeHRf$SJrN9rBE{mliyPZ6X4{w z1lQO$dlzkd@c{fiK7dSw;q&|b5{MsaNdx|*g>6lHEwjg}HNGa)_z%srY>!vgY-$dk z7wScOKJJmq=(ZDiY9nKPl_pY35V`vp;L z9lAjYnkMB{!PkjJ{e$b@#fQ2EWJfiJs-5a4yCO7KqONA&9%<{_h-y#s&hEe6Uh3@= zK6pD6R`H3}laQEDany>k??La%TV>v--$uqdjDE@O-?v9g)g*h^W6$vQJtPD2fbhbB zrgln&iRYqoo8+bA2ZLm2!bs1Kt5>(ax0@gI`hhj(us(85gB$bLt#wQH{3%W%DlW%068ZMX1ecR+{CeulY7|tBS_krReB^rSloL1=pF-g|bmF z>?!?Mvd7de?)bJhX{r0idD&mXSofOuxK36qX#8IKRr6jw)S2@wEGsVBr=w0*;=sl2 zQX**2{i-{*&UiYTaf67YPWuxgb(qMt22Ya0kax)}sz zH0lTKS5jpB=9Y@%=co)j3&tg%JT0ESNE9n$-0S|sGRqA6XXXbPMmEB$pC~&Pr82BP z>PS84Y@rw>2B_G%2c2=E%<_36qemxl5)W8aU$$r1*jr`)tSgHkvJDJ5noo9 zZto14P=;4ORCX+6nmFE(s_twd37IfLXE>DAr7%rc<|OX1s=mN9@v+k0xh_>5G7+WX zSQV9F%j`wEr^WA@NK$qzVVd~fk$T$MVk>0A1fAhnRtNRMCnphZRecpQQDxs#mwFm9 z5u@T*6P0n8X=2dR0`nqqDb$OEvbqfW9;g>u@ao?)aq4*47N{3`If*;0s?RV@s8!l8 ztxKH;nFv?u`Ve*M5YxmJPm7=ziPM=T?BdF{q}wlrOe}|2-&5{-$?SzhN9sIh3w~xV zUhAWO*TkK=RAb1*O_i=sQKukxzw@+6c#*geGV!o%ONRX&$i#Yh^<(9(*Gv;PI#P|D zEmlG%cB4<7DBA)Nbu=e&pH=lGs25fCziR^hB2fr35nq;*ZV!h{D8j4bmAeXV0?LHj?Y!~Y~kdsv4wuqSgi}0t#8<{T9*?Ut=F!Y z#=5rvjB)SipTeM*ht zXT_opG1xS;IVYyZuh{)cAAq}_E2wBEdeA&D{7OsQD(}SyUvEfEnY$eHVQ;^7;>9!O zwz7KbBCg$Ui8NoZ{&|7%70^}PQmf~3#dd)i5|E+8y1R>KN2@`GiZ4rC6^l9y8449s zlTqw0fegti+U-1O9u65YIQaT{Vu}J}C?@gZb_?6Rkf8$>3(`^xk|0C%sdB~kfu|Nj zhK}m)em7fkJ!I(JWr<3{_s1cqkU-02xXUQ!`QQu7V8h zRkVvcX#N;7^!VWG{m)VkK!$9dU3_I}`y4X#*>XWzMu7`ts6Iok*fDU3Wync)ckOIR zb;wZdWr*1Q6=X=z z?6qi8N+4uNI_cteYug38n89$sdO=!Nfyz}Tb@f?t#cqM8q##4?y1QFuOKye?wOp2X zD;DJr87dP~Lo0T-LWXWC+HEj1FNX|mHhb-vlyV0$6p(b04{p01G9(6Hus)~YD#H+z zVD{CX(A_?XsXbP7>V)q0RMF1d%)A=98{F)5Zc<7zbhqN9i{Wrvd+2WQ@CExY1=SF2 zUyR%=WMI)!CTugL?mNP4%vL6BSK?&}yjV37GW1nU?UkZaFJ!1t(e9#|`8UW=h}mnY z*ss$Mux-8KpR_y~B z>K0S`qUbaX8LC&b%Q7=3K!)ER@W3(YdWEH!5q-{OpqHhtI{^HMjFeyEZ`?;0Y=Lgpg!)u2brm|X`@w!fA z6(=$Sg?0Z6qTCCA@fZFKfGXbg$OZNJLiNmBqH}vN7oG8e&P;1Yjl=NQFNg&%7(j{? zT>q!$n1$D#Tac}isAtij<4Kfq7c<(4Ohr^?eI&M_M-qjJCe>iHhP{TmJl-?9bH=#l z5vrs!C%&of8wc1wu_@nrc;K;Whi9ZTH8Wvp@wHy&ef zy)D7<_4a_>ee?cx@yO`&*X$QJm&|9sI7DXakBdtjU&j&qZ#4P)&wax(otfnx%otRp zzI%pb6-8RjVOjZv^}P1uhu6wU4R(wHihm1yxrVV!xc-gTZVav|+#e8yiA#HiWtC9V}u)sQOzbyh3iY!qwR?XYXJ++vTq8 zm$Q2?nhjwiJHj?Lgc`qPBJ1I0HJiLwi>|Sf#W}cZ?u#3&Y&mXa?V3`=sDq4*um9$A zPI(0@U+NasNII#pYQx7DS&fH+4OkiaqG)KA)oxabrYk3D)Y>uXF-Xe?&ThYL3WU9& zjWQc>wPxjXcmzSOblZ z8+!;(YH2=4hd8(x2J@D{9EkT|Fl$aDBYB2+)UDM01A~D%7Aqg+Mc{vwyq9qB!w_}xIQT*g zrWi_IBMfH6X=DhLyiuo-d%6irpemuraBy)9<~>wkO)wah z)5tqefsHwh!S42-7+4R*Zb{g28~R4q;!qV8ZFPWd^;3aWdF z=@=_JWF|DRG(X=E@|U?Wc>jk*bo zpaQGdL*N-Q;8Dkv-FU&k&|v>(!2s<$fYh-6nbCiYAu!)#Q3vLG!8jcBkjpLshg*cf zltXpT7=r;dG#aXVcTOWg4ONHg-qs$%i&~sBRA5in;hdji>Y)PLosOvo1s0lE)`9}d zGjy3p9Yc2G1p`xq{htK`W7kr1b8D^79Zyl-^##8HYCFF&~P%@3SG$0>C z_VT4)(1@kA*3^q9=TpjDGhryoU}$ViC$+s~jG`(-Z1ksnEg7Mr>qAiiBRCwnCesy` zKRPHx8|}^%cSZPnjeW!QB-Ho?)PRFvf#A>;;q%iH6eU!(F_&y|dY~62OsAB{#L#=N z)TXh%W}7@zAGs?N3r^SxCnKn%-BcKMjj%9{j3dLeG>1*-!i-agaxI9#wVS3*Q(XVk zB&QB1J$cDId-z)?FJA~PaHtCrz!_@24BIVJyF&I-Xk(+3sE~^Fd&)ZviaquA%F`=l z^c>||G^gw7S!ms|DTvo{9(k#=^MPCH1$iaWyGiP;H&UskFYq&siRSZDD-VN|dEWnIxn*o&T=y&pe5so%YV z<|Uh6vTIG0{-Bnw;B722Hs$N3nR_Gc4m|haV~`NYHzbz+pB}wj4;{TMkFaS!71Ol* z&E}L%Z{F?1lBs6;IT1tGverC)WVdy3q5P`*JEtj46KPEo7TBY{<=~pp;hqYVSAR=668wAL zV7XaxK)`T64mD8tv_>^1-+v@InLgN3UXdKDMWs>s^F}*L`+C>L?7 zRHgA9xS4n@@8L{hOdjm`oq0Ze@V5KnCJ6eNDvVx;$)t?o#OTA6gmzatl?sn-Y)i)b zjiGB)>Aj=U^xlpu2w0miQJXG9BYjH>3RFSy-eS>KC9cO z9Zaz>!IB4!Roz_&I*^P5nDxncY^w|cMINd!p$~S)#^zCLV(Fu#V3`;!6>Wy{<0t03 zWBvMjYD~2AI;k)UvArU|d*BNU)z?;HhNaZhRAA}E3WT=rhyt15NUc$AM)?kPR$ys0 z^g(L!V286d{Wyv)Nh7Qe7(r{eBJxIED`My!C7IZOO4WSSaA6geGI-F2I*5uv(T7Wr z)%hoeuF;dtuzo&n^iC=UHPV@&L8Q~0qx~saz^8qb)*!kcDRYEAR@P1&ZPCzf^P=2n zt}2njlGTR#4tLTE+Z{q>sl}8pg|Lv)M~SMYFo*blF-E{m@_Qk-8Eqo zBGvV>wm*5WJ{f@}wFcJ^VZ+oNE{LT>?axD*9k`X`AzeC%s>WPY&sP5dV>+p)u*QT& z>Z;ey|Ay@qYo*L?QoK5#IM^rFoQD2XhN*3KY->-x*BU7jzTHYgy^uaQiG-|A* zFB(g0rfz*9Y!=#|a+KUjnxUQh5JfXkHThbo_?FbI(9Yw4#_^_J1@@Ef|yEd^eA9?*GW^%|nW*e2d^5j5788P{D^vV=)hc@RE7}QTag$pIH z)M<6?!iTOt&=S@Bg)O}znf)QV?%ISToT%g370l&rqIRL~IZnFho=^6YXt?gO+0S=vhQByhb~WgoeM#Zax{Hh!{bTk_ z8wT)$Cx6&b$m!OHpZ=QqRA8s<@T!EetKU;^8|McsP8GK@r(-vIRNpzonBAxFq@}qJF-&_cl+jPrxzQ4$i zS{ZyxAygjIebV{60l zKbX$<7x{5(2A?E^%47QJbiTjHkFByBeU0teOIreg`n(v#9M}@Dv+ooe0;U70}rYYijLxiY`Aasi@ zDd&w$-HlAMXVFwib1KeK~uyp=OGM*3I-3~XejsZx#G8PIlko$ zQp;URer0e=wEWmZgZxVou1{JpN}CB0*KGUu-1NJ)0-s*0;nokoBh_A7sy(*i=ls>z zT}4`>^~O|8uPU}4dFWTQ8h`$+MtuLCCw?_+N}ju?9?UO|boFVCJ~Z~wJl`m)@Mw0+ z{nmmLHvu4v?A2(a8!BjPjJ1U7HGoJ_gU>@ys4c?ihLGgZ2*5$>!^0}JZ>-%FJZ#_Y z7Kw;RY-o9>(0$WL<+zH{EdxM7Wb~8l^A;YZr^*9u*BbyD-4|-0Q(RQCbF))iR{-u0 zx-4lI(R;68wpsk>dwIa(+dZAu1=ZOtcI^dqED$k9H#E`K1J)8+5QruZ!Ls%UqgbK( zV-N_GitRMG`%F*UpY||89QmNoeb-3kyo%C22*i?{^P4?Ohdu;u-vEKoMWtg@_+*hCpnw zDL9?m@~yAHhXvw|(Ty;)^$r_}a0rB~x1dpfgi)!Gl{*AtrK;`EHtxFKwx$q>BdrSE zZ;e!xRF%pg5IMQ$y}e6^S_98)hCtjzr>s-WewUlF9s*%GAX40>U=}Uj)&_xaw<&1L zZMi>C(8&T(VRRz~ZS89#5etFf^AQvpj4=8jB zRh23s5NqIk&m0)a?Crx>edYv!exKp>V3iRg7Im^~Ih+6jSpVpDK1uf=Yt z!14~{j%uSDiD>II8;NHSh$bJwvY`m08ln1A5D0{-?MoZ?nZC9KEGD`Yx@(P8j;Jbq zfk52KJHOetbf`OU`&I}<20G=0YIb5?iX#M~ZdjzaN5L#pysZZU@y({7nEGZTfnO5I zuu-z7IpBN9Se?LVaW%S_Dj+$sye3P=sexf+sYVESfjVJVk{hfxi-B{i%aW1h zP7R^f%>gNH3@fJ_HV}5zX31n1Qx7#WfOcm&G=!$O!Q4a0uZkIc{o+=YC1cwh(6VIY zTnH0s^Fue-UjhhYn6!@gKlkMu7QJ;$+yhT3Z_q$(94W__`wyT!@YJ4$K12oeu^SOl zj?e8a%p{S<0?hzLS6Wu)NO{Q%YkGZ+1e`7(yQIQj-Et<9-v;<`T}146_d`a~wQRo} zZaH)3U%&jSFTIKFm+xE7Tx0vCp>!78F9|JYqJR9-?`uLa6*_kt$W@MVpoEYc_ z+%G#*s_@uagfY>Ohka~)*%GmzIf;P!;%=~pIc4~jxh*oa5k{5J^n!I4hY5GtFySss zCfp@t0_fripqnRvc4G(qd*;So##l$ZpKzD<33u_BaF+>P@0bAEa{}ls6F}dY0Qz^# zz30E$5;14OU1m+V%ee`6dBqO8^ea2)BPZEGubaU1;t8OC&z$zj73d=qyuNNi*Kba^ z%kBxFS4;qXZ35`h37}ghfc_nG17{So<%1{OrD(!kzD>Bx1h1z~=(@!O&`J|PFPZ@Q zcg)=pZYWjwGT|;x6Yiou;V#h=K$lMd-8KRAgdj_u!1V8#^E~ALM%lpsWklF`DQSy} z(>`RNKc1)_My4_;@{ID%%&}~R$HL|!jPlP+)rN>q{7Qm9>W+&+=k%!aKWam!{1Bg! z&X3|?6+!w%L3X@4yqm;q`*b6B)=FVK?E@#UP{7UtYYj18WhO+e=>*rUo<_LJDWusGrW2##+G=_%f zUI_8sqfUtA8RgLn#@xSrAc4ur4Qmlz7W`rPuKXoSgGFy+L2;gfeg39)s zRoND)6L>}!m-6Ukj{CeoVytb$bN@*x11-CtHMw3tFW;F{*#Bm}6G1smW0g~Bb%NHH zEaZ3%2NT7Wk|Xa~HQcv0YXsEr@rk0aFIg<%TVK1N*k4|91X8RQmjXpQ^hmQdYaTuZH1Tk!29|0r z55#Qkh!8R)cVB4%a`>)PHQo>9Gi9=*&N2rrPBD~^9u%0LfISaUXBzB8Tz z{&(~JSgKp>IK3A_$h*}E*H}~HiWIk4P{YRs+r{dH^*D za+(jO#C9xt6`dN)Qr!^aH9RncY^Y8MX4UZI6gLE@;p5V@m^wl6OO`6DhUbHMIMBn3 zST)?NHY@o^bN=`|e759Bq*H@9tA-q{!uBz+=C>@%lAL5 zoZfM2&|uMv2jvt@iI1{s_*SO|WU3o}yoLvbkd4&|(X1LyOmV}58a}RvFI6XO{gUN6 zUcjQH2ejl;Ws*j0Jj(H%0?3{1!t}w>Bs!(L2N6K~_PMJa93S^5btRD7%3=|D0c29gKqfZ7rO=8* zt^kLzlk@4cJ{jQrMDLyMWl~~B4A@=aFF}jK#4z{)S+5|pmhY-puA{Q4MaXI zfmlXH45=pD(8s9BWA$_)#*uz`%-4z#*tBVG{{QiN4VVY}f9t&l2e3?t0Qg^{}hEJ>!K2&S7Q7h1=%nVt+whJQ}-c zwTRB%DuTI~pkP?7rS!F{Z!|Zk$5DMBE|{?hZaFggCcmCqYVuk9n48+1hmMx}-0mKS zOYjen`hUhp8;YJQT0Kkp-OZwIq=uEDR?$0?%`4t_nm>`7UAb?`yS*ESrs?b&UbRP7 zM-YE__>uj)-=82gD)=bDk^U1qj>yR_BMR31V04tsgo*gS)0?bmfWG%}YF6E1->(H}r( zEhhcK_Cn|j+n&emo$Xbbe=rpXHJHMjFCE5%4RMlnPe>7YnzkFNv`sX^1H}owMm5dJA>wQyF71)QrfBTsJ zlhvWm!XE4rQ-2WUSifFz$Lez#Fw`ZKwC?H^8k-xBoiAM@uu{}O7cTE0yX?@aeNsq4 zS<{#0XIhjM;)7Prz0tq4`W=65Z{upZfHWSyZ=Upg_zo-j-HgqdOQuEcXgEkeVLexX zh&$ZZu%>Z-O>hFAz~PL`wk~-13@dE$lDj81UGewr@U{8yO0q9c7X9?8BSsSM*hia2 z976}c?D|SwBy%P4)ak=pm->D9cC2vdeUeMkv8B1Q6gJq8Jh-f3s2;W|A@*va)ph&s z+izn_ANyawhu@4}^NUJSUCf*}31@c-C^oFrk-4~A?I%QO%ysV%m+vm0rW7;NJny!} zTN><)dV>Ai2SsmlEd8taEgZ$-uM=7pD#QjJRdl~~!OH&A!k$mpAL@TfF|K)4B-Nqz zHJcLo_6Yi}PQ07v1@lk$4)a~Mel)wVwAM1&e^^0S@oBS^@l*G0sTuX#51VzqeY+(( za~DQ1`PlQdf-|;!FEfxR&5_StAmTx^mzN^%SBhG$wCKHulG1}UonCzVhi|=1mf$F!*_&KTzIQ8_1b})9R;;6aFJs5-9PJ-+7{OMWJ{@LY&ATb?-gum zRfsv0N|>YhWPSV0HFNZemgsdp+*g?RbKi_$*=-vRnk-Rryhm?6x;iUNLtkEOH|j}s zY<$GYZB@H_PNmJcp3xwMbipFrjmXs1$A*G;-@kZhv#moyrU%eb z&Fn)Ayu@8iKAsBQI;2v5Y1X>f+@i*$Qpv~!!xaGE0C9JG-jNuv%7su!`S%= z@^~r>_P_lU( zw{^Eavd`1q!R43-qx|$(`I^;lT11ca7~U!{>}2pT4Ef6v9kkBTvUv(h1_@PF9jl8E zrX*awZnTDwZkKV!-lxt7cIE7;4%@C(u-!>A&g(U;rD@0K$~v2R zP{e4`_{{RWHF*5f%h>%wnY%VMwta6|(^&oJbF6dqSNyyupVNJe96!et?0B|FW;7_^ zSxwjTgKAnKA*ZGvol9EZZPT3LU-)tHj^u5=gZ49DZ5&ovT0dXHEepP{ey5#Fyf%`r z5J?Ot*gQ{#nOjo@o4xkmlTN;3D}dDRC|w!akJEsA*7Zo=+ipUIHM~e(JbWj*~OhlT_+-KqWI-yyMGP*me(=d|v z2v=;&uiGvzw3@jr(e)nbYISYpk`T}3bp7nZ*K}Ua)4KnQP|A|Qt3GEUbJR;Neq1n% zxKZt!=;EA`wUvSev2!)s<{MA~&4T4t>^*VNu~n%@@{vu=3Fl`S8}Yix*{fYci$AsM z&)=`swt81p#7lX6gBOd)Cf9}jO`hkl#OO~1OcjV5Fnh`op z<%SUQ%-r-NCugsH@g-Z%sCi}N?1QpzRbOxXY;4o{E~4zZ{feJVkJ2@YW*k+V(|0Kt z7IAQ`*xuQraVq)Wr|rJ)ekA#Bp!8YSg8tVpM=XNpZ_^m|N*cQqP*0g-`m$}0?bfwh zMBDD43wk8akNj{{>$y~#*y7H4!R4*$cOKmSw)N|nwpvBReyi1a(MPXTUhIFjdD_>e zwh$U+{eAWCJ)4cwGelDB=G>lPD|>^|7r%Y8(vh}jy72z1ht8{+M@ndZ+|>*pLr4T4 z2-K~+vf~7QlWpU>!hHf`ws$RyrIb!?65I6=W=9iU?0fjjS968nkv*kYP1he5UFP?wt>D%{ec&^?Rqt^{5 z4(t@$a4+U*zkm?N%lH#~!_%_1o`mTa7YD@kN*V@yv%eO$UH2?TWYzNABL8m|$CGNl z&JLfwRZdPyQtT{j;IRH#oxJ?ku<||fi>tp)TWx!netgE~nkyy^4MmktJ%!p5rPqvJ z<3E$uQuT1wJfT#hi=DQu>PO|isRXWl-4wPe<=eOWl$&dBOxG8?w?Qexyx`r#roPRl z5gJ{cS2w)w|8(Gto$N_UZN$9}=K{;LYXi#9@*Zro>gee4Do{4O6)5um8oBDICfx2n zK*A9cgV7}*-3km836UN+##f|cI1mA0v?$%BNJuG2D2)g>Lb_W?Y3UAiNdE@D-=E0q z@w|6`Z0B&E&$-X%d2T%Sa;^bRdP2L}ilolfxforiJapA{tIHBF!KF|NiT4ABBT%`e zaLb&k629bbJw9nn?W6c|Rz7zXd1%NtxNigRTXiz!m9{*ia;k?$1!?fHi3pK0z0bcE z9N<&)h_xDUq@9{U70Cm>g4XKq*&pJdd-ya$&?;k!NgLV_>wxs*lketB)~DVyNVr-A zoz-gOOd_}=7AmMGT3TA-yk{hN6{22u+w6Msb(obZ9|48kIdki`t&Qfnjzqn)%GQF~ zlpSq5(xC1d6b^UZ=E}9(Di%X)ZQBOvhZ6%Y*_7H zB#@ME0lcl_TJaoKTiFpKF&vr`F!X!m^Y$TVpj<4!#7Wi&`w{{PE>tv zyiBMhaxqab>0Pd#hj+NwV@!V0@@K9HhEn4U`)p>)s5f)ql-2mCa^>f}T-EU;WiekO z1fE2(T~Ft#u%{}ZY|Q&&;?O97e7Gx4N@8mGp~6zho5!94&b#Xx7OeNSHUQrEQiUuS zDg*1#p>Se3oWx_QR`#G*OnCk&LzFDvE0`td)9{ZeUX2~pyt+5T{j|P87=}LvQXRds zK)o>u!iX&=#>MgP``;@cp_mzU(`T-enRU^Vf+U&m>C>VUFJ^Xq|A7gjWrwI;Tb|KX zW_2uqA;l~*bsygJ8ygxe?0HuQ)iYEe{1ma8fP8a>b?(dhvj(rBARdwV=<$ru?~$$f zQ3RhlpP@+i(d`MDufAz}aEo}(wLk?jl6#zil3rZ#6M9D*YpMi6pO|9I+_PH3Au)DB ziHR!zvrI;pQX+w^kFS1fpGZ*_q{kHJ#m zq*6F#_PI~}gE$CuYn~elh7AC(6@74nZC-3AfqtihlfP~WPDX!|kdW9;0~12xb@Rpp zo7-zAb00&Q4{M+9Wow7w*B2Z4Qrc2om|r1yR}qU8h@hV`|d8m9Xs)cqfSfM=pFlD4}F8>^?!5_6eT5XETCBFwtNFkBwt&->qtz_ zC5DP>CTFN-d6uthbI!Q-_P$9^c-c|Tpma!Cm87!x12FmfzVJx@$(=N=OA)Z4hm;K$ zs^J}+$XnZQKFURDxo9iiBxDZZ9i9(Sjic4Qtst^A;x%g7QO|F(vF2Gvz^u3p7?gg) z%$bt4V=&y@(4go?rb;GY^RVNSsizwdzom(B2u%*6s)Y6pjK zCWfy6_(}^YT9gn8b-(Tw+^fmStNl27J|<(fnNx>4j+@s2!PVLRDt9mcK1H$WB9 zgk3~n%!g|WBn2&a;Bj?JyD-CKSlq1DCUB@-%r1o9Xv2r`Am2Ub@Ty(y-Rh(k!x>8q zyF_fxey6#OSpCj&xWRhl!5rHlJG~Uw=CuFd-bKb8YV5zMO|ZVTWaalv>%K(RRgckG z3ZC(v1+WJ+m-9KZ0wlCo^-GB-NB+5T(@ffMznqwzb|%kDwoAcb6ZQ#hBAYWtxB?zK zoU9+-shyhqQ8;o$<}><=qy7StcdKvhpQ0H2F2Z?wmF2*u33@h1H&EzXdS#q#R4JLI@bZ*a z(s@C*R6#LKPa}||f@eCzQB$B^sN$Y59*P?TZC{eF*VUQ=QRV8A@{tGP-&J*|GZv-x zE@dN|4c#UQPRR;R;g%&6(|o_tz#e}6sd_@QxR~;j%aveS%1<-;fW5@sEN|dA%MuP{ z5^xkxnlkI+hSgy@L4%)7>^I<0fMnmjf+&;VeAnfzhww$^yO41Kb@S~D_k6$^yIhlP zV>cXp9P7HlwAl9sA2$V#QPM zx^ZiMbdAvG&p|CHJW4}7NCa8zl<4wRYP=kK)_SU@8+OCn@o81(lk7tnX_;w4rxhnW zC?}H7lPqe|6DeIFD?=uy@CqeEX!4aJ@ypILznH(4H5gIIWZa-<5%`LUoJE$s3H%@_ zNb*tXSgWOEzb5vXb>xxONdPxYMrTj8JWn2EX6pKUm&?qRGheon=&`5o>gX%uu{4Q1 z{iQiElXJ|Z=W#1h!NGMMn5uHtdmWYI@;;Nu4=Z^=jYeOv&52K+a(x$x6$ zTq6PY z6hMu^>V~K9{fNEI(Xr`9jaThRp*)xgWnHEirOHKCI2W|#YqwoAAEi{cMw^&vj)8csgr9o%k52c_^}%(w`lxTY8-6SvUh%Qc zCy^pIlMcYq%k(qweO)4c2q=0x!`xpX56{n9oI95lQ#i@`6^2uOF%?%1anXVt^<627 z#vEj2>*91-&cdI!G+XEJ@&di^o?qWU1CzOE3)0bS|8eCY;^;yB+%)Qi_}2uZYVC=_ zuia0E!bCA-m*=ypXoW&Uz0;db1APqkmPt=I{Yh@Vd27g+9?sl%*di2e48(EF^ z@!B2w`%Tf@b0oR)h9jcW1$$Y`9$hjjIfzgi(Q)7Tk$vm0Bzft=(>di|`TcIc*bI5? zL0*>w4EsjmY*Me`-K7lt7?T#A{e&|}JYujeT+H=RY^yV5+BS=-3*$+6)Y_Exnp!3e zLR%=V&Bom!PxLJRT-%RGnFJM3-TQb;nw_U~d>KlcYl3~u2;x>siAi|3S z{@V5Tjw;;%F~s<4q2~tdXGsFrY>(=QV-i}UL-pXwE%ceUzFaiBLgmpGu1Mx2s=TFR z{ah)`!jhiO<-SMcq6A(!HH;}Q0QF8^>)`U%(a-`qDnlO0vB{O0pxM|D(}7yeq}7H< z^*bMaM)J~al=iHx%Zrk9ZA8Zx&uiJb98*`~{-_#0uZ7d=>6(zt>?uBmZ3S6)dZ*!eV5>QU0f_YlD z_bZYqt_Q;@V$wHCgE4|TABr3AsJ{+LL(zbUzJIuB@Jam&<5S3LuZ741(kYUuLYMB8 z0MJpgxE&5o@z6h)b9EEhA@BC7E|r-Vcf^D-^BvRp;8`FW8J5EHf2HxblCQ5|XNyAE zqKtLi>=BNJ$Nsz%K4M!ZOmg6Jpb#_ohE&)pfeUz{LdI6voW+DbPU)QrC{gX`k(IZK zxjJqh(!=U0^T_;=)992dET$KD)#~1@UZ^{}e6(IhrwsM_r}-b6iVY^9X9Fg@)*K?t zhNM{~3L!qlsXCyJH6ztFV6GSCa?d%AvJbdqH)Z3<-r{#?let&vKi8{AO3rZw(2}x< z1R`2{>oJ~G5b8;TH)4}7)dEb|-9MkR=PQA1?H`JC*wAfSoU9hHL;b7`=TpRxP$4N`(e@9WnV*df}U$RM=Z*55OG8z5GY5XldnJSDDU;MMlqHQUabDdGuVI0&8o8~ zKmF$~tP@XqR;|(`+8vud60*8Z+m)Am`w z6Se-x`RR7&?`l1lz&XSDDYZ_p{>awphT(CS^p~Va;{12d^z5!qw?K}4(J#r9I??BB zkMS(P>6hMdr{tH=Up)c%ufEAygwsjy@r&@6pp<_i{Qvv#?9$VT(Q#J%OLDO0^q(92 zZ;pI+@#(OCoICxJI*pUXKl%T!OzJGo>3De@6@LjE6z6oxaCYJ82=V(uVXYGd{td2Y k7oK)gzaN^5HqM{IiIy5Z)}vu99sB9PW;uK?tg!t<8 literal 44509 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 KiDCCD5Dx$sU01#U diff --git a/whitelist.txt b/whitelist.txt index 7095e567..ccca824c 100644 --- a/whitelist.txt +++ b/whitelist.txt @@ -93,4 +93,7 @@ nom wmc util tmp -Namespace \ No newline at end of file +Namespace +pandarallel +isin +loc \ No newline at end of file From 48e3408ec72fa75ba11332d09b3ffcc85a646b8a Mon Sep 17 00:00:00 2001 From: "Anastasiia.Birillo" Date: Tue, 18 May 2021 12:11:42 +0300 Subject: [PATCH 5/9] Add readme, add distribute grades --- src/python/evaluation/README.md | 12 +-- src/python/evaluation/common/pandas_util.py | 7 +- src/python/evaluation/inspectors/README.md | 78 +++++++++++++++++++ .../inspectors/distribute_grades.py | 66 ++++++++++++++++ .../evaluation/inspectors/filter_solutions.py | 34 ++++---- whitelist.txt | 3 +- 6 files changed, 171 insertions(+), 29 deletions(-) create mode 100644 src/python/evaluation/inspectors/README.md create mode 100644 src/python/evaluation/inspectors/distribute_grades.py diff --git a/src/python/evaluation/README.md b/src/python/evaluation/README.md index 1198bf3a..5aa4bdf7 100644 --- a/src/python/evaluation/README.md +++ b/src/python/evaluation/README.md @@ -1,6 +1,6 @@ # Hyperstyle evaluation -This tool allows running the `Hyperstyle` tool on a xlsx or csv table to get code quality for all code fragments. +This tool allows running the `Hyperstyle` tool on a `xlsx` or `csv` table to get code quality for all code fragments. Please, note that your input file should consist of at least 2 obligatory columns to run the tool on its code fragments: - `code` @@ -8,13 +8,13 @@ Please, note that your input file should consist of at least 2 obligatory column Possible values for column `lang` are: `python3`, `kotlin`, `java8`, `java11`. -Output file is a new `xlsx` or `csv` file with 3 columns: -- `code` -- `lang` +Output file is a new `xlsx` or `csv` file with the all columns from the input file and two additional ones: - `grade` +- `traceback` (optional) + Grade assessment is conducted by [`run_tool.py`](https://github.com/hyperskill/hyperstyle/blob/main/README.md) with default arguments. Avaliable values for column `grade` are: BAD, MODERATE, GOOD, EXCELLENT. - It is also possible add fourth column: `traceback` to get full inspectors feedback on each code fragment. + `traceback` column stores full inspectors feedback on each code fragment. More details on enabling traceback column in **Optional Arguments** table. ## Usage @@ -23,7 +23,7 @@ Run the [evaluation_run_tool.py](evaluation_run_tool.py) with the arguments from Required arguments: -`solutions_file_path` — path to xlsx-file with code samples to inspect. +`solutions_file_path` — path to xlsx-file or csv-file with code samples to inspect. Optional arguments: Argument | Description diff --git a/src/python/evaluation/common/pandas_util.py b/src/python/evaluation/common/pandas_util.py index 8d627ead..5b395249 100644 --- a/src/python/evaluation/common/pandas_util.py +++ b/src/python/evaluation/common/pandas_util.py @@ -7,7 +7,7 @@ from src.python.evaluation.common.util import ColumnName from src.python.evaluation.common.xlsx_util import create_workbook, remove_sheet, write_dataframe_to_xlsx_sheet from src.python.review.application_config import LanguageVersion -from src.python.review.common.file_system import Extension +from src.python.review.common.file_system import Extension, get_restricted_extension logger = logging.getLogger(__name__) @@ -34,6 +34,11 @@ def get_solutions_df(ext: Extension, file_path: Union[str, Path]) -> pd.DataFram return lang_code_dataframe +def get_solutions_df_by_file_path(path: Path) -> pd.DataFrame: + ext = get_restricted_extension(path, [Extension.XLSX, Extension.CSV]) + return get_solutions_df(ext, path) + + def write_df_to_file(df: pd.DataFrame, output_file_path: Path, extension: Extension) -> None: if extension == Extension.CSV: write_dataframe_to_csv(output_file_path, df) diff --git a/src/python/evaluation/inspectors/README.md b/src/python/evaluation/inspectors/README.md new file mode 100644 index 00000000..92dc6a8a --- /dev/null +++ b/src/python/evaluation/inspectors/README.md @@ -0,0 +1,78 @@ +# Hyperstyle evaluation: inspectors + +This module allows comparing two different versions of `Hyperstyle` tool. +This module contains _preprocessing_ stage and _analysing_ stage. +`Preprocessing` stage includes: +- [filter_solutions.py](filter_solutions.py) script, that allows keeping only necessary languages in + the `csv` or `xslx` file with student solutions and drop duplicates of code fragments (optional); +- [distribute_grades.py](distribute_grades.py) allows distributing calculated grades and traceback + for unique solutions into all solutions. + +`Analysing` stage includes: +**TODO** + +___ + +## Preprocessing + +### Filter solutions + +[filter_solutions.py](filter_solutions.py) script allows keeping only necessary languages in + the `csv` or `xslx` file with student solutions and drop duplicates of code fragments (optional). + +Please, note that your input file must meet the requirements to [evaluation](./../evaluation_run_tool.py) tool. +You can find all requirements in the evaluation [README](./../README.md) file. + +Output file is a new `xlsx` or `csv` (the same format with the input file) file with the all columns +from the input file. + +#### Usage + +Run the [filter_solutions.py](filter_solutions.py) with the arguments from command line. + +Required arguments: + +`solutions_file_path` — path to xlsx-file or csv-file with code samples. + +Optional arguments: +Argument | Description +--- | --- +|**‑l**, **‑‑languages**| Set of languages to keep in the dataset. Available values: `java7`, `java8`, `java9` `java11`, `python3`, `kotlin`. The default value is set of all languages.| +|**‑‑duplicates**| If True, drop duplicates in the "code" column. By default is disabled.| + +The resulting file will be stored in the same folder as the input file. + +___ + +### Distribute grades + +[distribute_grades.py](distribute_grades.py) allows distributing calculated grades and traceback + for unique solutions into all solutions. + +Please, note that your input file with all code fragments should consist of at least 1 obligatory columns: + +- `code`. + +Please, note that your input file with unique code fragments should consist of at least 2 obligatory columns: + +- `code`, +- `grade`, +- `traceback` (optional), + +and must have all fragments from the input file with all code fragments + +Output file is a new `xlsx` or `csv` (the same format with the input files) file with the all columns +from the input file with unique solutions. + +#### Usage + +Run the [distribute_grades.py](distribute_grades.py) with the arguments from command line. + +Required arguments: + +- `solutions_file_path_all` — path to xlsx-file or csv-file with all code samples, +- `solutions_file_path_uniq` — path to xlsx-file or csv-file with unique code samples, + +The resulting file will be stored in the same folder as the input file with all samples. + +___ diff --git a/src/python/evaluation/inspectors/distribute_grades.py b/src/python/evaluation/inspectors/distribute_grades.py new file mode 100644 index 00000000..e9d3e3ad --- /dev/null +++ b/src/python/evaluation/inspectors/distribute_grades.py @@ -0,0 +1,66 @@ +import argparse +from pathlib import Path +from typing import Dict, Optional, Tuple + +import pandas as pd +from src.python.common.tool_arguments import RunToolArgument +from src.python.evaluation.common.pandas_util import get_solutions_df, get_solutions_df_by_file_path, write_df_to_file +from src.python.evaluation.common.util import ColumnName, EvaluationArgument +from src.python.review.common.file_system import Extension, get_parent_folder, get_restricted_extension + +CodeToGradesDict = Dict[str, Tuple[str, Optional[str]]] + + +def configure_arguments(parser: argparse.ArgumentParser) -> None: + parser.add_argument(f'{RunToolArgument.SOLUTIONS_FILE_PATH.value.long_name}_all', + type=lambda value: Path(value).absolute(), + help=f'{RunToolArgument.SOLUTIONS_FILE_PATH.value.description}' + f'\nAll code fragments from this file must be in the uniq file') + + parser.add_argument(f'{RunToolArgument.SOLUTIONS_FILE_PATH.value.long_name}_uniq', + type=lambda value: Path(value).absolute(), + help=f'{RunToolArgument.SOLUTIONS_FILE_PATH.value.description}' + f'\nAll code fragments from this file must be graded ' + f'(file contains grade and traceback (optional) columns)') + + +def __add_grade(code_to_grades_dict: CodeToGradesDict, code: str, grade: str, traceback: Optional[str]) -> None: + code_to_grades_dict[code] = (grade, traceback) + + +# Return a dictionary that contains code fragments +# with their grades and traceback (optional, can be None) +def get_code_to_grades_dict(df: pd.DataFrame) -> CodeToGradesDict: + code_to_grades_dict: CodeToGradesDict = {} + df.apply(lambda row: __add_grade(code_to_grades_dict, + row[ColumnName.CODE.value], + row[ColumnName.GRADE.value], + row[EvaluationArgument.TRACEBACK.value]), axis=1) + return code_to_grades_dict + + +def fill_all_solutions_df(all_solutions_df: pd.DataFrame, code_to_grades_dict: CodeToGradesDict) -> pd.DataFrame: + all_solutions_df[ColumnName.GRADE.value], all_solutions_df[EvaluationArgument.TRACEBACK.value] = zip( + *all_solutions_df[ColumnName.CODE.value].map(lambda code: code_to_grades_dict[code])) + return all_solutions_df + + +def main() -> None: + parser = argparse.ArgumentParser() + configure_arguments(parser) + args = parser.parse_args() + + all_solutions_file_path = args.solutions_file_path_all + output_ext = get_restricted_extension(all_solutions_file_path, [Extension.XLSX, Extension.CSV]) + all_solutions_df = get_solutions_df(output_ext, all_solutions_file_path) + uniq_solutions_df = get_solutions_df_by_file_path(args.solutions_file_path_uniq) + + code_to_grades_dict = get_code_to_grades_dict(uniq_solutions_df) + all_solutions_df = fill_all_solutions_df(all_solutions_df, code_to_grades_dict) + + output_path = get_parent_folder(Path(all_solutions_file_path)) + write_df_to_file(all_solutions_df, output_path / f'evaluation_result_all{output_ext.value}', output_ext) + + +if __name__ == '__main__': + main() diff --git a/src/python/evaluation/inspectors/filter_solutions.py b/src/python/evaluation/inspectors/filter_solutions.py index 14cfd810..99d3ac89 100644 --- a/src/python/evaluation/inspectors/filter_solutions.py +++ b/src/python/evaluation/inspectors/filter_solutions.py @@ -1,6 +1,5 @@ import argparse import logging -import sys from pathlib import Path from typing import Set @@ -41,28 +40,21 @@ def configure_arguments(parser: argparse.ArgumentParser) -> None: action='store_true') -# TODO: add readme -def main() -> int: - try: - parser = argparse.ArgumentParser() - configure_arguments(parser) - args = parser.parse_args() +def main() -> None: + parser = argparse.ArgumentParser() + configure_arguments(parser) + args = parser.parse_args() - solutions_file_path = args.solutions_file_path - ext = get_restricted_extension(solutions_file_path, [Extension.XLSX, Extension.CSV]) - solutions_df = get_solutions_df(ext, args.solutions_file_path) + solutions_file_path = args.solutions_file_path + ext = get_restricted_extension(solutions_file_path, [Extension.XLSX, Extension.CSV]) + solutions_df = get_solutions_df(ext, solutions_file_path) - filtered_df = filter_df_by_language(solutions_df, args.languages) - if args.duplicates: - filtered_df = drop_duplicates(filtered_df) - output_path = get_parent_folder(Path(solutions_file_path)) - write_df_to_file(filtered_df, output_path / f'filtered_solutions{ext.value}', ext) - return 0 - - except Exception: - logger.exception('An unexpected error.') - return 2 + filtered_df = filter_df_by_language(solutions_df, args.languages) + if args.duplicates: + filtered_df = drop_duplicates(filtered_df) + output_path = get_parent_folder(Path(solutions_file_path)) + write_df_to_file(filtered_df, output_path / f'filtered_solutions{ext.value}', ext) if __name__ == '__main__': - sys.exit(main()) + main() diff --git a/whitelist.txt b/whitelist.txt index ccca824c..4bfcd439 100644 --- a/whitelist.txt +++ b/whitelist.txt @@ -96,4 +96,5 @@ tmp Namespace pandarallel isin -loc \ No newline at end of file +loc +uniq \ No newline at end of file From 995aaea3d7d9e62da5077555eaf9882a8889ad76 Mon Sep 17 00:00:00 2001 From: "Anastasiia.Birillo" Date: Tue, 18 May 2021 13:55:01 +0300 Subject: [PATCH 6/9] Add tests --- src/python/review/common/file_system.py | 30 +++++++++++++++++++ test/python/common_util.py | 16 ++++++++++ test/python/evaluation/__init__.py | 4 +++ .../evaluation/common}/__init__.py | 0 .../common/pandas_util}/__init__.py | 0 .../pandas_util/test_drop_duplicates.py | 19 ++++++++++++ .../pandas_util/test_filter_by_language.py | 30 +++++++++++++++++++ test/python/evaluation/test_output_results.py | 3 +- .../pandas_util/drop_duplicates/in_1.csv | 10 +++++++ .../pandas_util/drop_duplicates/in_2.csv | 1 + .../pandas_util/drop_duplicates/in_3.csv | 3 ++ .../pandas_util/drop_duplicates/out_1.csv | 4 +++ .../pandas_util/drop_duplicates/out_2.csv | 1 + .../pandas_util/drop_duplicates/out_3.csv | 3 ++ .../pandas_util/filter_by_language/in_1.csv | 13 ++++++++ .../pandas_util/filter_by_language/in_2.csv | 13 ++++++++ .../pandas_util/filter_by_language/in_3.csv | 13 ++++++++ .../pandas_util/filter_by_language/in_4.csv | 13 ++++++++ .../pandas_util/filter_by_language/in_5.csv | 13 ++++++++ .../pandas_util/filter_by_language/out_1.csv | 13 ++++++++ .../pandas_util/filter_by_language/out_2.csv | 1 + .../pandas_util/filter_by_language/out_3.csv | 3 ++ .../pandas_util/filter_by_language/out_4.csv | 3 ++ .../pandas_util/filter_by_language/out_5.csv | 5 ++++ whitelist.txt | 3 +- 25 files changed, 215 insertions(+), 2 deletions(-) create mode 100644 test/python/common_util.py rename test/{resources/evaluation/xlsx_files => python/evaluation/common}/__init__.py (100%) rename test/{resources/evaluation/xlsx_target_files => python/evaluation/common/pandas_util}/__init__.py (100%) create mode 100644 test/python/evaluation/common/pandas_util/test_drop_duplicates.py create mode 100644 test/python/evaluation/common/pandas_util/test_filter_by_language.py create mode 100644 test/resources/evaluation/common/pandas_util/drop_duplicates/in_1.csv create mode 100644 test/resources/evaluation/common/pandas_util/drop_duplicates/in_2.csv create mode 100644 test/resources/evaluation/common/pandas_util/drop_duplicates/in_3.csv create mode 100644 test/resources/evaluation/common/pandas_util/drop_duplicates/out_1.csv create mode 100644 test/resources/evaluation/common/pandas_util/drop_duplicates/out_2.csv create mode 100644 test/resources/evaluation/common/pandas_util/drop_duplicates/out_3.csv create mode 100644 test/resources/evaluation/common/pandas_util/filter_by_language/in_1.csv create mode 100644 test/resources/evaluation/common/pandas_util/filter_by_language/in_2.csv create mode 100644 test/resources/evaluation/common/pandas_util/filter_by_language/in_3.csv create mode 100644 test/resources/evaluation/common/pandas_util/filter_by_language/in_4.csv create mode 100644 test/resources/evaluation/common/pandas_util/filter_by_language/in_5.csv create mode 100644 test/resources/evaluation/common/pandas_util/filter_by_language/out_1.csv create mode 100644 test/resources/evaluation/common/pandas_util/filter_by_language/out_2.csv create mode 100644 test/resources/evaluation/common/pandas_util/filter_by_language/out_3.csv create mode 100644 test/resources/evaluation/common/pandas_util/filter_by_language/out_4.csv create mode 100644 test/resources/evaluation/common/pandas_util/filter_by_language/out_5.csv diff --git a/src/python/review/common/file_system.py b/src/python/review/common/file_system.py index 245bc92e..e89dd75a 100644 --- a/src/python/review/common/file_system.py +++ b/src/python/review/common/file_system.py @@ -1,5 +1,6 @@ import linecache import os +import re import tempfile from contextlib import contextmanager from enum import Enum, unique @@ -61,6 +62,35 @@ def get_all_file_system_items(root: Path, item_condition: ItemCondition = all_it return items +def match_condition(regex: str) -> ItemCondition: + def does_name_match(name: str) -> bool: + return re.fullmatch(regex, name) is not None + return does_name_match + + +# For getting name of the last folder or file +# For example, returns 'folder' for both 'path/data/folder' and 'path/data/folder/' +def get_name_from_path(path: str, with_extension: bool = True) -> str: + head, tail = os.path.split(path) + # Tail can be empty if '/' is at the end of the path + file_name = tail or os.path.basename(head) + if not with_extension: + file_name = os.path.splitext(file_name)[0] + elif get_extension_from_file(Path(file_name)) == Extension.EMPTY: + raise ValueError('Cannot get file name with extension, because the passed path does not contain it') + return file_name + + +def pair_in_and_out_files(in_files: List[Path], out_files: List[Path]) -> List[Tuple[Path, Path]]: + pairs = [] + for in_file in in_files: + out_file = Path(re.sub(r'in(?=[^in]*$)', 'out', str(in_file))) + if out_file not in out_files: + raise ValueError(f'List of out files does not contain a file for {in_file}') + pairs.append((in_file, out_file)) + return pairs + + # TODO: Need testing @contextmanager def new_temp_dir() -> Path: diff --git a/test/python/common_util.py b/test/python/common_util.py new file mode 100644 index 00000000..0315a4cc --- /dev/null +++ b/test/python/common_util.py @@ -0,0 +1,16 @@ +from pathlib import Path +from typing import List, Tuple + +import pandas as pd +from src.python.review.common.file_system import get_all_file_system_items, match_condition, pair_in_and_out_files + + +def get_in_and_out_list(root: Path) -> List[Tuple[Path, Path]]: + in_files = get_all_file_system_items(root, match_condition(r'in_\d+.csv')) + out_files = get_all_file_system_items(root, match_condition(r'out_\d+.csv')) + return pair_in_and_out_files(in_files, out_files) + + +def equal_df(expected_df: pd.DataFrame, actual_df: pd.DataFrame) -> bool: + return expected_df.reset_index(drop=True).equals( + actual_df.reset_index(drop=True)) or (expected_df.empty and actual_df.empty) diff --git a/test/python/evaluation/__init__.py b/test/python/evaluation/__init__.py index 31b1b86f..3aaf37cf 100644 --- a/test/python/evaluation/__init__.py +++ b/test/python/evaluation/__init__.py @@ -9,3 +9,7 @@ TARGET_XLSX_DATA_FOLDER = CURRENT_TEST_DATA_FOLDER / 'xlsx_target_files' RESULTS_DIR_PATH = MAIN_FOLDER.parent / 'evaluation/results' + +EVALUATION_COMMON_DIR_PATH = CURRENT_TEST_DATA_FOLDER / 'common' + +PANDAS_UTIL_DIR_PATH = EVALUATION_COMMON_DIR_PATH / 'pandas_util' diff --git a/test/resources/evaluation/xlsx_files/__init__.py b/test/python/evaluation/common/__init__.py similarity index 100% rename from test/resources/evaluation/xlsx_files/__init__.py rename to test/python/evaluation/common/__init__.py diff --git a/test/resources/evaluation/xlsx_target_files/__init__.py b/test/python/evaluation/common/pandas_util/__init__.py similarity index 100% rename from test/resources/evaluation/xlsx_target_files/__init__.py rename to test/python/evaluation/common/pandas_util/__init__.py diff --git a/test/python/evaluation/common/pandas_util/test_drop_duplicates.py b/test/python/evaluation/common/pandas_util/test_drop_duplicates.py new file mode 100644 index 00000000..258ff0d9 --- /dev/null +++ b/test/python/evaluation/common/pandas_util/test_drop_duplicates.py @@ -0,0 +1,19 @@ +from pathlib import Path +from test.python.common_util import equal_df, get_in_and_out_list +from test.python.evaluation import PANDAS_UTIL_DIR_PATH + +import pytest +from src.python.evaluation.common.pandas_util import drop_duplicates, get_solutions_df_by_file_path + +RESOURCES_PATH = PANDAS_UTIL_DIR_PATH / 'drop_duplicates' + +IN_AND_OUT_FILES = get_in_and_out_list(RESOURCES_PATH) + + +@pytest.mark.parametrize(('in_file', 'out_file'), IN_AND_OUT_FILES) +def test(in_file: Path, out_file: Path): + print(in_file) + in_df = get_solutions_df_by_file_path(in_file) + out_df = get_solutions_df_by_file_path(out_file) + filtered_df = drop_duplicates(in_df) + assert equal_df(out_df, filtered_df) diff --git a/test/python/evaluation/common/pandas_util/test_filter_by_language.py b/test/python/evaluation/common/pandas_util/test_filter_by_language.py new file mode 100644 index 00000000..2e496f2e --- /dev/null +++ b/test/python/evaluation/common/pandas_util/test_filter_by_language.py @@ -0,0 +1,30 @@ +from pathlib import Path +from test.python.common_util import equal_df, get_in_and_out_list +from test.python.evaluation import PANDAS_UTIL_DIR_PATH + +import pytest +from src.python.evaluation.common.pandas_util import filter_df_by_language, get_solutions_df_by_file_path +from src.python.review.application_config import LanguageVersion +from src.python.review.common.file_system import get_name_from_path + +RESOURCES_PATH = PANDAS_UTIL_DIR_PATH / 'filter_by_language' + + +IN_FILE_TO_LANGUAGES = { + 'in_1.csv': set(LanguageVersion), + 'in_2.csv': set(), + 'in_3.csv': [LanguageVersion.PYTHON_3], + 'in_4.csv': [LanguageVersion.PYTHON_3, LanguageVersion.PYTHON_3], + 'in_5.csv': [LanguageVersion.PYTHON_3, LanguageVersion.JAVA_11], +} + +IN_AND_OUT_FILES = get_in_and_out_list(RESOURCES_PATH) + + +@pytest.mark.parametrize(('in_file', 'out_file'), IN_AND_OUT_FILES) +def test(in_file: Path, out_file: Path): + print(in_file) + in_df = get_solutions_df_by_file_path(in_file) + out_df = get_solutions_df_by_file_path(out_file) + filtered_df = filter_df_by_language(in_df, IN_FILE_TO_LANGUAGES[get_name_from_path(str(in_file))]) + assert equal_df(out_df, filtered_df) diff --git a/test/python/evaluation/test_output_results.py b/test/python/evaluation/test_output_results.py index 2573613e..44508688 100644 --- a/test/python/evaluation/test_output_results.py +++ b/test/python/evaluation/test_output_results.py @@ -1,3 +1,4 @@ +from test.python.common_util import equal_df from test.python.evaluation import TARGET_XLSX_DATA_FOLDER, XLSX_DATA_FOLDER from test.python.evaluation.testing_config import get_testing_arguments @@ -30,4 +31,4 @@ def test_correct_output(test_file: str, target_file: str, output_type: bool): sheet_name = 'traceback' target_dataframe = pd.read_excel(TARGET_XLSX_DATA_FOLDER / target_file, sheet_name=sheet_name) - assert target_dataframe.reset_index(drop=True).equals(test_dataframe.reset_index(drop=True)) + assert equal_df(target_dataframe, test_dataframe) diff --git a/test/resources/evaluation/common/pandas_util/drop_duplicates/in_1.csv b/test/resources/evaluation/common/pandas_util/drop_duplicates/in_1.csv new file mode 100644 index 00000000..813f1a3c --- /dev/null +++ b/test/resources/evaluation/common/pandas_util/drop_duplicates/in_1.csv @@ -0,0 +1,10 @@ +id,time,code,lang +46846118,1617443943,"print("")",python3 +46846118,1617443943,"print("Hello")",python3 +46846118,1617443943,"print("")",python3 +46846118,1617443943,"print("")",python3 +46846118,1617443943,"print("")",python3 +46846118,1617443943,"print("Hello")",python3 +46846118,1617443943,"print("Hello")",python3 +46846118,1617443943,"print("Hello")",python3 +46846118,1617443943,"print("Hi")",python3 \ No newline at end of file diff --git a/test/resources/evaluation/common/pandas_util/drop_duplicates/in_2.csv b/test/resources/evaluation/common/pandas_util/drop_duplicates/in_2.csv new file mode 100644 index 00000000..ddc699ea --- /dev/null +++ b/test/resources/evaluation/common/pandas_util/drop_duplicates/in_2.csv @@ -0,0 +1 @@ +id,time,code,lang diff --git a/test/resources/evaluation/common/pandas_util/drop_duplicates/in_3.csv b/test/resources/evaluation/common/pandas_util/drop_duplicates/in_3.csv new file mode 100644 index 00000000..01a46348 --- /dev/null +++ b/test/resources/evaluation/common/pandas_util/drop_duplicates/in_3.csv @@ -0,0 +1,3 @@ +id,time,code,lang +46846118,1617443943,"print("")",python3 +46846118,1617443943,"print("Hello")",python3 diff --git a/test/resources/evaluation/common/pandas_util/drop_duplicates/out_1.csv b/test/resources/evaluation/common/pandas_util/drop_duplicates/out_1.csv new file mode 100644 index 00000000..d35cff98 --- /dev/null +++ b/test/resources/evaluation/common/pandas_util/drop_duplicates/out_1.csv @@ -0,0 +1,4 @@ +id,time,code,lang +46846118,1617443943,"print("")",python3 +46846118,1617443943,"print("Hello")",python3 +46846118,1617443943,"print("Hi")",python3 \ No newline at end of file diff --git a/test/resources/evaluation/common/pandas_util/drop_duplicates/out_2.csv b/test/resources/evaluation/common/pandas_util/drop_duplicates/out_2.csv new file mode 100644 index 00000000..ddc699ea --- /dev/null +++ b/test/resources/evaluation/common/pandas_util/drop_duplicates/out_2.csv @@ -0,0 +1 @@ +id,time,code,lang diff --git a/test/resources/evaluation/common/pandas_util/drop_duplicates/out_3.csv b/test/resources/evaluation/common/pandas_util/drop_duplicates/out_3.csv new file mode 100644 index 00000000..01a46348 --- /dev/null +++ b/test/resources/evaluation/common/pandas_util/drop_duplicates/out_3.csv @@ -0,0 +1,3 @@ +id,time,code,lang +46846118,1617443943,"print("")",python3 +46846118,1617443943,"print("Hello")",python3 diff --git a/test/resources/evaluation/common/pandas_util/filter_by_language/in_1.csv b/test/resources/evaluation/common/pandas_util/filter_by_language/in_1.csv new file mode 100644 index 00000000..a1ca4864 --- /dev/null +++ b/test/resources/evaluation/common/pandas_util/filter_by_language/in_1.csv @@ -0,0 +1,13 @@ +id,time,code,lang +46846118,1617443943,"print("")",python3 +46846118,1617443943,"print("Hello")",python3 +46846118,1617443943,"println("")",kotlin +46846118,1617443943,"println("Hello")",kotlin +46846118,1617443943,"System.out.println("");",java7 +46846118,1617443943,"System.out.println("Hello");",java7 +46846118,1617443943,"System.out.println("");",java8 +46846118,1617443943,"System.out.println("Hello");",java8 +46846118,1617443943,"System.out.println("");",java9 +46846118,1617443943,"System.out.println("Hello");",java9 +46846118,1617443943,"System.out.println("");",java11 +46846118,1617443943,"System.out.println("Hello");",java11 diff --git a/test/resources/evaluation/common/pandas_util/filter_by_language/in_2.csv b/test/resources/evaluation/common/pandas_util/filter_by_language/in_2.csv new file mode 100644 index 00000000..a1ca4864 --- /dev/null +++ b/test/resources/evaluation/common/pandas_util/filter_by_language/in_2.csv @@ -0,0 +1,13 @@ +id,time,code,lang +46846118,1617443943,"print("")",python3 +46846118,1617443943,"print("Hello")",python3 +46846118,1617443943,"println("")",kotlin +46846118,1617443943,"println("Hello")",kotlin +46846118,1617443943,"System.out.println("");",java7 +46846118,1617443943,"System.out.println("Hello");",java7 +46846118,1617443943,"System.out.println("");",java8 +46846118,1617443943,"System.out.println("Hello");",java8 +46846118,1617443943,"System.out.println("");",java9 +46846118,1617443943,"System.out.println("Hello");",java9 +46846118,1617443943,"System.out.println("");",java11 +46846118,1617443943,"System.out.println("Hello");",java11 diff --git a/test/resources/evaluation/common/pandas_util/filter_by_language/in_3.csv b/test/resources/evaluation/common/pandas_util/filter_by_language/in_3.csv new file mode 100644 index 00000000..a1ca4864 --- /dev/null +++ b/test/resources/evaluation/common/pandas_util/filter_by_language/in_3.csv @@ -0,0 +1,13 @@ +id,time,code,lang +46846118,1617443943,"print("")",python3 +46846118,1617443943,"print("Hello")",python3 +46846118,1617443943,"println("")",kotlin +46846118,1617443943,"println("Hello")",kotlin +46846118,1617443943,"System.out.println("");",java7 +46846118,1617443943,"System.out.println("Hello");",java7 +46846118,1617443943,"System.out.println("");",java8 +46846118,1617443943,"System.out.println("Hello");",java8 +46846118,1617443943,"System.out.println("");",java9 +46846118,1617443943,"System.out.println("Hello");",java9 +46846118,1617443943,"System.out.println("");",java11 +46846118,1617443943,"System.out.println("Hello");",java11 diff --git a/test/resources/evaluation/common/pandas_util/filter_by_language/in_4.csv b/test/resources/evaluation/common/pandas_util/filter_by_language/in_4.csv new file mode 100644 index 00000000..a1ca4864 --- /dev/null +++ b/test/resources/evaluation/common/pandas_util/filter_by_language/in_4.csv @@ -0,0 +1,13 @@ +id,time,code,lang +46846118,1617443943,"print("")",python3 +46846118,1617443943,"print("Hello")",python3 +46846118,1617443943,"println("")",kotlin +46846118,1617443943,"println("Hello")",kotlin +46846118,1617443943,"System.out.println("");",java7 +46846118,1617443943,"System.out.println("Hello");",java7 +46846118,1617443943,"System.out.println("");",java8 +46846118,1617443943,"System.out.println("Hello");",java8 +46846118,1617443943,"System.out.println("");",java9 +46846118,1617443943,"System.out.println("Hello");",java9 +46846118,1617443943,"System.out.println("");",java11 +46846118,1617443943,"System.out.println("Hello");",java11 diff --git a/test/resources/evaluation/common/pandas_util/filter_by_language/in_5.csv b/test/resources/evaluation/common/pandas_util/filter_by_language/in_5.csv new file mode 100644 index 00000000..a1ca4864 --- /dev/null +++ b/test/resources/evaluation/common/pandas_util/filter_by_language/in_5.csv @@ -0,0 +1,13 @@ +id,time,code,lang +46846118,1617443943,"print("")",python3 +46846118,1617443943,"print("Hello")",python3 +46846118,1617443943,"println("")",kotlin +46846118,1617443943,"println("Hello")",kotlin +46846118,1617443943,"System.out.println("");",java7 +46846118,1617443943,"System.out.println("Hello");",java7 +46846118,1617443943,"System.out.println("");",java8 +46846118,1617443943,"System.out.println("Hello");",java8 +46846118,1617443943,"System.out.println("");",java9 +46846118,1617443943,"System.out.println("Hello");",java9 +46846118,1617443943,"System.out.println("");",java11 +46846118,1617443943,"System.out.println("Hello");",java11 diff --git a/test/resources/evaluation/common/pandas_util/filter_by_language/out_1.csv b/test/resources/evaluation/common/pandas_util/filter_by_language/out_1.csv new file mode 100644 index 00000000..a1ca4864 --- /dev/null +++ b/test/resources/evaluation/common/pandas_util/filter_by_language/out_1.csv @@ -0,0 +1,13 @@ +id,time,code,lang +46846118,1617443943,"print("")",python3 +46846118,1617443943,"print("Hello")",python3 +46846118,1617443943,"println("")",kotlin +46846118,1617443943,"println("Hello")",kotlin +46846118,1617443943,"System.out.println("");",java7 +46846118,1617443943,"System.out.println("Hello");",java7 +46846118,1617443943,"System.out.println("");",java8 +46846118,1617443943,"System.out.println("Hello");",java8 +46846118,1617443943,"System.out.println("");",java9 +46846118,1617443943,"System.out.println("Hello");",java9 +46846118,1617443943,"System.out.println("");",java11 +46846118,1617443943,"System.out.println("Hello");",java11 diff --git a/test/resources/evaluation/common/pandas_util/filter_by_language/out_2.csv b/test/resources/evaluation/common/pandas_util/filter_by_language/out_2.csv new file mode 100644 index 00000000..ddc699ea --- /dev/null +++ b/test/resources/evaluation/common/pandas_util/filter_by_language/out_2.csv @@ -0,0 +1 @@ +id,time,code,lang diff --git a/test/resources/evaluation/common/pandas_util/filter_by_language/out_3.csv b/test/resources/evaluation/common/pandas_util/filter_by_language/out_3.csv new file mode 100644 index 00000000..01a46348 --- /dev/null +++ b/test/resources/evaluation/common/pandas_util/filter_by_language/out_3.csv @@ -0,0 +1,3 @@ +id,time,code,lang +46846118,1617443943,"print("")",python3 +46846118,1617443943,"print("Hello")",python3 diff --git a/test/resources/evaluation/common/pandas_util/filter_by_language/out_4.csv b/test/resources/evaluation/common/pandas_util/filter_by_language/out_4.csv new file mode 100644 index 00000000..01a46348 --- /dev/null +++ b/test/resources/evaluation/common/pandas_util/filter_by_language/out_4.csv @@ -0,0 +1,3 @@ +id,time,code,lang +46846118,1617443943,"print("")",python3 +46846118,1617443943,"print("Hello")",python3 diff --git a/test/resources/evaluation/common/pandas_util/filter_by_language/out_5.csv b/test/resources/evaluation/common/pandas_util/filter_by_language/out_5.csv new file mode 100644 index 00000000..d54853f4 --- /dev/null +++ b/test/resources/evaluation/common/pandas_util/filter_by_language/out_5.csv @@ -0,0 +1,5 @@ +id,time,code,lang +46846118,1617443943,"print("")",python3 +46846118,1617443943,"print("Hello")",python3 +46846118,1617443943,"System.out.println("");",java11 +46846118,1617443943,"System.out.println("Hello");",java11 diff --git a/whitelist.txt b/whitelist.txt index 4bfcd439..c9064163 100644 --- a/whitelist.txt +++ b/whitelist.txt @@ -97,4 +97,5 @@ Namespace pandarallel isin loc -uniq \ No newline at end of file +uniq +fullmatch \ No newline at end of file From 30859c0b46317bcc688a2f80f7fa62f5dfb0a496 Mon Sep 17 00:00:00 2001 From: "Anastasiia.Birillo" Date: Tue, 18 May 2021 18:49:41 +0300 Subject: [PATCH 7/9] Add diffs finder between two dfs --- src/python/evaluation/common/pandas_util.py | 38 ++++++++ src/python/evaluation/common/util.py | 48 +++++----- src/python/evaluation/inspectors/README.md | 65 ++++++++++++- .../evaluation/inspectors/diffs_between_df.py | 94 +++++++++++++++++++ src/python/review/common/file_system.py | 17 +++- .../review/inspectors/inspector_type.py | 2 + .../review/reviewers/utils/print_review.py | 50 ++++++++-- test/python/common_util.py | 12 ++- test/python/evaluation/__init__.py | 2 + .../pandas_util/test_drop_duplicates.py | 1 - .../pandas_util/test_filter_by_language.py | 1 - test/python/evaluation/inspectors/__init__.py | 0 .../inspectors/diffs_between_df/__init__.py | 0 .../diffs_between_df/test_diifs_between_df.py | 72 ++++++++++++++ .../inspectors/diffs_between_df/new_1.csv | 10 ++ .../inspectors/diffs_between_df/new_2.csv | 10 ++ .../inspectors/diffs_between_df/new_3.csv | 10 ++ .../inspectors/diffs_between_df/new_4.csv | 10 ++ .../inspectors/diffs_between_df/old_1.csv | 10 ++ .../inspectors/diffs_between_df/old_2.csv | 10 ++ .../inspectors/diffs_between_df/old_3.csv | 10 ++ .../inspectors/diffs_between_df/old_4.csv | 10 ++ whitelist.txt | 7 +- 23 files changed, 448 insertions(+), 41 deletions(-) create mode 100644 src/python/evaluation/inspectors/diffs_between_df.py create mode 100644 test/python/evaluation/inspectors/__init__.py create mode 100644 test/python/evaluation/inspectors/diffs_between_df/__init__.py create mode 100644 test/python/evaluation/inspectors/diffs_between_df/test_diifs_between_df.py create mode 100644 test/resources/evaluation/common/inspectors/diffs_between_df/new_1.csv create mode 100644 test/resources/evaluation/common/inspectors/diffs_between_df/new_2.csv create mode 100644 test/resources/evaluation/common/inspectors/diffs_between_df/new_3.csv create mode 100644 test/resources/evaluation/common/inspectors/diffs_between_df/new_4.csv create mode 100644 test/resources/evaluation/common/inspectors/diffs_between_df/old_1.csv create mode 100644 test/resources/evaluation/common/inspectors/diffs_between_df/old_2.csv create mode 100644 test/resources/evaluation/common/inspectors/diffs_between_df/old_3.csv create mode 100644 test/resources/evaluation/common/inspectors/diffs_between_df/old_4.csv diff --git a/src/python/evaluation/common/pandas_util.py b/src/python/evaluation/common/pandas_util.py index 5b395249..bb956759 100644 --- a/src/python/evaluation/common/pandas_util.py +++ b/src/python/evaluation/common/pandas_util.py @@ -2,6 +2,7 @@ from pathlib import Path from typing import Set, Union +import numpy as np import pandas as pd from src.python.evaluation.common.csv_util import write_dataframe_to_csv from src.python.evaluation.common.util import ColumnName @@ -21,6 +22,43 @@ def drop_duplicates(df: pd.DataFrame, column: str = ColumnName.CODE.value) -> pd return df.drop_duplicates(column, keep='last') +# Find all rows and columns where two dataframes are inconsistent. +# For example: +# row | column | +# ------------------------- +# 3 | column_1 | True +# | column_2 | True +# ------------------------- +# 4 | column_1 | True +# | column_2 | True +# means first and second dataframes have different values +# in column_1 and in column_2 in 3-th and 4-th rows +def get_inconsistent_positions(first: pd.DataFrame, second: pd.DataFrame) -> pd.DataFrame: + ne_stacked = (first != second).stack() + inconsistent_positions = ne_stacked[ne_stacked] + inconsistent_positions.index.names = [ColumnName.ROW.value, ColumnName.COLUMN.value] + return inconsistent_positions + + +# Create a new dataframe with all items that are different. +# For example: +# | old | new +# --------------------------------- +# row column | | +# 3 grade | EXCELLENT | MODERATE +# 4 grade | EXCELLENT | BAD +def get_diffs(first: pd.DataFrame, second: pd.DataFrame) -> pd.DataFrame: + changed = get_inconsistent_positions(first, second) + + difference_locations = np.where(first != second) + changed_from = first.values[difference_locations] + changed_to = second.values[difference_locations] + return pd.DataFrame({ + ColumnName.OLD.value: changed_from, + ColumnName.NEW.value: changed_to}, + index=changed.index) + + def get_solutions_df(ext: Extension, file_path: Union[str, Path]) -> pd.DataFrame: try: if ext == Extension.XLSX: diff --git a/src/python/evaluation/common/util.py b/src/python/evaluation/common/util.py index 6ec0da59..b1c501b8 100644 --- a/src/python/evaluation/common/util.py +++ b/src/python/evaluation/common/util.py @@ -6,31 +6,35 @@ @unique class ColumnName(Enum): - CODE = "code" - LANG = "lang" - LANGUAGE = "language" - GRADE = "grade" - ID = "id" + CODE = 'code' + LANG = 'lang' + LANGUAGE = 'language' + GRADE = 'grade' + ID = 'id' + COLUMN = 'column' + ROW = 'row' + OLD = 'old' + NEW = 'new' @unique class EvaluationArgument(Enum): - TRACEBACK = "traceback" - RESULT_FILE_NAME = "evaluation_results" - RESULT_FILE_NAME_XLSX = f"{RESULT_FILE_NAME}{Extension.XLSX.value}" - RESULT_FILE_NAME_CSV = f"{RESULT_FILE_NAME}{Extension.CSV.value}" + TRACEBACK = 'traceback' + RESULT_FILE_NAME = 'evaluation_results' + RESULT_FILE_NAME_XLSX = f'{RESULT_FILE_NAME}{Extension.XLSX.value}' + RESULT_FILE_NAME_CSV = f'{RESULT_FILE_NAME}{Extension.CSV.value}' -script_structure_rule = ("Please, make sure your XLSX-file matches following script standards: \n" - "1. Your XLSX-file or CSV-file should have 2 obligatory columns named:" - f"'{ColumnName.CODE.value}' & '{ColumnName.LANG.value}'. \n" - f"'{ColumnName.CODE.value}' column -- relates to the code-sample. \n" - f"'{ColumnName.LANG.value}' column -- relates to the language of a " - "particular code-sample. \n" - "2. Your code samples should belong to the one of the supported languages. \n" - "Supported languages are: Java, Kotlin, Python. \n" - f"3. Check that '{ColumnName.LANG.value}' column cells are filled with " - "acceptable language-names: \n" - f"Acceptable language-names are: {LanguageVersion.PYTHON_3.value}, " - f"{LanguageVersion.JAVA_8.value} ," - f"{LanguageVersion.JAVA_11.value} and {LanguageVersion.KOTLIN.value}.") +script_structure_rule = ('Please, make sure your XLSX-file matches following script standards: \n' + '1. Your XLSX-file or CSV-file should have 2 obligatory columns named:' + f'"{ColumnName.CODE.value}" & "{ColumnName.LANG.value}". \n' + f'"{ColumnName.CODE.value}" column -- relates to the code-sample. \n' + f'"{ColumnName.LANG.value}" column -- relates to the language of a ' + 'particular code-sample. \n' + '2. Your code samples should belong to the one of the supported languages. \n' + 'Supported languages are: Java, Kotlin, Python. \n' + f'3. Check that "{ColumnName.LANG.value}" column cells are filled with ' + 'acceptable language-names: \n' + f'Acceptable language-names are: {LanguageVersion.PYTHON_3.value}, ' + f'{LanguageVersion.JAVA_8.value} ,' + f'{LanguageVersion.JAVA_11.value} and {LanguageVersion.KOTLIN.value}.') diff --git a/src/python/evaluation/inspectors/README.md b/src/python/evaluation/inspectors/README.md index 92dc6a8a..e34a3b01 100644 --- a/src/python/evaluation/inspectors/README.md +++ b/src/python/evaluation/inspectors/README.md @@ -9,7 +9,8 @@ This module contains _preprocessing_ stage and _analysing_ stage. for unique solutions into all solutions. `Analysing` stage includes: -**TODO** +- [diffs_between_df.py](diffs_between_df.py) allows finding a difference between + old and new grades and collect issues that were found in new data ___ @@ -59,7 +60,7 @@ Please, note that your input file with unique code fragments should consist of a - `grade`, - `traceback` (optional), -and must have all fragments from the input file with all code fragments +and must have all fragments from the input file with all code fragments. Output file is a new `xlsx` or `csv` (the same format with the input files) file with the all columns from the input file with unique solutions. @@ -76,3 +77,63 @@ Required arguments: The resulting file will be stored in the same folder as the input file with all samples. ___ + +## Analysing + +### Find diffs + +[diffs_between_df.py](diffs_between_df.py) allows finding a difference between + old and new grades and collect issues that were found in new data. + +Please, note that your input files should consist of at least 3 obligatory columns: + +- `id`, +- `grade`, +- `traceback`. + +Output file is a `pickle` file with serialized dictionary with the result. + + +#### Usage + +Run the [diffs_between_df.py](diffs_between_df.py) with the arguments from command line. + +Required arguments: + +- `solutions_file_path_old` — path to xlsx-file or csv-file with code samples that was graded by the old version of the tool, +- `solutions_file_path_new` — path to xlsx-file or csv-file with code samples that was graded by the new version of the tool. + +The resulting file will be stored in the same folder as the `solutions_file_path_old` input file. + +An example of the pickle` file is: + +```json +{ + grade: [2, 3], + traceback: { + 1: { + BaseIssue( + origin_class='C0305', + description='Trailing newlines', + line_no=15, + column_no=1, + type=IssueType('CODE_STYLE'), + + file_path=Path(), + inspector_type=InspectorType.UNDEFINED, + ), BaseIssue( + origin_class='E211', + description='whitespace before \'(\'', + line_no=1, + column_no=6, + type=IssueType('CODE_STYLE'), + + file_path=Path(), + inspector_type=InspectorType.UNDEFINED, + ), + } + }, +} +``` +In the `grade` field are stored fragments ids for which grade was increased in the new data. +In the `traceback` field for fragments ids are stored set of issues. These issues were found in the new data and were not found in the old data. \ No newline at end of file diff --git a/src/python/evaluation/inspectors/diffs_between_df.py b/src/python/evaluation/inspectors/diffs_between_df.py new file mode 100644 index 00000000..c069b7cb --- /dev/null +++ b/src/python/evaluation/inspectors/diffs_between_df.py @@ -0,0 +1,94 @@ +import argparse +import json +from pathlib import Path +from typing import List + +import pandas as pd +from src.python.common.tool_arguments import RunToolArgument +from src.python.evaluation.common.pandas_util import ( + get_inconsistent_positions, get_solutions_df, get_solutions_df_by_file_path, +) +from src.python.evaluation.common.util import ColumnName, EvaluationArgument +from src.python.review.common.file_system import ( + Extension, get_parent_folder, get_restricted_extension, serialize_data_and_write_to_file, +) +from src.python.review.inspectors.issue import BaseIssue +from src.python.review.quality.model import QualityType +from src.python.review.reviewers.utils.print_review import convert_json_to_issues + + +def configure_arguments(parser: argparse.ArgumentParser) -> None: + parser.add_argument(f'{RunToolArgument.SOLUTIONS_FILE_PATH.value.long_name}_old', + type=lambda value: Path(value).absolute(), + help=f'{RunToolArgument.SOLUTIONS_FILE_PATH.value.description}' + f'\nAll code fragments from this file must be graded ' + f'(file contains grade and traceback (optional) columns)') + + parser.add_argument(f'{RunToolArgument.SOLUTIONS_FILE_PATH.value.long_name}_new', + type=lambda value: Path(value).absolute(), + help=f'{RunToolArgument.SOLUTIONS_FILE_PATH.value.description}' + f'\nAll code fragments from this file must be graded ' + f'(file contains grade and traceback (optional) columns)') + + +def __get_issues(df: pd.DataFrame, row: int) -> List[BaseIssue]: + parsed_json = json.loads(df.iloc[row][EvaluationArgument.TRACEBACK.value])['issues'] + return convert_json_to_issues(parsed_json) + + +# Find difference between two dataframes. Return dict: +# { +# grade: [list_of_fragment_ids], +# traceback: { +# fragment_id: [list of issues] +# }, +# } +# The key contains only fragments that increase quality in new df +# The key contains list of new issues for each fragment +def find_diffs(old_df: pd.DataFrame, new_df: pd.DataFrame) -> dict: + inconsistent_positions = get_inconsistent_positions(old_df, new_df) + diffs = { + ColumnName.GRADE.value: [], + EvaluationArgument.TRACEBACK.value: {}, + } + # Keep only diffs in the TRACEBACK column + for row, _ in filter(lambda t: t[1] == EvaluationArgument.TRACEBACK.value, inconsistent_positions.index): + old_value = old_df.iloc[row][ColumnName.GRADE.value] + new_value = new_df.iloc[row][ColumnName.GRADE.value] + old_quality = QualityType(old_value).to_number() + new_quality = QualityType(new_value).to_number() + fragment_id = old_df.iloc[row][ColumnName.ID.value] + if new_quality > old_quality: + # It is an unexpected keys, we should check the algorithm + diffs[ColumnName.GRADE.value].append(fragment_id) + else: + # Find difference between issues + old_issues = __get_issues(old_df, row) + new_issues = __get_issues(new_df, row) + if len(old_issues) > len(new_issues): + raise ValueError(f'New dataframe contains less issues than old for fragment {id}') + difference = set(set(new_issues) - set(old_issues)) + diffs[EvaluationArgument.TRACEBACK.value][fragment_id] = difference + return diffs + + +# TODO: add description in readme +def main() -> None: + parser = argparse.ArgumentParser() + configure_arguments(parser) + args = parser.parse_args() + + old_solutions_file_path = args.solutions_file_path_old + output_ext = get_restricted_extension(old_solutions_file_path, [Extension.XLSX, Extension.CSV]) + old_solutions_df = get_solutions_df(output_ext, old_solutions_file_path) + + new_solutions_file_path = args.solutions_file_path_new + new_solutions_df = get_solutions_df_by_file_path(new_solutions_file_path) + + diffs = find_diffs(old_solutions_df, new_solutions_df) + output_path = get_parent_folder(Path(old_solutions_file_path)) / f'diffs{Extension.PICKLE.value}' + serialize_data_and_write_to_file(output_path, diffs) + + +if __name__ == '__main__': + main() diff --git a/src/python/review/common/file_system.py b/src/python/review/common/file_system.py index e89dd75a..eb5bc768 100644 --- a/src/python/review/common/file_system.py +++ b/src/python/review/common/file_system.py @@ -1,11 +1,12 @@ import linecache import os +import pickle import re import tempfile from contextlib import contextmanager from enum import Enum, unique from pathlib import Path -from typing import Callable, List, Optional, Tuple, Union +from typing import Any, Callable, List, Optional, Tuple, Union @unique @@ -32,6 +33,7 @@ class Extension(Enum): KTS = '.kts' XLSX = '.xlsx' CSV = '.csv' + PICKLE = '.pickle' # Not empty extensions are returned with a dot, for example, '.txt' # If file has no extensions, an empty one ('') is returned @@ -68,6 +70,19 @@ def does_name_match(name: str) -> bool: return does_name_match +def serialize_data_and_write_to_file(path: Path, data: Any) -> None: + create_directory(get_parent_folder(path)) + with open(path, 'wb') as f: + p = pickle.Pickler(f) + p.dump(data) + + +def deserialize_data_from_file(path: Path) -> Any: + with open(path, 'rb') as f: + u = pickle.Unpickler(f) + return u.load() + + # For getting name of the last folder or file # For example, returns 'folder' for both 'path/data/folder' and 'path/data/folder/' def get_name_from_path(path: str, with_extension: bool = True) -> str: diff --git a/src/python/review/inspectors/inspector_type.py b/src/python/review/inspectors/inspector_type.py index 15482a8b..2d00c0d5 100644 --- a/src/python/review/inspectors/inspector_type.py +++ b/src/python/review/inspectors/inspector_type.py @@ -23,6 +23,8 @@ class InspectorType(Enum): # JavaScript language ESLINT = 'ESLINT' + UNDEFINED = 'UNDEFINED' + @classmethod def available_values(cls) -> List[str]: return [ diff --git a/src/python/review/reviewers/utils/print_review.py b/src/python/review/reviewers/utils/print_review.py index a5a2f59b..f67db761 100644 --- a/src/python/review/reviewers/utils/print_review.py +++ b/src/python/review/reviewers/utils/print_review.py @@ -1,10 +1,12 @@ import json import linecache +from enum import Enum, unique from pathlib import Path -from typing import Any, Dict +from typing import Any, Dict, List from src.python.review.common.file_system import get_file_line -from src.python.review.inspectors.issue import BaseIssue +from src.python.review.inspectors.inspector_type import InspectorType +from src.python.review.inspectors.issue import BaseIssue, IssueType from src.python.review.reviewers.review_result import ReviewResult @@ -107,15 +109,45 @@ def print_review_result_as_multi_file_json(review_result: ReviewResult) -> None: print(json.dumps(output_json)) +@unique +class IssueJsonFields(Enum): + CODE = 'code' + TEXT = 'text' + LINE = 'line' + LINE_NUMBER = 'line_number' + COLUMN_NUMBER = 'column_number' + CATEGORY = 'category' + INFLUENCE_ON_PENALTY = 'influence_on_penalty' + + def convert_issue_to_json(issue: BaseIssue, influence_on_penalty: int) -> Dict[str, Any]: line_text = get_file_line(issue.file_path, issue.line_no) return { - 'code': issue.origin_class, - 'text': issue.description, - 'line': line_text, - 'line_number': issue.line_no, - 'column_number': issue.column_no, - 'category': issue.type.value, - 'influence_on_penalty': influence_on_penalty, + IssueJsonFields.CODE.value: issue.origin_class, + IssueJsonFields.TEXT.value: issue.description, + IssueJsonFields.LINE.value: line_text, + IssueJsonFields.LINE_NUMBER.value: issue.line_no, + IssueJsonFields.COLUMN_NUMBER.value: issue.column_no, + IssueJsonFields.CATEGORY.value: issue.type.value, + IssueJsonFields.INFLUENCE_ON_PENALTY.value: influence_on_penalty, } + + +# It works only for old json format +def convert_json_to_issues(issues_json: List[dict]) -> List[BaseIssue]: + issues = [] + for issue in issues_json: + issues.append( + BaseIssue( + origin_class=issue[IssueJsonFields.CODE.value], + description=issue[IssueJsonFields.TEXT.value], + line_no=int(issue[IssueJsonFields.LINE_NUMBER.value]), + column_no=int(issue[IssueJsonFields.COLUMN_NUMBER.value]), + type=IssueType(issue[IssueJsonFields.CATEGORY.value]), + + file_path=Path(), + inspector_type=InspectorType.UNDEFINED, + ), + ) + return issues diff --git a/test/python/common_util.py b/test/python/common_util.py index 0315a4cc..4823bec8 100644 --- a/test/python/common_util.py +++ b/test/python/common_util.py @@ -2,12 +2,16 @@ from typing import List, Tuple import pandas as pd -from src.python.review.common.file_system import get_all_file_system_items, match_condition, pair_in_and_out_files +from src.python.review.common.file_system import ( + Extension, get_all_file_system_items, match_condition, pair_in_and_out_files, +) -def get_in_and_out_list(root: Path) -> List[Tuple[Path, Path]]: - in_files = get_all_file_system_items(root, match_condition(r'in_\d+.csv')) - out_files = get_all_file_system_items(root, match_condition(r'out_\d+.csv')) +def get_in_and_out_list(root: Path, + in_ext: Extension = Extension.CSV, + out_ext: Extension = Extension.CSV) -> List[Tuple[Path, Path]]: + in_files = get_all_file_system_items(root, match_condition(rf'in_\d+{in_ext.value}')) + out_files = get_all_file_system_items(root, match_condition(rf'out_\d+{out_ext.value}')) return pair_in_and_out_files(in_files, out_files) diff --git a/test/python/evaluation/__init__.py b/test/python/evaluation/__init__.py index 3aaf37cf..293fdcae 100644 --- a/test/python/evaluation/__init__.py +++ b/test/python/evaluation/__init__.py @@ -13,3 +13,5 @@ EVALUATION_COMMON_DIR_PATH = CURRENT_TEST_DATA_FOLDER / 'common' PANDAS_UTIL_DIR_PATH = EVALUATION_COMMON_DIR_PATH / 'pandas_util' + +INSPECTORS_DIR_PATH = EVALUATION_COMMON_DIR_PATH / 'inspectors' diff --git a/test/python/evaluation/common/pandas_util/test_drop_duplicates.py b/test/python/evaluation/common/pandas_util/test_drop_duplicates.py index 258ff0d9..acd47445 100644 --- a/test/python/evaluation/common/pandas_util/test_drop_duplicates.py +++ b/test/python/evaluation/common/pandas_util/test_drop_duplicates.py @@ -12,7 +12,6 @@ @pytest.mark.parametrize(('in_file', 'out_file'), IN_AND_OUT_FILES) def test(in_file: Path, out_file: Path): - print(in_file) in_df = get_solutions_df_by_file_path(in_file) out_df = get_solutions_df_by_file_path(out_file) filtered_df = drop_duplicates(in_df) diff --git a/test/python/evaluation/common/pandas_util/test_filter_by_language.py b/test/python/evaluation/common/pandas_util/test_filter_by_language.py index 2e496f2e..25af150d 100644 --- a/test/python/evaluation/common/pandas_util/test_filter_by_language.py +++ b/test/python/evaluation/common/pandas_util/test_filter_by_language.py @@ -23,7 +23,6 @@ @pytest.mark.parametrize(('in_file', 'out_file'), IN_AND_OUT_FILES) def test(in_file: Path, out_file: Path): - print(in_file) in_df = get_solutions_df_by_file_path(in_file) out_df = get_solutions_df_by_file_path(out_file) filtered_df = filter_df_by_language(in_df, IN_FILE_TO_LANGUAGES[get_name_from_path(str(in_file))]) diff --git a/test/python/evaluation/inspectors/__init__.py b/test/python/evaluation/inspectors/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/python/evaluation/inspectors/diffs_between_df/__init__.py b/test/python/evaluation/inspectors/diffs_between_df/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/python/evaluation/inspectors/diffs_between_df/test_diifs_between_df.py b/test/python/evaluation/inspectors/diffs_between_df/test_diifs_between_df.py new file mode 100644 index 00000000..86af9105 --- /dev/null +++ b/test/python/evaluation/inspectors/diffs_between_df/test_diifs_between_df.py @@ -0,0 +1,72 @@ +from pathlib import Path +from test.python.evaluation import INSPECTORS_DIR_PATH + +import pytest +from src.python.evaluation.common.pandas_util import get_solutions_df_by_file_path +from src.python.evaluation.common.util import ColumnName, EvaluationArgument +from src.python.evaluation.inspectors.diffs_between_df import find_diffs +from src.python.review.inspectors.inspector_type import InspectorType +from src.python.review.inspectors.issue import BaseIssue, IssueType + +RESOURCES_PATH = INSPECTORS_DIR_PATH / 'diffs_between_df' + +EMPTY_DIFFS = { + ColumnName.GRADE.value: [], + EvaluationArgument.TRACEBACK.value: {}, +} + +INCORRECT_GRADE_DIFFS = { + ColumnName.GRADE.value: [1, 2], + EvaluationArgument.TRACEBACK.value: {}, +} + +ISSUES = { + BaseIssue( + origin_class='C0305', + description='Trailing newlines', + line_no=15, + column_no=1, + type=IssueType('CODE_STYLE'), + + file_path=Path(), + inspector_type=InspectorType.UNDEFINED, + ), BaseIssue( + origin_class='E211', + description='whitespace before \'(\'', + line_no=1, + column_no=6, + type=IssueType('CODE_STYLE'), + + file_path=Path(), + inspector_type=InspectorType.UNDEFINED, + ), +} + +ISSUES_DIFFS = { + ColumnName.GRADE.value: [], + EvaluationArgument.TRACEBACK.value: { + 1: ISSUES, + }, +} + +MIXED_DIFFS = { + ColumnName.GRADE.value: [2, 3], + EvaluationArgument.TRACEBACK.value: { + 1: ISSUES, + }, +} + +TEST_DATA = [ + ('old_1.csv', 'new_1.csv', EMPTY_DIFFS), + ('old_2.csv', 'new_2.csv', INCORRECT_GRADE_DIFFS), + ('old_3.csv', 'new_3.csv', ISSUES_DIFFS), + ('old_4.csv', 'new_4.csv', MIXED_DIFFS), +] + + +@pytest.mark.parametrize(('old_file', 'new_file', 'diffs'), TEST_DATA) +def test(old_file: Path, new_file: Path, diffs: dict): + old_df = get_solutions_df_by_file_path(RESOURCES_PATH / old_file) + new_df = get_solutions_df_by_file_path(RESOURCES_PATH / new_file) + actual_diffs = find_diffs(old_df, new_df) + assert actual_diffs == diffs diff --git a/test/resources/evaluation/common/inspectors/diffs_between_df/new_1.csv b/test/resources/evaluation/common/inspectors/diffs_between_df/new_1.csv new file mode 100644 index 00000000..c415b91a --- /dev/null +++ b/test/resources/evaluation/common/inspectors/diffs_between_df/new_1.csv @@ -0,0 +1,10 @@ +id,time,code,lang,grade,traceback +1,1617455906,"print (""Learn Python to be great!"") +",python3,MODERATE,"{""quality"": {""code"": ""MODERATE"", ""text"": ""Code quality (beta): MODERATE""}, ""issues"": [{""code"": ""E215"", ""text"": ""whitespace before '('"", ""line"": ""print (\""Learn Python to be great!\"")"", ""line_number"": 1, ""column_number"": 6, ""category"": ""CODE_STYLE"", ""influence_on_penalty"": 0}]} +" +2,1617455906,"print (""Learn Python to be great!"") +",python3,MODERATE,"{""quality"": {""code"": ""MODERATE"", ""text"": ""Code quality (beta): MODERATE""}, ""issues"": [{""code"": ""E215"", ""text"": ""whitespace before '('"", ""line"": ""print (\""Learn Python to be great!\"")"", ""line_number"": 1, ""column_number"": 6, ""category"": ""CODE_STYLE"", ""influence_on_penalty"": 0}]} +" +3,1617455906,"print (""Learn Python to be great!"") +",python3,MODERATE,"{""quality"": {""code"": ""MODERATE"", ""text"": ""Code quality (beta): MODERATE""}, ""issues"": [{""code"": ""E215"", ""text"": ""whitespace before '('"", ""line"": ""print (\""Learn Python to be great!\"")"", ""line_number"": 1, ""column_number"": 6, ""category"": ""CODE_STYLE"", ""influence_on_penalty"": 0}]} +" \ No newline at end of file diff --git a/test/resources/evaluation/common/inspectors/diffs_between_df/new_2.csv b/test/resources/evaluation/common/inspectors/diffs_between_df/new_2.csv new file mode 100644 index 00000000..2ccfe8aa --- /dev/null +++ b/test/resources/evaluation/common/inspectors/diffs_between_df/new_2.csv @@ -0,0 +1,10 @@ +id,time,code,lang,grade,traceback +1,1617455906,"print (""Learn Python to be great!"") +",python3,GOOD,"{""quality"": {""code"": ""GOOD"", ""text"": ""Code quality (beta): GOOD""}, ""issues"": [{""code"": ""E215"", ""text"": ""whitespace before '('"", ""line"": ""print (\""Learn Python to be great!\"")"", ""line_number"": 1, ""column_number"": 6, ""category"": ""CODE_STYLE"", ""influence_on_penalty"": 0}]} +" +2,1617455906,"print (""Learn Python to be great!"") +",python3,GOOD,"{""quality"": {""code"": ""GOOD"", ""text"": ""Code quality (beta): GOOD""}, ""issues"": [{""code"": ""E215"", ""text"": ""whitespace before '('"", ""line"": ""print (\""Learn Python to be great!\"")"", ""line_number"": 1, ""column_number"": 6, ""category"": ""CODE_STYLE"", ""influence_on_penalty"": 0}]} +" +3,1617455906,"print (""Learn Python to be great!"") +",python3,MODERATE,"{""quality"": {""code"": ""MODERATE"", ""text"": ""Code quality (beta): MODERATE""}, ""issues"": [{""code"": ""E215"", ""text"": ""whitespace before '('"", ""line"": ""print (\""Learn Python to be great!\"")"", ""line_number"": 1, ""column_number"": 6, ""category"": ""CODE_STYLE"", ""influence_on_penalty"": 0}]} +" \ No newline at end of file diff --git a/test/resources/evaluation/common/inspectors/diffs_between_df/new_3.csv b/test/resources/evaluation/common/inspectors/diffs_between_df/new_3.csv new file mode 100644 index 00000000..d8b2addc --- /dev/null +++ b/test/resources/evaluation/common/inspectors/diffs_between_df/new_3.csv @@ -0,0 +1,10 @@ +id,time,code,lang,grade,traceback +1,1617455906,"print (""Learn Python to be great!"") +",python3,MODERATE,"{""quality"": {""code"": ""MODERATE"", ""text"": ""Code quality (beta): MODERATE""}, ""issues"": [{""code"": ""E215"", ""text"": ""whitespace before '('"", ""line"": ""print (\""Learn Python to be great!\"")"", ""line_number"": 1, ""column_number"": 6, ""category"": ""CODE_STYLE"", ""influence_on_penalty"": 0},{""code"": ""C0305"", ""text"": ""Trailing newlines"", ""line"": """", ""line_number"": 15, ""column_number"": 1, ""category"": ""CODE_STYLE"", ""influence_on_penalty"": 0},{""code"": ""E211"", ""text"": ""whitespace before '('"", ""line"": ""print (\""Learn Python to be great!\"")"", ""line_number"": 1, ""column_number"": 6, ""category"": ""CODE_STYLE"", ""influence_on_penalty"": 0}]} +" +2,1617455906,"print (""Learn Python to be great!"") +",python3,MODERATE,"{""quality"": {""code"": ""MODERATE"", ""text"": ""Code quality (beta): MODERATE""}, ""issues"": [{""code"": ""E215"", ""text"": ""whitespace before '('"", ""line"": ""print (\""Learn Python to be great!\"")"", ""line_number"": 1, ""column_number"": 6, ""category"": ""CODE_STYLE"", ""influence_on_penalty"": 0}]} +" +3,1617455906,"print (""Learn Python to be great!"") +",python3,MODERATE,"{""quality"": {""code"": ""MODERATE"", ""text"": ""Code quality (beta): MODERATE""}, ""issues"": [{""code"": ""E215"", ""text"": ""whitespace before '('"", ""line"": ""print (\""Learn Python to be great!\"")"", ""line_number"": 1, ""column_number"": 6, ""category"": ""CODE_STYLE"", ""influence_on_penalty"": 0}]} +" \ No newline at end of file diff --git a/test/resources/evaluation/common/inspectors/diffs_between_df/new_4.csv b/test/resources/evaluation/common/inspectors/diffs_between_df/new_4.csv new file mode 100644 index 00000000..b77ae579 --- /dev/null +++ b/test/resources/evaluation/common/inspectors/diffs_between_df/new_4.csv @@ -0,0 +1,10 @@ +id,time,code,lang,grade,traceback +1,1617455906,"print (""Learn Python to be great!"") +",python3,MODERATE,"{""quality"": {""code"": ""MODERATE"", ""text"": ""Code quality (beta): MODERATE""}, ""issues"": [{""code"": ""E215"", ""text"": ""whitespace before '('"", ""line"": ""print (\""Learn Python to be great!\"")"", ""line_number"": 1, ""column_number"": 6, ""category"": ""CODE_STYLE"", ""influence_on_penalty"": 0},{""code"": ""C0305"", ""text"": ""Trailing newlines"", ""line"": """", ""line_number"": 15, ""column_number"": 1, ""category"": ""CODE_STYLE"", ""influence_on_penalty"": 0},{""code"": ""E211"", ""text"": ""whitespace before '('"", ""line"": ""print (\""Learn Python to be great!\"")"", ""line_number"": 1, ""column_number"": 6, ""category"": ""CODE_STYLE"", ""influence_on_penalty"": 0}]} +" +2,1617455906,"print (""Learn Python to be great!"") +",python3,GOOD,"{""quality"": {""code"": ""GOOD"", ""text"": ""Code quality (beta): MODERATE""}, ""issues"": [{""code"": ""E215"", ""text"": ""whitespace before '('"", ""line"": ""print (\""Learn Python to be great!\"")"", ""line_number"": 1, ""column_number"": 6, ""category"": ""CODE_STYLE"", ""influence_on_penalty"": 0}]} +" +3,1617455906,"print (""Learn Python to be great!"") +",python3,GOOD,"{""quality"": {""code"": ""GOOD"", ""text"": ""Code quality (beta): MODERATE""}, ""issues"": [{""code"": ""E215"", ""text"": ""whitespace before '('"", ""line"": ""print (\""Learn Python to be great!\"")"", ""line_number"": 1, ""column_number"": 6, ""category"": ""CODE_STYLE"", ""influence_on_penalty"": 0}]} +" \ No newline at end of file diff --git a/test/resources/evaluation/common/inspectors/diffs_between_df/old_1.csv b/test/resources/evaluation/common/inspectors/diffs_between_df/old_1.csv new file mode 100644 index 00000000..c415b91a --- /dev/null +++ b/test/resources/evaluation/common/inspectors/diffs_between_df/old_1.csv @@ -0,0 +1,10 @@ +id,time,code,lang,grade,traceback +1,1617455906,"print (""Learn Python to be great!"") +",python3,MODERATE,"{""quality"": {""code"": ""MODERATE"", ""text"": ""Code quality (beta): MODERATE""}, ""issues"": [{""code"": ""E215"", ""text"": ""whitespace before '('"", ""line"": ""print (\""Learn Python to be great!\"")"", ""line_number"": 1, ""column_number"": 6, ""category"": ""CODE_STYLE"", ""influence_on_penalty"": 0}]} +" +2,1617455906,"print (""Learn Python to be great!"") +",python3,MODERATE,"{""quality"": {""code"": ""MODERATE"", ""text"": ""Code quality (beta): MODERATE""}, ""issues"": [{""code"": ""E215"", ""text"": ""whitespace before '('"", ""line"": ""print (\""Learn Python to be great!\"")"", ""line_number"": 1, ""column_number"": 6, ""category"": ""CODE_STYLE"", ""influence_on_penalty"": 0}]} +" +3,1617455906,"print (""Learn Python to be great!"") +",python3,MODERATE,"{""quality"": {""code"": ""MODERATE"", ""text"": ""Code quality (beta): MODERATE""}, ""issues"": [{""code"": ""E215"", ""text"": ""whitespace before '('"", ""line"": ""print (\""Learn Python to be great!\"")"", ""line_number"": 1, ""column_number"": 6, ""category"": ""CODE_STYLE"", ""influence_on_penalty"": 0}]} +" \ No newline at end of file diff --git a/test/resources/evaluation/common/inspectors/diffs_between_df/old_2.csv b/test/resources/evaluation/common/inspectors/diffs_between_df/old_2.csv new file mode 100644 index 00000000..c415b91a --- /dev/null +++ b/test/resources/evaluation/common/inspectors/diffs_between_df/old_2.csv @@ -0,0 +1,10 @@ +id,time,code,lang,grade,traceback +1,1617455906,"print (""Learn Python to be great!"") +",python3,MODERATE,"{""quality"": {""code"": ""MODERATE"", ""text"": ""Code quality (beta): MODERATE""}, ""issues"": [{""code"": ""E215"", ""text"": ""whitespace before '('"", ""line"": ""print (\""Learn Python to be great!\"")"", ""line_number"": 1, ""column_number"": 6, ""category"": ""CODE_STYLE"", ""influence_on_penalty"": 0}]} +" +2,1617455906,"print (""Learn Python to be great!"") +",python3,MODERATE,"{""quality"": {""code"": ""MODERATE"", ""text"": ""Code quality (beta): MODERATE""}, ""issues"": [{""code"": ""E215"", ""text"": ""whitespace before '('"", ""line"": ""print (\""Learn Python to be great!\"")"", ""line_number"": 1, ""column_number"": 6, ""category"": ""CODE_STYLE"", ""influence_on_penalty"": 0}]} +" +3,1617455906,"print (""Learn Python to be great!"") +",python3,MODERATE,"{""quality"": {""code"": ""MODERATE"", ""text"": ""Code quality (beta): MODERATE""}, ""issues"": [{""code"": ""E215"", ""text"": ""whitespace before '('"", ""line"": ""print (\""Learn Python to be great!\"")"", ""line_number"": 1, ""column_number"": 6, ""category"": ""CODE_STYLE"", ""influence_on_penalty"": 0}]} +" \ No newline at end of file diff --git a/test/resources/evaluation/common/inspectors/diffs_between_df/old_3.csv b/test/resources/evaluation/common/inspectors/diffs_between_df/old_3.csv new file mode 100644 index 00000000..c415b91a --- /dev/null +++ b/test/resources/evaluation/common/inspectors/diffs_between_df/old_3.csv @@ -0,0 +1,10 @@ +id,time,code,lang,grade,traceback +1,1617455906,"print (""Learn Python to be great!"") +",python3,MODERATE,"{""quality"": {""code"": ""MODERATE"", ""text"": ""Code quality (beta): MODERATE""}, ""issues"": [{""code"": ""E215"", ""text"": ""whitespace before '('"", ""line"": ""print (\""Learn Python to be great!\"")"", ""line_number"": 1, ""column_number"": 6, ""category"": ""CODE_STYLE"", ""influence_on_penalty"": 0}]} +" +2,1617455906,"print (""Learn Python to be great!"") +",python3,MODERATE,"{""quality"": {""code"": ""MODERATE"", ""text"": ""Code quality (beta): MODERATE""}, ""issues"": [{""code"": ""E215"", ""text"": ""whitespace before '('"", ""line"": ""print (\""Learn Python to be great!\"")"", ""line_number"": 1, ""column_number"": 6, ""category"": ""CODE_STYLE"", ""influence_on_penalty"": 0}]} +" +3,1617455906,"print (""Learn Python to be great!"") +",python3,MODERATE,"{""quality"": {""code"": ""MODERATE"", ""text"": ""Code quality (beta): MODERATE""}, ""issues"": [{""code"": ""E215"", ""text"": ""whitespace before '('"", ""line"": ""print (\""Learn Python to be great!\"")"", ""line_number"": 1, ""column_number"": 6, ""category"": ""CODE_STYLE"", ""influence_on_penalty"": 0}]} +" \ No newline at end of file diff --git a/test/resources/evaluation/common/inspectors/diffs_between_df/old_4.csv b/test/resources/evaluation/common/inspectors/diffs_between_df/old_4.csv new file mode 100644 index 00000000..c415b91a --- /dev/null +++ b/test/resources/evaluation/common/inspectors/diffs_between_df/old_4.csv @@ -0,0 +1,10 @@ +id,time,code,lang,grade,traceback +1,1617455906,"print (""Learn Python to be great!"") +",python3,MODERATE,"{""quality"": {""code"": ""MODERATE"", ""text"": ""Code quality (beta): MODERATE""}, ""issues"": [{""code"": ""E215"", ""text"": ""whitespace before '('"", ""line"": ""print (\""Learn Python to be great!\"")"", ""line_number"": 1, ""column_number"": 6, ""category"": ""CODE_STYLE"", ""influence_on_penalty"": 0}]} +" +2,1617455906,"print (""Learn Python to be great!"") +",python3,MODERATE,"{""quality"": {""code"": ""MODERATE"", ""text"": ""Code quality (beta): MODERATE""}, ""issues"": [{""code"": ""E215"", ""text"": ""whitespace before '('"", ""line"": ""print (\""Learn Python to be great!\"")"", ""line_number"": 1, ""column_number"": 6, ""category"": ""CODE_STYLE"", ""influence_on_penalty"": 0}]} +" +3,1617455906,"print (""Learn Python to be great!"") +",python3,MODERATE,"{""quality"": {""code"": ""MODERATE"", ""text"": ""Code quality (beta): MODERATE""}, ""issues"": [{""code"": ""E215"", ""text"": ""whitespace before '('"", ""line"": ""print (\""Learn Python to be great!\"")"", ""line_number"": 1, ""column_number"": 6, ""category"": ""CODE_STYLE"", ""influence_on_penalty"": 0}]} +" \ No newline at end of file diff --git a/whitelist.txt b/whitelist.txt index c9064163..0573d872 100644 --- a/whitelist.txt +++ b/whitelist.txt @@ -98,4 +98,9 @@ pandarallel isin loc uniq -fullmatch \ No newline at end of file +fullmatch +iloc +dataframes +numpy +Pickler +Unpickler \ No newline at end of file From f21cdf8236397b12af294abab380a2f498628913 Mon Sep 17 00:00:00 2001 From: "Anastasiia.Birillo" Date: Tue, 18 May 2021 18:53:31 +0300 Subject: [PATCH 8/9] Delete unnecessary files --- src/python/evaluation/inspectors/diffs_between_df.py | 1 - src/python/evaluation/statistics/__init__.py | 0 2 files changed, 1 deletion(-) delete mode 100644 src/python/evaluation/statistics/__init__.py diff --git a/src/python/evaluation/inspectors/diffs_between_df.py b/src/python/evaluation/inspectors/diffs_between_df.py index c069b7cb..70e93331 100644 --- a/src/python/evaluation/inspectors/diffs_between_df.py +++ b/src/python/evaluation/inspectors/diffs_between_df.py @@ -72,7 +72,6 @@ def find_diffs(old_df: pd.DataFrame, new_df: pd.DataFrame) -> dict: return diffs -# TODO: add description in readme def main() -> None: parser = argparse.ArgumentParser() configure_arguments(parser) diff --git a/src/python/evaluation/statistics/__init__.py b/src/python/evaluation/statistics/__init__.py deleted file mode 100644 index e69de29b..00000000 From 03d6844d335775dedddba93d4657afd581102ef5 Mon Sep 17 00:00:00 2001 From: Nastya Birillo Date: Thu, 20 May 2021 17:54:34 +0300 Subject: [PATCH 9/9] Print inspectors statistics (#30) * Add possibility for printing statistics that were found by diffs_between_df.py * Add possibility for filtering issues by list --- src/python/common/tool_arguments.py | 3 + src/python/evaluation/common/pandas_util.py | 20 +++- src/python/evaluation/common/util.py | 1 + src/python/evaluation/inspectors/README.md | 98 ++++++++++++++++++- .../evaluation/inspectors/common/__init__.py | 0 .../inspectors/common/statistics.py | 56 +++++++++++ .../evaluation/inspectors/diffs_between_df.py | 15 +-- .../evaluation/inspectors/filter_issues.py | 70 +++++++++++++ .../inspectors/get_worse_public_examples.py | 68 +++++++++++++ .../inspectors/print_inspectors_statistics.py | 84 ++++++++++++++++ src/python/review/inspectors/issue.py | 15 ++- 11 files changed, 410 insertions(+), 20 deletions(-) create mode 100644 src/python/evaluation/inspectors/common/__init__.py create mode 100644 src/python/evaluation/inspectors/common/statistics.py create mode 100644 src/python/evaluation/inspectors/filter_issues.py create mode 100644 src/python/evaluation/inspectors/get_worse_public_examples.py create mode 100644 src/python/evaluation/inspectors/print_inspectors_statistics.py diff --git a/src/python/common/tool_arguments.py b/src/python/common/tool_arguments.py index b285ac23..d3048051 100644 --- a/src/python/common/tool_arguments.py +++ b/src/python/common/tool_arguments.py @@ -86,3 +86,6 @@ class RunToolArgument(Enum): f'"{ColumnName.LANG.value}" column are: ' f'{LanguageVersion.PYTHON_3.value}, {LanguageVersion.JAVA_8.value}, ' f'{LanguageVersion.JAVA_11.value}, {LanguageVersion.KOTLIN.value}.') + + DIFFS_FILE_PATH = ArgumentsInfo(None, 'diffs_file_path', + 'Path to a file with serialized diffs that were founded by diffs_between_df.py') diff --git a/src/python/evaluation/common/pandas_util.py b/src/python/evaluation/common/pandas_util.py index bb956759..987ef030 100644 --- a/src/python/evaluation/common/pandas_util.py +++ b/src/python/evaluation/common/pandas_util.py @@ -1,14 +1,17 @@ +import json import logging from pathlib import Path -from typing import Set, Union +from typing import Any, List, Set, Union import numpy as np import pandas as pd from src.python.evaluation.common.csv_util import write_dataframe_to_csv -from src.python.evaluation.common.util import ColumnName +from src.python.evaluation.common.util import ColumnName, EvaluationArgument from src.python.evaluation.common.xlsx_util import create_workbook, remove_sheet, write_dataframe_to_xlsx_sheet from src.python.review.application_config import LanguageVersion from src.python.review.common.file_system import Extension, get_restricted_extension +from src.python.review.inspectors.issue import BaseIssue +from src.python.review.reviewers.utils.print_review import convert_json_to_issues logger = logging.getLogger(__name__) @@ -18,6 +21,10 @@ def filter_df_by_language(df: pd.DataFrame, languages: Set[LanguageVersion], return df.loc[df[column].isin(set(map(lambda l: l.value, languages)))] +def filter_df_by_condition(df: pd.DataFrame, column: str, value: Any) -> pd.DataFrame: + return df.loc[df[column] == value] + + def drop_duplicates(df: pd.DataFrame, column: str = ColumnName.CODE.value) -> pd.DataFrame: return df.drop_duplicates(column, keep='last') @@ -85,3 +92,12 @@ def write_df_to_file(df: pd.DataFrame, output_file_path: Path, extension: Extens write_dataframe_to_xlsx_sheet(output_file_path, df, 'inspection_results') # remove empty sheet that was initially created with the workbook remove_sheet(output_file_path, 'Sheet') + + +def get_issues_from_json(str_json: str) -> List[BaseIssue]: + parsed_json = json.loads(str_json)['issues'] + return convert_json_to_issues(parsed_json) + + +def get_issues_by_row(df: pd.DataFrame, row: int) -> List[BaseIssue]: + return get_issues_from_json(df.iloc[row][EvaluationArgument.TRACEBACK.value]) diff --git a/src/python/evaluation/common/util.py b/src/python/evaluation/common/util.py index b1c501b8..271956f1 100644 --- a/src/python/evaluation/common/util.py +++ b/src/python/evaluation/common/util.py @@ -15,6 +15,7 @@ class ColumnName(Enum): ROW = 'row' OLD = 'old' NEW = 'new' + IS_PUBLIC = 'is_public' @unique diff --git a/src/python/evaluation/inspectors/README.md b/src/python/evaluation/inspectors/README.md index e34a3b01..a0de1314 100644 --- a/src/python/evaluation/inspectors/README.md +++ b/src/python/evaluation/inspectors/README.md @@ -11,6 +11,10 @@ This module contains _preprocessing_ stage and _analysing_ stage. `Analysing` stage includes: - [diffs_between_df.py](diffs_between_df.py) allows finding a difference between old and new grades and collect issues that were found in new data +- [print_inspectors_statistics.py](print_inspectors_statistics.py) allows printing statistics + that were found by [diffs_between_df.py](diffs_between_df.py) +- [get_worse_public_examples.py](get_worse_public_examples.py) allows getting + top N worse public examples from a dataset. The measure is to count unique new inspections. ___ @@ -136,4 +140,96 @@ An example of the pickle` file is: } ``` In the `grade` field are stored fragments ids for which grade was increased in the new data. -In the `traceback` field for fragments ids are stored set of issues. These issues were found in the new data and were not found in the old data. \ No newline at end of file +In the `traceback` field for fragments ids are stored set of issues. These issues were found in the new data and were not found in the old data. + +___ + +### Print statistics + +[print_inspectors_statistics.py](print_inspectors_statistics.py) allows print statistics + that were calculated by [diffs_between_df.py](diffs_between_df.py) + +#### Usage + +Run the [print_inspectors_statistics.py](print_inspectors_statistics.py) with the arguments from command line. + +Required arguments: + +- `diffs_file_path` — path to a `pickle` file, that was calculated by [diffs_between_df.py](diffs_between_df.py). + +Optional arguments: +Argument | Description +--- | --- +|**‑‑categorize**| If True, statistics will be categorized by several categories. By default is disabled.| +|**‑n**, **‑‑top_n**| The top N items will be printed. Default value is 10.| +|**‑‑full_stat**| If True, full statistics (with all issues) will be printed. By default is disabled.| + +The statistics will be printed into console. + +The output contains: +- was found incorrect grades or not; +- how many fragments has additional issues; +- how many unique issues was found; +- top N issues in the format: (issue_key, frequency); +- short categorized statistics: for each category how many issues were found and how many + fragments have these issues; +- \[Optional\] full categorized statistics: for each category for each issue how many + fragments have this issue + +An example of the printed statistics (without full categorized statistics): + +```json +SUCCESS! Was not found incorrect grades. +______ +39830 fragments has additional issues +139 unique issues was found +______ +Top 10 issues: +SC200: 64435 times +WPS432: 17477 times +WPS221: 10618 times +WPS336: 4965 times +H601: 3826 times +SC100: 2719 times +WPS319: 2655 times +WPS317: 2575 times +WPS515: 1783 times +WPS503: 1611 times +______ +CODE_STYLE: 28 issues, 26171 fragments +BEST_PRACTICES: 76 issues, 88040 fragments +ERROR_PRONE: 17 issues, 2363 fragments +COMPLEXITY: 17 issues, 13928 fragments +COHESION: 1 issues, 3826 fragments +______ +``` + +--- + +### Get worse public examples + +[get_worse_public_examples.py](get_worse_public_examples.py) allows getting + top N worse public examples from a dataset. The measure is to count unique new inspections. + +#### Usage + +Run the [get_worse_public_examples.py](get_worse_public_examples.py) with the arguments from command line. + +Required arguments: + +- `solutions_file_path` — path to xlsx-file or csv-file with graded code samples; +- `diffs_file_path` — path to a `pickle` file, that was calculated by [diffs_between_df.py](diffs_between_df.py). + +Please, note that your `solutions_file_path` file with code fragments should consist of at least 2 obligatory columns: + +- `code`, +- `traceback`, +- `is_public`, +- `id`. + +Optional arguments: +Argument | Description +--- | --- +|**‑n**, **‑‑n**| The N worse fragments will be saved.| + +The resulting file will be stored in the same folder as the `solutions_file_path` input file. diff --git a/src/python/evaluation/inspectors/common/__init__.py b/src/python/evaluation/inspectors/common/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/python/evaluation/inspectors/common/statistics.py b/src/python/evaluation/inspectors/common/statistics.py new file mode 100644 index 00000000..a36cefb7 --- /dev/null +++ b/src/python/evaluation/inspectors/common/statistics.py @@ -0,0 +1,56 @@ +from collections import defaultdict +from dataclasses import dataclass +from typing import Dict, List, Tuple + +from src.python.review.inspectors.issue import IssueType, ShortIssue + + +@dataclass(frozen=True) +class IssuesStatistics: + stat: Dict[ShortIssue, int] + changed_grades_count: int + + def print_full_statistics(self, to_categorize: bool = True): + if to_categorize: + categorized_statistics: Dict[IssueType, Dict[ShortIssue, int]] = self.get_categorized_statistics() + for category, issues in categorized_statistics.items(): + print(f'{category.value} issues:') + self.__print_stat(issues) + else: + self.__print_stat(self.stat) + + @classmethod + def __print_stat(cls, stat: Dict[ShortIssue, int]): + for issue, freq in stat.items(): + cls.print_issue_with_freq(issue, freq, prefix='- ') + + @classmethod + def print_issue_with_freq(cls, issue: ShortIssue, freq: int, prefix: str = '', suffix: str = '') -> None: + print(f'{prefix}{issue.origin_class}: {freq} times{suffix}') + + def get_categorized_statistics(self) -> Dict[IssueType, Dict[ShortIssue, int]]: + categorized_stat: Dict[IssueType, Dict[ShortIssue, int]] = defaultdict(dict) + for issue, freq in self.stat.items(): + categorized_stat[issue.type][issue] = freq + return categorized_stat + + # Get statistics for each IssueType: count unique issues, count fragments with these issues + def get_short_categorized_statistics(self) -> Dict[IssueType, Tuple[int, int]]: + categorized_statistics: Dict[IssueType, Dict[ShortIssue, int]] = self.get_categorized_statistics() + short_categorized_statistics = defaultdict(tuple) + for issue_type, stat in categorized_statistics.items(): + unique_issues = len(stat) + fragments = sum(stat.values()) + short_categorized_statistics[issue_type] = (unique_issues, fragments) + return short_categorized_statistics + + def print_short_categorized_statistics(self) -> None: + short_categorized_statistics = self.get_short_categorized_statistics() + for category, stat in short_categorized_statistics.items(): + print(f'{category.value}: {stat[0]} issues, {stat[1]} fragments') + + def get_top_n_issues(self, n: int) -> List[ShortIssue]: + return sorted(self.stat.items(), key=lambda t: t[1], reverse=True)[:n] + + def count_unique_issues(self) -> int: + return len(self.stat) diff --git a/src/python/evaluation/inspectors/diffs_between_df.py b/src/python/evaluation/inspectors/diffs_between_df.py index 70e93331..c747175f 100644 --- a/src/python/evaluation/inspectors/diffs_between_df.py +++ b/src/python/evaluation/inspectors/diffs_between_df.py @@ -1,20 +1,16 @@ import argparse -import json from pathlib import Path -from typing import List import pandas as pd from src.python.common.tool_arguments import RunToolArgument from src.python.evaluation.common.pandas_util import ( - get_inconsistent_positions, get_solutions_df, get_solutions_df_by_file_path, + get_inconsistent_positions, get_issues_by_row, get_solutions_df, get_solutions_df_by_file_path, ) from src.python.evaluation.common.util import ColumnName, EvaluationArgument from src.python.review.common.file_system import ( Extension, get_parent_folder, get_restricted_extension, serialize_data_and_write_to_file, ) -from src.python.review.inspectors.issue import BaseIssue from src.python.review.quality.model import QualityType -from src.python.review.reviewers.utils.print_review import convert_json_to_issues def configure_arguments(parser: argparse.ArgumentParser) -> None: @@ -31,11 +27,6 @@ def configure_arguments(parser: argparse.ArgumentParser) -> None: f'(file contains grade and traceback (optional) columns)') -def __get_issues(df: pd.DataFrame, row: int) -> List[BaseIssue]: - parsed_json = json.loads(df.iloc[row][EvaluationArgument.TRACEBACK.value])['issues'] - return convert_json_to_issues(parsed_json) - - # Find difference between two dataframes. Return dict: # { # grade: [list_of_fragment_ids], @@ -63,8 +54,8 @@ def find_diffs(old_df: pd.DataFrame, new_df: pd.DataFrame) -> dict: diffs[ColumnName.GRADE.value].append(fragment_id) else: # Find difference between issues - old_issues = __get_issues(old_df, row) - new_issues = __get_issues(new_df, row) + old_issues = get_issues_by_row(old_df, row) + new_issues = get_issues_by_row(new_df, row) if len(old_issues) > len(new_issues): raise ValueError(f'New dataframe contains less issues than old for fragment {id}') difference = set(set(new_issues) - set(old_issues)) diff --git a/src/python/evaluation/inspectors/filter_issues.py b/src/python/evaluation/inspectors/filter_issues.py new file mode 100644 index 00000000..ca4b38b6 --- /dev/null +++ b/src/python/evaluation/inspectors/filter_issues.py @@ -0,0 +1,70 @@ +import argparse +from pathlib import Path +from typing import List, Set + +import pandas as pd +from src.python.common.tool_arguments import RunToolArgument +from src.python.evaluation.common.pandas_util import get_issues_from_json, get_solutions_df_by_file_path +from src.python.evaluation.common.util import ColumnName, EvaluationArgument +from src.python.review.common.file_system import Extension, get_parent_folder, serialize_data_and_write_to_file +from src.python.review.inspectors.issue import BaseIssue + + +TRACEBACK = EvaluationArgument.TRACEBACK.value +ID = ColumnName.ID.value +GRADE = ColumnName.GRADE.value + + +def configure_arguments(parser: argparse.ArgumentParser) -> None: + parser.add_argument(RunToolArgument.SOLUTIONS_FILE_PATH.value.long_name, + type=lambda value: Path(value).absolute(), + help=f'{RunToolArgument.SOLUTIONS_FILE_PATH.value.description}' + f'\nAll code fragments from this file must be graded ') + + parser.add_argument('-i', '--issues', + help='Set of issues', + default='') + + +def __parse_issues_arg(str_issues: str) -> Set[str]: + return set(str_issues.split(',')) + + +def __get_new_issues(traceback: str, new_issues_classes: Set[str]) -> List[BaseIssue]: + all_issues = get_issues_from_json(traceback) + return list(filter(lambda i: i.origin_class in new_issues_classes, all_issues)) + + +def __add_issues_for_fragment(fragment_id: int, new_issues: List[BaseIssue], diffs: dict) -> None: + if len(new_issues) > 0: + diffs[TRACEBACK][fragment_id] = new_issues + + +# Make a dict with the same structure as in the find_diffs function from diffs_between_df.py +def get_statistics_dict(solutions_df: pd.DataFrame, new_issues_classes: Set[str]) -> dict: + diffs = { + GRADE: [], + TRACEBACK: {}, + } + solutions_df.apply(lambda row: __add_issues_for_fragment(row[ID], + __get_new_issues(row[TRACEBACK], new_issues_classes), + diffs), axis=1) + return diffs + + +def main() -> None: + parser = argparse.ArgumentParser() + configure_arguments(parser) + args = parser.parse_args() + + solutions_file_path = args.solutions_file_path + solutions_df = get_solutions_df_by_file_path(solutions_file_path) + issues = __parse_issues_arg(args.issues) + + diffs = get_statistics_dict(solutions_df, issues) + output_path = get_parent_folder(Path(solutions_file_path)) / f'diffs{Extension.PICKLE.value}' + serialize_data_and_write_to_file(output_path, diffs) + + +if __name__ == '__main__': + main() diff --git a/src/python/evaluation/inspectors/get_worse_public_examples.py b/src/python/evaluation/inspectors/get_worse_public_examples.py new file mode 100644 index 00000000..1bb036c5 --- /dev/null +++ b/src/python/evaluation/inspectors/get_worse_public_examples.py @@ -0,0 +1,68 @@ +import argparse +from pathlib import Path +from typing import Dict, List + +import pandas as pd +from src.python.common.tool_arguments import RunToolArgument +from src.python.evaluation.common.csv_util import write_dataframe_to_csv +from src.python.evaluation.common.pandas_util import filter_df_by_condition, get_solutions_df_by_file_path +from src.python.evaluation.common.util import ColumnName, EvaluationArgument +from src.python.review.common.file_system import deserialize_data_from_file, Extension, get_parent_folder +from src.python.review.inspectors.issue import BaseIssue + + +def configure_arguments(parser: argparse.ArgumentParser) -> None: + parser.add_argument(RunToolArgument.SOLUTIONS_FILE_PATH.value.long_name, + type=lambda value: Path(value).absolute(), + help=RunToolArgument.SOLUTIONS_FILE_PATH.value.description) + + parser.add_argument(RunToolArgument.DIFFS_FILE_PATH.value.long_name, + type=lambda value: Path(value).absolute(), + help=RunToolArgument.DIFFS_FILE_PATH.value.description) + + parser.add_argument('-n', '--n', + help='The N worse fragments will be saved', + type=int, + default=10) + + +def __get_new_inspections(fragment_id_to_issues: Dict[int, List[BaseIssue]], fragment_id: int) -> str: + return ','.join(set(map(lambda i: i.origin_class, fragment_id_to_issues.get(fragment_id, [])))) + + +def __get_public_fragments(solutions_df: pd.DataFrame, diffs_dict: dict) -> pd.DataFrame: + # Keep only public solutions + public_fragments = filter_df_by_condition(solutions_df, ColumnName.IS_PUBLIC.value, 'YES') + count_inspections_column = 'count_inspections' + new_inspections_column = 'new_inspections' + + # Get only new inspections and count them + fragment_id_to_issues = diffs_dict[EvaluationArgument.TRACEBACK.value] + public_fragments[new_inspections_column] = public_fragments.apply( + lambda row: __get_new_inspections(fragment_id_to_issues, row[ColumnName.ID.value]), axis=1) + public_fragments[count_inspections_column] = public_fragments.apply( + lambda row: len(row[new_inspections_column].split(',')), axis=1) + + public_fragments = public_fragments.sort_values(count_inspections_column, ascending=False) + # Keep only public columns + return public_fragments[[ColumnName.CODE.value, EvaluationArgument.TRACEBACK.value, new_inspections_column]] + + +# TODO: add readme +def main() -> None: + parser = argparse.ArgumentParser() + configure_arguments(parser) + args = parser.parse_args() + + solutions_file_path = args.solutions_file_path + solutions_df = get_solutions_df_by_file_path(solutions_file_path) + diffs = deserialize_data_from_file(args.diffs_file_path) + + public_fragments = __get_public_fragments(solutions_df, diffs) + + output_path = get_parent_folder(Path(solutions_file_path)) / f'worse_fragments{Extension.CSV.value}' + write_dataframe_to_csv(output_path, public_fragments.head(args.n)) + + +if __name__ == '__main__': + main() diff --git a/src/python/evaluation/inspectors/print_inspectors_statistics.py b/src/python/evaluation/inspectors/print_inspectors_statistics.py new file mode 100644 index 00000000..8b132a31 --- /dev/null +++ b/src/python/evaluation/inspectors/print_inspectors_statistics.py @@ -0,0 +1,84 @@ +import argparse +from collections import defaultdict +from pathlib import Path +from typing import Dict + +from src.python.common.tool_arguments import RunToolArgument +from src.python.evaluation.common.util import ColumnName, EvaluationArgument +from src.python.evaluation.inspectors.common.statistics import IssuesStatistics +from src.python.review.common.file_system import deserialize_data_from_file +from src.python.review.inspectors.issue import ShortIssue + + +def configure_arguments(parser: argparse.ArgumentParser) -> None: + parser.add_argument(RunToolArgument.DIFFS_FILE_PATH.value.long_name, + type=lambda value: Path(value).absolute(), + help=RunToolArgument.DIFFS_FILE_PATH.value.description) + + parser.add_argument('--categorize', + help='If True, statistics will be categorized by several categories.', + action='store_true') + + parser.add_argument('-n', '--top_n', + help='The top N items will be printed', + type=int, + default=10) + + parser.add_argument('--full_stat', + help='If True, full statistics will be printed.', + action='store_true') + + +def has_incorrect_grades(diffs_dict: dict) -> bool: + return len(diffs_dict[ColumnName.GRADE.value]) > 0 + + +def gather_statistics(diffs_dict: dict) -> IssuesStatistics: + changed_grades_count = len(diffs_dict[EvaluationArgument.TRACEBACK.value]) + issues_dict: Dict[ShortIssue, int] = defaultdict(int) + for _, issues in diffs_dict[EvaluationArgument.TRACEBACK.value].items(): + for issue in issues: + short_issue = ShortIssue(origin_class=issue.origin_class, type=issue.type) + issues_dict[short_issue] += 1 + return IssuesStatistics(issues_dict, changed_grades_count) + + +def __print_top_n(statistics: IssuesStatistics, n: int, separator: str) -> None: + top_n = statistics.get_top_n_issues(n) + print(separator) + print(f'Top {n} issues:') + for issue, freq in top_n: + IssuesStatistics.print_issue_with_freq(issue, freq) + print(separator) + + +def main() -> None: + parser = argparse.ArgumentParser() + configure_arguments(parser) + args = parser.parse_args() + + separator = '______' + + diffs = deserialize_data_from_file(args.diffs_file_path) + if has_incorrect_grades(diffs): + print(f'WARNING! Was found incorrect grades in the following fragments: {diffs[ColumnName.GRADE.value]}.') + else: + print('SUCCESS! Was not found incorrect grades.') + print(separator) + + statistics = gather_statistics(diffs) + print(f'{statistics.changed_grades_count} fragments has additional issues') + print(f'{statistics.count_unique_issues()} unique issues was found') + + n = args.top_n + __print_top_n(statistics, n, separator) + + statistics.print_short_categorized_statistics() + print(separator) + + if args.full_stat: + statistics.print_full_statistics() + + +if __name__ == '__main__': + main() diff --git a/src/python/review/inspectors/issue.py b/src/python/review/inspectors/issue.py index c910bf80..965f2262 100644 --- a/src/python/review/inspectors/issue.py +++ b/src/python/review/inspectors/issue.py @@ -66,16 +66,21 @@ def get_base_issue_data_dict(cls, @dataclass(frozen=True, eq=True) -class BaseIssue: +class ShortIssue: + origin_class: str + + type: IssueType + + +@dataclass(frozen=True, eq=True) +class BaseIssue(ShortIssue): + description: str + file_path: Path line_no: int column_no: int - description: str - origin_class: str - inspector_type: InspectorType - type: IssueType class Measurable(abc.ABC):