From 80ea546894cff1e3b88822a696b303eb2c56ef83 Mon Sep 17 00:00:00 2001 From: Ilya Vlasov Date: Tue, 18 May 2021 12:23:57 +0300 Subject: [PATCH 01/73] Added project template for Java --- .../project_templates/java/build.gradle | 19 ++ .../java/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 59203 bytes .../gradle/wrapper/gradle-wrapper.properties | 5 + .../qodana/project_templates/java/gradlew | 185 ++++++++++++++++++ .../qodana/project_templates/java/gradlew.bat | 89 +++++++++ .../project_templates/java/settings.gradle | 2 + 6 files changed, 300 insertions(+) create mode 100644 src/python/evaluation/qodana/project_templates/java/build.gradle create mode 100644 src/python/evaluation/qodana/project_templates/java/gradle/wrapper/gradle-wrapper.jar create mode 100644 src/python/evaluation/qodana/project_templates/java/gradle/wrapper/gradle-wrapper.properties create mode 100755 src/python/evaluation/qodana/project_templates/java/gradlew create mode 100644 src/python/evaluation/qodana/project_templates/java/gradlew.bat create mode 100644 src/python/evaluation/qodana/project_templates/java/settings.gradle diff --git a/src/python/evaluation/qodana/project_templates/java/build.gradle b/src/python/evaluation/qodana/project_templates/java/build.gradle new file mode 100644 index 00000000..95687b5b --- /dev/null +++ b/src/python/evaluation/qodana/project_templates/java/build.gradle @@ -0,0 +1,19 @@ +plugins { + id 'java' +} + +group 'org.example' +version '1.0-SNAPSHOT' + +repositories { + mavenCentral() +} + +dependencies { + testImplementation 'org.junit.jupiter:junit-jupiter-api:5.6.0' + testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine' +} + +test { + useJUnitPlatform() +} \ No newline at end of file diff --git a/src/python/evaluation/qodana/project_templates/java/gradle/wrapper/gradle-wrapper.jar b/src/python/evaluation/qodana/project_templates/java/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..e708b1c023ec8b20f512888fe07c5bd3ff77bb8f GIT binary patch literal 59203 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

K7UpUK$>l@ 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 E2O(~oYXATM literal 0 HcmV?d00001 diff --git a/src/python/evaluation/qodana/project_templates/java/gradle/wrapper/gradle-wrapper.properties b/src/python/evaluation/qodana/project_templates/java/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..be52383e --- /dev/null +++ b/src/python/evaluation/qodana/project_templates/java/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-6.7-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/src/python/evaluation/qodana/project_templates/java/gradlew b/src/python/evaluation/qodana/project_templates/java/gradlew new file mode 100755 index 00000000..4f906e0c --- /dev/null +++ b/src/python/evaluation/qodana/project_templates/java/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/src/python/evaluation/qodana/project_templates/java/gradlew.bat b/src/python/evaluation/qodana/project_templates/java/gradlew.bat new file mode 100644 index 00000000..107acd32 --- /dev/null +++ b/src/python/evaluation/qodana/project_templates/java/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/src/python/evaluation/qodana/project_templates/java/settings.gradle b/src/python/evaluation/qodana/project_templates/java/settings.gradle new file mode 100644 index 00000000..670936d0 --- /dev/null +++ b/src/python/evaluation/qodana/project_templates/java/settings.gradle @@ -0,0 +1,2 @@ +rootProject.name = 'gradle_qqodana_example' + From db30732122031c342372c01804e767af0ca9d8d2 Mon Sep 17 00:00:00 2001 From: Ilya Vlasov Date: Tue, 18 May 2021 12:24:56 +0300 Subject: [PATCH 02/73] Added DatasetMarker --- src/python/evaluation/qodana/__init__.py | 0 .../evaluation/qodana/dataset_marking.py | 258 ++++++++++++++++++ 2 files changed, 258 insertions(+) create mode 100644 src/python/evaluation/qodana/__init__.py create mode 100644 src/python/evaluation/qodana/dataset_marking.py diff --git a/src/python/evaluation/qodana/__init__.py b/src/python/evaluation/qodana/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/python/evaluation/qodana/dataset_marking.py b/src/python/evaluation/qodana/dataset_marking.py new file mode 100644 index 00000000..6317cd4c --- /dev/null +++ b/src/python/evaluation/qodana/dataset_marking.py @@ -0,0 +1,258 @@ +import json +import logging +import os +import re +import shutil +import sys +import traceback +from argparse import ArgumentParser, Namespace +from collections import defaultdict +from dataclasses import dataclass +from math import ceil +from pathlib import Path +from typing import Any, Dict, Optional + +import numpy as np +import pandas as pd +from pandas import DataFrame +from python_on_whales import docker +from src.python.review.application_config import LanguageVersion +from src.python.review.common.file_system import new_temp_dir +from src.python.review.run_tool import positive_int + +logger = logging.getLogger(__name__) +logging.basicConfig(level=logging.INFO) + + +def configure_arguments(parser: ArgumentParser) -> None: + parser.add_argument("dataset_path", type=lambda value: Path(value).absolute(), help="Path to dataset") # TODO + + parser.add_argument( + "inspections_output", + type=lambda value: Path(value).absolute(), + help="The path where the id of the inspections will be saved", + ) + + parser.add_argument("-c", "--config", type=lambda value: Path(value).absolute(), help="Path to qodana.yaml") # TODO + + parser.add_argument("-l", "--limit", type=positive_int, help="dataset head limit. ONLY FOR DEBUG") # TODO + + parser.add_argument("-s", "--chunk-size", type=positive_int, help="The size of the chunk") # TODO + + parser.add_argument( + "-o", + "--dataset-output", + type=lambda value: Path(value).absolute(), + help="The path where the tagged dataset will be saved. " + "If not specified, the original dataset will be overwritten", + ) # TODO + + +@dataclass(init=False) +class InspectionData: + package: str + + def __init__(self, package: str, **kwargs: Any): + self.package = package + + def __str__(self): + return self.package + + +IGNORE = [LanguageVersion.KOTLIN.value, LanguageVersion.PYTHON_3.value] + + +class DatasetMarker: + dataset_path: Path + config: Optional[Path] + limit: Optional[int] + chunk_size: Optional[int] + inspection_to_id: Dict[str, int] + dataset_output: Path + inspections_output: Path + + def __init__(self, args: Namespace): + self.dataset_path = args.dataset_path + self.config = args.config + self.limit = args.limit + self.chunk_size = args.chunk_size + + self.dataset_output = self.dataset_path + if args.dataset_output is not None: + self.dataset_output = args.dataset_output + + self.inspections_output = args.inspections_output + + self.inspection_to_id = {} + + def mark(self): + df = pd.read_csv(self.dataset_path, index_col="id", nrows=self.limit) + + grouped_df = df.groupby("lang") + unique_languages = df["lang"].unique() + + logger.info(f"Unique languages: {unique_languages}") + + groups = [] + for language in unique_languages: + lang_df = grouped_df.get_group(language) + + if language in LanguageVersion.values() and language not in IGNORE: + logger.info(f"Processing the language: {language}") + groups.append(self._mark_language(lang_df, LanguageVersion(language))) + else: + logger.warning(f"Unknown language: {language}") + groups.append(lang_df) + + logger.info("Dataset processing finished") + + result = pd.concat(groups) + + logger.info("Writing the dataset to a file.") + result.to_csv(self.dataset_output) + + id_to_inspection = {value: index for index, value in self.inspection_to_id.items()} + id_to_inspection_df = pd.DataFrame.from_dict(id_to_inspection, "index") + id_to_inspection_df.index.name = "id" + id_to_inspection_df.columns = ["inspection"] + id_to_inspection_df.to_csv(self.inspections_output) + print(self.inspection_to_id) + print(id_to_inspection) + + def _mark_language(self, df: DataFrame, language: LanguageVersion): + number_of_chunks = 1 + if self.chunk_size is not None: + number_of_chunks = ceil(df.shape[0] / self.chunk_size) + + chunks = np.array_split(df, number_of_chunks) + for index, chunk in enumerate(chunks): + logger.info(f"Processing chunk: {index + 1} / {number_of_chunks}") + self._mark_chunk(chunk, language) + + logger.info(f"{language} processing finished.") + result = pd.concat(chunks) + return result + + def _mark_chunk(self, chunk: DataFrame, language: LanguageVersion): + with new_temp_dir() as temp_dir: + logger.info("Copying the template") + self._copy_template(temp_dir, language) + + if self.config: + logger.info("Copying the config") + self._copy_config(temp_dir) + + logger.info("Creating main files") + self._create_main_files(temp_dir, chunk, language) + + logger.info("Running qodana") + self._run_qodana(temp_dir) + + logger.info("Getting unique inspections") + inspections = self._get_inspections(temp_dir) + existing_inspections = set(self.inspection_to_id.keys()) + new_inspections = inspections.difference(existing_inspections) + + for inspection in new_inspections: + self.inspection_to_id[inspection] = len(self.inspection_to_id) + + logger.info("Parsing the output of qodana") + student_id_to_inspection_ids = self._parse(temp_dir, inspections) + chunk["inspection_ids"] = "" + for student_id, inspection_ids in student_id_to_inspection_ids.items(): + chunk.loc[student_id, "inspection_ids"] = ",".join(map(str, inspection_ids)) + + @staticmethod + def _copy_template(temp_dir: Path, language: LanguageVersion): + if ( + language == LanguageVersion.JAVA_11 + or language == LanguageVersion.JAVA_9 + or language == LanguageVersion.JAVA_8 + or language == LanguageVersion.JAVA_7 + ): + shutil.copytree(Path("./project_templates/java"), temp_dir, dirs_exist_ok=True) + else: + logger.warning(f"{language} is not supported yet") + + def _copy_config(self, temp_dir: Path): + shutil.copy(self.config, temp_dir) + + @staticmethod + def _create_main_files(temp_dir: Path, chunk: DataFrame, language: LanguageVersion): + if ( + language == LanguageVersion.JAVA_11 + or language == LanguageVersion.JAVA_9 + or language == LanguageVersion.JAVA_8 + or language == LanguageVersion.JAVA_7 + ): + (temp_dir / "results").mkdir() + dist_path = temp_dir / "src" / "main" / "java" + for index, row in chunk.iterrows(): + directory = dist_path / f"student{index}" + directory.mkdir(parents=True) + file_path = directory / "Main.java" + with open(file_path, "w") as file: + file.write(f"package student{index};\n\n") + file.write(row["code"]) + else: + logger.warning(f"{language} is not supported yet") + + @staticmethod + def _run_qodana(temp_dir: Path): + docker.run( + "jetbrains/qodana", + remove=True, + volumes=[(temp_dir, "/data/project/"), ((temp_dir / "results/"), "/data/results/")], + user=os.getuid(), + ) + + @staticmethod + def _get_inspections(temp_dir: Path) -> set[str]: + results_dir = temp_dir / "results" + files = os.listdir(results_dir) + + file_name_regex = re.compile(r"(\w*).json") + inspection_files = filter(lambda file: file_name_regex.match(file), files) + + return {file_name_regex.match(file).group(1) for file in inspection_files} + + def _parse(self, temp_dir: Path, inspections: set[str]): + results_dir = temp_dir / "results" + package_regex = re.compile(r"student(\d*)") + + student_id_to_inspections_ids = defaultdict(list) + for inspection in inspections: + inspection_id = self.inspection_to_id[inspection] + file_path = results_dir / f"{inspection}.json" + + with open(file_path) as file: + inspection_json = json.load(file) + + problems = inspection_json["problems"] + for problem_data in problems: + data = InspectionData(**problem_data) + student_match = package_regex.match(data.package) + if student_match: + student_id = int(student_match.group(1)) + student_id_to_inspections_ids[student_id].append(inspection_id) + + return student_id_to_inspections_ids + + +def main(): + parser = ArgumentParser() + configure_arguments(parser) + + try: + args = parser.parse_args() + dm = DatasetMarker(args) + dm.mark() + + except Exception: + traceback.print_exc() + logger.exception("An unexpected error") + return 2 + + +if __name__ == "__main__": + sys.exit(main()) From 8c85e3690d2907ac3bf25140a8f88da3590d823a Mon Sep 17 00:00:00 2001 From: Ilya Vlasov Date: Tue, 18 May 2021 20:46:28 +0300 Subject: [PATCH 03/73] Code refactoring --- .../evaluation/qodana/dataset_marking.py | 136 ++++++++++-------- 1 file changed, 75 insertions(+), 61 deletions(-) diff --git a/src/python/evaluation/qodana/dataset_marking.py b/src/python/evaluation/qodana/dataset_marking.py index 6317cd4c..513eb7b5 100644 --- a/src/python/evaluation/qodana/dataset_marking.py +++ b/src/python/evaluation/qodana/dataset_marking.py @@ -25,27 +25,41 @@ def configure_arguments(parser: ArgumentParser) -> None: - parser.add_argument("dataset_path", type=lambda value: Path(value).absolute(), help="Path to dataset") # TODO + parser.add_argument( + "dataset_path", + type=lambda value: Path(value).absolute(), + help=f"Dataset path. The dataset must contain at least three columns: 'id', 'code' and 'lang', where 'id' " + f"is a unique solution number, 'lang' is the language in which the code is written in the 'code' column. " + f"The 'lang' must belong to one of the following values: {LanguageVersion.values()}. " + f"If 'lang' is not equal to any of the values, the row will be skipped.", + ) parser.add_argument( - "inspections_output", + "inspections_output_path", type=lambda value: Path(value).absolute(), - help="The path where the id of the inspections will be saved", + help="Path where id of all found inspections will be saved.", ) - parser.add_argument("-c", "--config", type=lambda value: Path(value).absolute(), help="Path to qodana.yaml") # TODO + parser.add_argument("-c", "--config", type=lambda value: Path(value).absolute(), help="Path to qodana.yaml") - parser.add_argument("-l", "--limit", type=positive_int, help="dataset head limit. ONLY FOR DEBUG") # TODO + parser.add_argument( + "-l", + "--limit", + type=positive_int, + help="Allows you to read only the specified number of first rows from the dataset.", + ) - parser.add_argument("-s", "--chunk-size", type=positive_int, help="The size of the chunk") # TODO + parser.add_argument( + "-s", "--chunk-size", type=positive_int, help="The number of files that qodana will process at a time.", + ) parser.add_argument( "-o", - "--dataset-output", + "--dataset-output-path", type=lambda value: Path(value).absolute(), - help="The path where the tagged dataset will be saved. " - "If not specified, the original dataset will be overwritten", - ) # TODO + help="The path where the marked dataset will be saved. " + "If not specified, the original dataset will be overwritten.", + ) @dataclass(init=False) @@ -59,17 +73,14 @@ def __str__(self): return self.package -IGNORE = [LanguageVersion.KOTLIN.value, LanguageVersion.PYTHON_3.value] - - class DatasetMarker: dataset_path: Path config: Optional[Path] limit: Optional[int] chunk_size: Optional[int] inspection_to_id: Dict[str, int] - dataset_output: Path - inspections_output: Path + dataset_output_path: Path + inspections_output_path: Path def __init__(self, args: Namespace): self.dataset_path = args.dataset_path @@ -77,49 +88,51 @@ def __init__(self, args: Namespace): self.limit = args.limit self.chunk_size = args.chunk_size - self.dataset_output = self.dataset_path - if args.dataset_output is not None: - self.dataset_output = args.dataset_output + self.dataset_output_path = self.dataset_path + if args.dataset_output_path is not None: + self.dataset_output_path = args.dataset_output_path - self.inspections_output = args.inspections_output + self.inspections_output_path = args.inspections_output_path self.inspection_to_id = {} def mark(self): df = pd.read_csv(self.dataset_path, index_col="id", nrows=self.limit) - grouped_df = df.groupby("lang") + group_by_lang = df.groupby("lang") unique_languages = df["lang"].unique() logger.info(f"Unique languages: {unique_languages}") groups = [] for language in unique_languages: - lang_df = grouped_df.get_group(language) - - if language in LanguageVersion.values() and language not in IGNORE: - logger.info(f"Processing the language: {language}") - groups.append(self._mark_language(lang_df, LanguageVersion(language))) + lang_group = group_by_lang.get_group(language) + + if language in LanguageVersion.values(): + try: + logger.info(f"Processing the language: {language}") + groups.append(self._mark_language(lang_group, LanguageVersion(language))) + except NotImplementedError: + logger.warning(f"{language} needs implementation") + groups.append(lang_group) else: logger.warning(f"Unknown language: {language}") - groups.append(lang_df) + groups.append(lang_group) logger.info("Dataset processing finished") - result = pd.concat(groups) + df = pd.concat(groups) logger.info("Writing the dataset to a file.") - result.to_csv(self.dataset_output) + df.to_csv(self.dataset_output_path) id_to_inspection = {value: index for index, value in self.inspection_to_id.items()} - id_to_inspection_df = pd.DataFrame.from_dict(id_to_inspection, "index") + + id_to_inspection_df = pd.DataFrame.from_dict(id_to_inspection, orient="index", columns=["inspection"]) id_to_inspection_df.index.name = "id" - id_to_inspection_df.columns = ["inspection"] - id_to_inspection_df.to_csv(self.inspections_output) - print(self.inspection_to_id) - print(id_to_inspection) + id_to_inspection_df.to_csv(self.inspections_output_path) - def _mark_language(self, df: DataFrame, language: LanguageVersion): + def _mark_language(self, df: DataFrame, language: LanguageVersion) -> DataFrame: number_of_chunks = 1 if self.chunk_size is not None: number_of_chunks = ceil(df.shape[0] / self.chunk_size) @@ -157,10 +170,10 @@ def _mark_chunk(self, chunk: DataFrame, language: LanguageVersion): self.inspection_to_id[inspection] = len(self.inspection_to_id) logger.info("Parsing the output of qodana") - student_id_to_inspection_ids = self._parse(temp_dir, inspections) + solution_id_to_inspection_ids = self._parse(temp_dir, inspections) chunk["inspection_ids"] = "" - for student_id, inspection_ids in student_id_to_inspection_ids.items(): - chunk.loc[student_id, "inspection_ids"] = ",".join(map(str, inspection_ids)) + for solution_id, inspection_ids in solution_id_to_inspection_ids.items(): + chunk.loc[solution_id, "inspection_ids"] = ",".join(map(str, inspection_ids)) @staticmethod def _copy_template(temp_dir: Path, language: LanguageVersion): @@ -170,12 +183,12 @@ def _copy_template(temp_dir: Path, language: LanguageVersion): or language == LanguageVersion.JAVA_8 or language == LanguageVersion.JAVA_7 ): - shutil.copytree(Path("./project_templates/java"), temp_dir, dirs_exist_ok=True) + shutil.copytree(Path("./project_templates/java"), (temp_dir / "project"), dirs_exist_ok=True) else: - logger.warning(f"{language} is not supported yet") + raise NotImplementedError def _copy_config(self, temp_dir: Path): - shutil.copy(self.config, temp_dir) + shutil.copy(self.config, (temp_dir / "project")) @staticmethod def _create_main_files(temp_dir: Path, chunk: DataFrame, language: LanguageVersion): @@ -185,24 +198,25 @@ def _create_main_files(temp_dir: Path, chunk: DataFrame, language: LanguageVersi or language == LanguageVersion.JAVA_8 or language == LanguageVersion.JAVA_7 ): - (temp_dir / "results").mkdir() - dist_path = temp_dir / "src" / "main" / "java" + working_path = temp_dir / "project" / "src" / "main" / "java" for index, row in chunk.iterrows(): - directory = dist_path / f"student{index}" - directory.mkdir(parents=True) - file_path = directory / "Main.java" + src_directory = working_path / f"solution{index}" + src_directory.mkdir(parents=True) + file_path = src_directory / "Main.java" with open(file_path, "w") as file: - file.write(f"package student{index};\n\n") + file.write(f"package solution{index};\n\n") file.write(row["code"]) else: - logger.warning(f"{language} is not supported yet") + raise NotImplementedError @staticmethod def _run_qodana(temp_dir: Path): + (temp_dir / "results").mkdir() + docker.run( "jetbrains/qodana", remove=True, - volumes=[(temp_dir, "/data/project/"), ((temp_dir / "results/"), "/data/results/")], + volumes=[((temp_dir / "project"), "/data/project/"), ((temp_dir / "results/"), "/data/results/")], user=os.getuid(), ) @@ -218,25 +232,25 @@ def _get_inspections(temp_dir: Path) -> set[str]: def _parse(self, temp_dir: Path, inspections: set[str]): results_dir = temp_dir / "results" - package_regex = re.compile(r"student(\d*)") + package_regex = re.compile(r"solution(\d*)") - student_id_to_inspections_ids = defaultdict(list) + solution_id_to_inspections_ids = defaultdict(list) for inspection in inspections: inspection_id = self.inspection_to_id[inspection] - file_path = results_dir / f"{inspection}.json" + inspection_file_path = results_dir / f"{inspection}.json" - with open(file_path) as file: + with open(inspection_file_path) as file: inspection_json = json.load(file) problems = inspection_json["problems"] - for problem_data in problems: - data = InspectionData(**problem_data) - student_match = package_regex.match(data.package) - if student_match: - student_id = int(student_match.group(1)) - student_id_to_inspections_ids[student_id].append(inspection_id) + for problem in problems: + data = InspectionData(**problem) + package_match = package_regex.match(data.package) + if package_match: + solution_id = int(package_match.group(1)) + solution_id_to_inspections_ids[solution_id].append(inspection_id) - return student_id_to_inspections_ids + return solution_id_to_inspections_ids def main(): @@ -245,8 +259,8 @@ def main(): try: args = parser.parse_args() - dm = DatasetMarker(args) - dm.mark() + marker = DatasetMarker(args) + marker.mark() except Exception: traceback.print_exc() From b1fa21b26ffb27739b86726cb4c875d0b34a43f3 Mon Sep 17 00:00:00 2001 From: Ilya Vlasov Date: Tue, 18 May 2021 20:46:48 +0300 Subject: [PATCH 04/73] Added some words --- whitelist.txt | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/whitelist.txt b/whitelist.txt index 7095e567..0337ed42 100644 --- a/whitelist.txt +++ b/whitelist.txt @@ -93,4 +93,15 @@ nom wmc util tmp -Namespace \ No newline at end of file +Namespace +dataset +qodana +listdir +numpy +concat +loc +copytree +iterrows +nrows +groupby +getuid From 5929f46950faa602b7cf526ab6edb3838715643f Mon Sep 17 00:00:00 2001 From: Ilya Vlasov Date: Wed, 19 May 2021 21:55:43 +0300 Subject: [PATCH 05/73] Small code refactoring --- .../evaluation/qodana/dataset_marking.py | 73 +++++++++++-------- 1 file changed, 41 insertions(+), 32 deletions(-) diff --git a/src/python/evaluation/qodana/dataset_marking.py b/src/python/evaluation/qodana/dataset_marking.py index 513eb7b5..715ac507 100644 --- a/src/python/evaluation/qodana/dataset_marking.py +++ b/src/python/evaluation/qodana/dataset_marking.py @@ -10,12 +10,15 @@ from dataclasses import dataclass from math import ceil from pathlib import Path -from typing import Any, Dict, Optional +from typing import Any, Dict, Optional, Set + +sys.path.append("../../../..") import numpy as np import pandas as pd from pandas import DataFrame from python_on_whales import docker +from src.python.evaluation.common.util import ColumnName from src.python.review.application_config import LanguageVersion from src.python.review.common.file_system import new_temp_dir from src.python.review.run_tool import positive_int @@ -28,10 +31,12 @@ def configure_arguments(parser: ArgumentParser) -> None: parser.add_argument( "dataset_path", type=lambda value: Path(value).absolute(), - help=f"Dataset path. The dataset must contain at least three columns: 'id', 'code' and 'lang', where 'id' " - f"is a unique solution number, 'lang' is the language in which the code is written in the 'code' column. " - f"The 'lang' must belong to one of the following values: {LanguageVersion.values()}. " - f"If 'lang' is not equal to any of the values, the row will be skipped.", + help=f"Dataset path. The dataset must contain at least three columns: '{ColumnName.ID.value}', " + f"'{ColumnName.CODE.value}' and '{ColumnName.LANG.value}', where '{ColumnName.ID.value}' is a unique " + f"solution number, '{ColumnName.LANG.value}' is the language in which the code is written in the " + f"'{ColumnName.CODE.value}' column. The '{ColumnName.LANG.value}' must belong to one of the following " + f"values: {', '.join(LanguageVersion.values())}. " + f"If '{ColumnName.LANG.value}' is not equal to any of the values, the row will be skipped.", ) parser.add_argument( @@ -50,7 +55,10 @@ def configure_arguments(parser: ArgumentParser) -> None: ) parser.add_argument( - "-s", "--chunk-size", type=positive_int, help="The number of files that qodana will process at a time.", + "-s", + "--chunk-size", + type=positive_int, + help="The number of files that qodana will process at a time.", ) parser.add_argument( @@ -97,10 +105,10 @@ def __init__(self, args: Namespace): self.inspection_to_id = {} def mark(self): - df = pd.read_csv(self.dataset_path, index_col="id", nrows=self.limit) + df = pd.read_csv(self.dataset_path, index_col=ColumnName.ID.value, nrows=self.limit) - group_by_lang = df.groupby("lang") - unique_languages = df["lang"].unique() + group_by_lang = df.groupby(ColumnName.LANG.value) + unique_languages = df[ColumnName.LANG.value].unique() logger.info(f"Unique languages: {unique_languages}") @@ -148,21 +156,24 @@ def _mark_language(self, df: DataFrame, language: LanguageVersion) -> DataFrame: def _mark_chunk(self, chunk: DataFrame, language: LanguageVersion): with new_temp_dir() as temp_dir: + project_dir = temp_dir / "project" + results_dir = temp_dir / "results" + logger.info("Copying the template") - self._copy_template(temp_dir, language) + self._copy_template(project_dir, language) if self.config: logger.info("Copying the config") - self._copy_config(temp_dir) + self._copy_config(project_dir) logger.info("Creating main files") - self._create_main_files(temp_dir, chunk, language) + self._create_main_files(project_dir, chunk, language) logger.info("Running qodana") - self._run_qodana(temp_dir) + self._run_qodana(project_dir, results_dir) logger.info("Getting unique inspections") - inspections = self._get_inspections(temp_dir) + inspections = self._get_inspections(results_dir) existing_inspections = set(self.inspection_to_id.keys()) new_inspections = inspections.difference(existing_inspections) @@ -170,59 +181,58 @@ def _mark_chunk(self, chunk: DataFrame, language: LanguageVersion): self.inspection_to_id[inspection] = len(self.inspection_to_id) logger.info("Parsing the output of qodana") - solution_id_to_inspection_ids = self._parse(temp_dir, inspections) + solution_id_to_inspection_ids = self._parse(results_dir, inspections) chunk["inspection_ids"] = "" for solution_id, inspection_ids in solution_id_to_inspection_ids.items(): chunk.loc[solution_id, "inspection_ids"] = ",".join(map(str, inspection_ids)) @staticmethod - def _copy_template(temp_dir: Path, language: LanguageVersion): + def _copy_template(project_dir: Path, language: LanguageVersion): if ( language == LanguageVersion.JAVA_11 or language == LanguageVersion.JAVA_9 or language == LanguageVersion.JAVA_8 or language == LanguageVersion.JAVA_7 ): - shutil.copytree(Path("./project_templates/java"), (temp_dir / "project"), dirs_exist_ok=True) + shutil.copytree(Path("./project_templates/java"), project_dir, dirs_exist_ok=True) else: raise NotImplementedError - def _copy_config(self, temp_dir: Path): - shutil.copy(self.config, (temp_dir / "project")) + def _copy_config(self, project_dir: Path): + shutil.copy(self.config, project_dir) @staticmethod - def _create_main_files(temp_dir: Path, chunk: DataFrame, language: LanguageVersion): + def _create_main_files(project_dir: Path, chunk: DataFrame, language: LanguageVersion): if ( language == LanguageVersion.JAVA_11 or language == LanguageVersion.JAVA_9 or language == LanguageVersion.JAVA_8 or language == LanguageVersion.JAVA_7 ): - working_path = temp_dir / "project" / "src" / "main" / "java" + working_dir = project_dir / "src" / "main" / "java" for index, row in chunk.iterrows(): - src_directory = working_path / f"solution{index}" - src_directory.mkdir(parents=True) - file_path = src_directory / "Main.java" + solution_dir = working_dir / f"solution{index}" + solution_dir.mkdir(parents=True) + file_path = solution_dir / "Main.java" with open(file_path, "w") as file: file.write(f"package solution{index};\n\n") - file.write(row["code"]) + file.write(row[ColumnName.CODE.value]) else: raise NotImplementedError @staticmethod - def _run_qodana(temp_dir: Path): - (temp_dir / "results").mkdir() + def _run_qodana(project_dir: Path, results_dir: Path): + results_dir.mkdir() docker.run( "jetbrains/qodana", remove=True, - volumes=[((temp_dir / "project"), "/data/project/"), ((temp_dir / "results/"), "/data/results/")], + volumes=[(project_dir, "/data/project/"), (results_dir, "/data/results/")], user=os.getuid(), ) @staticmethod - def _get_inspections(temp_dir: Path) -> set[str]: - results_dir = temp_dir / "results" + def _get_inspections(results_dir: Path) -> Set[str]: files = os.listdir(results_dir) file_name_regex = re.compile(r"(\w*).json") @@ -230,8 +240,7 @@ def _get_inspections(temp_dir: Path) -> set[str]: return {file_name_regex.match(file).group(1) for file in inspection_files} - def _parse(self, temp_dir: Path, inspections: set[str]): - results_dir = temp_dir / "results" + def _parse(self, results_dir: Path, inspections: Set[str]): package_regex = re.compile(r"solution(\d*)") solution_id_to_inspections_ids = defaultdict(list) From 1a34b5007eae02219358bf422ef87501f2fc3391 Mon Sep 17 00:00:00 2001 From: Ilya Vlasov Date: Wed, 19 May 2021 21:56:02 +0300 Subject: [PATCH 06/73] Added new requirements --- requirements-evaluation.txt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/requirements-evaluation.txt b/requirements-evaluation.txt index 11910373..e498430c 100644 --- a/requirements-evaluation.txt +++ b/requirements-evaluation.txt @@ -1,2 +1,5 @@ openpyxl==3.0.7 -pandas==1.2.3 \ No newline at end of file +pandas==1.2.3 + +numpy~=1.20.2 +python_on_whales~=0.17.1 From ecbcdf490cc992cb9b51dd58363fc4056a05885e Mon Sep 17 00:00:00 2001 From: Ilya Vlasov Date: Wed, 19 May 2021 21:56:13 +0300 Subject: [PATCH 07/73] Added ID to ColumnName --- src/python/evaluation/common/util.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/python/evaluation/common/util.py b/src/python/evaluation/common/util.py index c306d3b7..c03f2075 100644 --- a/src/python/evaluation/common/util.py +++ b/src/python/evaluation/common/util.py @@ -6,6 +6,7 @@ @unique class ColumnName(Enum): + ID = "id" CODE = "code" LANG = "lang" LANGUAGE = "language" From 92f9a8a9352330e545db6204305e0e624cbdfc82 Mon Sep 17 00:00:00 2001 From: Ilya Vlasov <55441714+GirZ0n@users.noreply.github.com> Date: Thu, 20 May 2021 10:53:53 +0300 Subject: [PATCH 08/73] Added README.md --- src/python/evaluation/qodana/README.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 src/python/evaluation/qodana/README.md diff --git a/src/python/evaluation/qodana/README.md b/src/python/evaluation/qodana/README.md new file mode 100644 index 00000000..46cdf590 --- /dev/null +++ b/src/python/evaluation/qodana/README.md @@ -0,0 +1,23 @@ +# Dataset marker +This script allows you to mark up a dataset using the found qodana inspections. + +The dataset must contain at least three columns: `id`, `code` and `lang`, where `id` is a unique solution number, `lang` is the language in which the code is written in the `code` column. The `lang` must belong to one of the following values: `java7`, `java8`, `java9`, `java11`, `python3`, `kotlin`. If `lang` is not equal to any of the values, the row will be skipped. + +The dataset must have the format `csv`. The marked dataset is also in `csv` format, with a new column `inpection_ids` added, which contains a list of id's of all found inspections. The table with found inspections, consists of two columns: `id` and `inspection`, and is also in `csv` format. + +# Usage +Run the [dataset_marking.py](dataset_marking.py) with the arguments from command line. + +### Required arguments + +`dataset_path` — path to dataset. + +`inspections_output_path` — path where id of all found inspections will be saved. + +### Optional arguments +| Argument | Description | +|-----------------------------------|--------------------------------------------------------------------------------------------------------------| +| **-c**, **--config** | Path to qodana.yaml. Default is `None`. | +| **-l**, **--limit** | Allows you to read only the specified number of first rows from the dataset. Default is `None`. | +| **-s**, **--chunk-size** | The number of files that qodana will process at a time. Default is `5000`. | +| **-o**, **--dataset-output-path** | The path where the marked dataset will be saved. If not specified, the original dataset will be overwritten. | From 3ef6a4262d0666b088fb7c0fda0093c10b8155ca Mon Sep 17 00:00:00 2001 From: Ilya Vlasov Date: Thu, 20 May 2021 10:55:49 +0300 Subject: [PATCH 09/73] Added default value for --chunk-size --- src/python/evaluation/qodana/dataset_marking.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/python/evaluation/qodana/dataset_marking.py b/src/python/evaluation/qodana/dataset_marking.py index 715ac507..9abd5e44 100644 --- a/src/python/evaluation/qodana/dataset_marking.py +++ b/src/python/evaluation/qodana/dataset_marking.py @@ -59,6 +59,7 @@ def configure_arguments(parser: ArgumentParser) -> None: "--chunk-size", type=positive_int, help="The number of files that qodana will process at a time.", + default=5000, ) parser.add_argument( From 7cd79e0473b7653ac35c033180faabf3fba5c4b4 Mon Sep 17 00:00:00 2001 From: "Anastasiia.Birillo" Date: Thu, 20 May 2021 20:29:19 +0300 Subject: [PATCH 10/73] parse qodana output --- requirements-evaluation.txt | 1 + .../evaluation/qodana/dataset_marking.py | 30 +++++++++++++++---- src/python/evaluation/qodana/util/__init__.py | 0 .../evaluation/qodana/util/qoadana_issue.py | 11 +++++++ 4 files changed, 36 insertions(+), 6 deletions(-) create mode 100644 src/python/evaluation/qodana/util/__init__.py create mode 100644 src/python/evaluation/qodana/util/qoadana_issue.py diff --git a/requirements-evaluation.txt b/requirements-evaluation.txt index e498430c..a325d3c0 100644 --- a/requirements-evaluation.txt +++ b/requirements-evaluation.txt @@ -3,3 +3,4 @@ pandas==1.2.3 numpy~=1.20.2 python_on_whales~=0.17.1 +docker diff --git a/src/python/evaluation/qodana/dataset_marking.py b/src/python/evaluation/qodana/dataset_marking.py index 9abd5e44..cc6878b6 100644 --- a/src/python/evaluation/qodana/dataset_marking.py +++ b/src/python/evaluation/qodana/dataset_marking.py @@ -10,7 +10,9 @@ from dataclasses import dataclass from math import ceil from pathlib import Path -from typing import Any, Dict, Optional, Set +from typing import Any, Dict, Optional, Set, List + +from src.python.evaluation.qodana.util.qoadana_issue import QodanaIssue sys.path.append("../../../..") @@ -155,6 +157,21 @@ def _mark_language(self, df: DataFrame, language: LanguageVersion) -> DataFrame: result = pd.concat(chunks) return result + @classmethod + def _get_fragment_id_from_fragment_file_path(cls, fragment_file_path: str) -> int: + pass + + @classmethod + def _parse_inspections_files(cls, inspections_files: List[Path]): + for file in inspections_files: + issues = json.loads(str(file))['problems'] + for issue in issues: + qodana_issue = QodanaIssue(line=issue['line'], offset=issue['offset'], length=issue['length'], + highlighted_element=issue['highlighted_element'], + description=issue['description']) + pass + pass + def _mark_chunk(self, chunk: DataFrame, language: LanguageVersion): with new_temp_dir() as temp_dir: project_dir = temp_dir / "project" @@ -174,7 +191,10 @@ def _mark_chunk(self, chunk: DataFrame, language: LanguageVersion): self._run_qodana(project_dir, results_dir) logger.info("Getting unique inspections") - inspections = self._get_inspections(results_dir) + inspections = self._get_inspections_files(results_dir) + + # Todo: open all jsons and parse inspections + existing_inspections = set(self.inspection_to_id.keys()) new_inspections = inspections.difference(existing_inspections) @@ -233,13 +253,11 @@ def _run_qodana(project_dir: Path, results_dir: Path): ) @staticmethod - def _get_inspections(results_dir: Path) -> Set[str]: + def _get_inspections_files(results_dir: Path) -> Set[Path]: files = os.listdir(results_dir) file_name_regex = re.compile(r"(\w*).json") - inspection_files = filter(lambda file: file_name_regex.match(file), files) - - return {file_name_regex.match(file).group(1) for file in inspection_files} + return set(map(lambda f: results_dir / f, filter(lambda file: file_name_regex.match(file), files))) def _parse(self, results_dir: Path, inspections: Set[str]): package_regex = re.compile(r"solution(\d*)") diff --git a/src/python/evaluation/qodana/util/__init__.py b/src/python/evaluation/qodana/util/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/python/evaluation/qodana/util/qoadana_issue.py b/src/python/evaluation/qodana/util/qoadana_issue.py new file mode 100644 index 00000000..ff4db5d7 --- /dev/null +++ b/src/python/evaluation/qodana/util/qoadana_issue.py @@ -0,0 +1,11 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class QodanaIssue: + fragment_id: int + line: int + offset: int + length: int + highlighted_element: str + description: str From a8b80c0a2b30d8c8c4a72c2acb68fedc9d9723dd Mon Sep 17 00:00:00 2001 From: Ilya Vlasov <55441714+GirZ0n@users.noreply.github.com> Date: Thu, 20 May 2021 20:30:39 +0300 Subject: [PATCH 11/73] Update README.md --- src/python/evaluation/qodana/README.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/python/evaluation/qodana/README.md b/src/python/evaluation/qodana/README.md index 46cdf590..35ccb782 100644 --- a/src/python/evaluation/qodana/README.md +++ b/src/python/evaluation/qodana/README.md @@ -1,5 +1,5 @@ -# Dataset marker -This script allows you to mark up a dataset using the found qodana inspections. +# Dataset label +This script allows you to mark up a dataset using the found [Qodana](https://github.com/JetBrains/Qodana) inspections. The dataset must contain at least three columns: `id`, `code` and `lang`, where `id` is a unique solution number, `lang` is the language in which the code is written in the `code` column. The `lang` must belong to one of the following values: `java7`, `java8`, `java9`, `java11`, `python3`, `kotlin`. If `lang` is not equal to any of the values, the row will be skipped. @@ -15,9 +15,9 @@ Run the [dataset_marking.py](dataset_marking.py) with the arguments from command `inspections_output_path` — path where id of all found inspections will be saved. ### Optional arguments -| Argument | Description | -|-----------------------------------|--------------------------------------------------------------------------------------------------------------| -| **-c**, **--config** | Path to qodana.yaml. Default is `None`. | -| **-l**, **--limit** | Allows you to read only the specified number of first rows from the dataset. Default is `None`. | -| **-s**, **--chunk-size** | The number of files that qodana will process at a time. Default is `5000`. | -| **-o**, **--dataset-output-path** | The path where the marked dataset will be saved. If not specified, the original dataset will be overwritten. | +| Argument | Description | +|-|-| +| **‑c**, **‑‑config** | Path to qodana.yaml. If the path is not specified, Qodana will start without a configuration file| +| **‑l**, **‑‑limit** | Allows you to read only the specified number of first rows from the dataset. If no limit is specified, the whole dataset will be processed. | +| **‑s**, **‑‑chunk‑size** | The number of files that Qodana will process at a time. Default is `5000`. | +| **‑o**, **‑‑dataset‑output‑path** | The path where the marked dataset will be saved. If not specified, the original dataset will be overwritten. | From 75cac7b4e64570c74c950c9a060492511e7ffd14 Mon Sep 17 00:00:00 2001 From: "Anastasiia.Birillo" Date: Thu, 20 May 2021 21:12:42 +0300 Subject: [PATCH 12/73] Change qodana scipt output --- .../evaluation/qodana/dataset_marking.py | 93 ++++++++++--------- .../util/{qoadana_issue.py => models.py} | 7 ++ src/python/review/common/file_system.py | 4 +- 3 files changed, 59 insertions(+), 45 deletions(-) rename src/python/evaluation/qodana/util/{qoadana_issue.py => models.py} (61%) diff --git a/src/python/evaluation/qodana/dataset_marking.py b/src/python/evaluation/qodana/dataset_marking.py index cc6878b6..7697fc23 100644 --- a/src/python/evaluation/qodana/dataset_marking.py +++ b/src/python/evaluation/qodana/dataset_marking.py @@ -10,9 +10,7 @@ from dataclasses import dataclass from math import ceil from pathlib import Path -from typing import Any, Dict, Optional, Set, List - -from src.python.evaluation.qodana.util.qoadana_issue import QodanaIssue +from typing import Any, Dict, List, Optional, Set sys.path.append("../../../..") @@ -21,8 +19,11 @@ from pandas import DataFrame from python_on_whales import docker from src.python.evaluation.common.util import ColumnName +from src.python.evaluation.qodana.util.models import QodanaColumnName, QodanaIssue from src.python.review.application_config import LanguageVersion -from src.python.review.common.file_system import new_temp_dir +from src.python.review.common.file_system import ( + get_content_from_file, get_name_from_path, get_parent_folder, new_temp_dir, +) from src.python.review.run_tool import positive_int logger = logging.getLogger(__name__) @@ -105,10 +106,8 @@ def __init__(self, args: Namespace): self.inspections_output_path = args.inspections_output_path - self.inspection_to_id = {} - def mark(self): - df = pd.read_csv(self.dataset_path, index_col=ColumnName.ID.value, nrows=self.limit) + df = pd.read_csv(self.dataset_path, nrows=self.limit) group_by_lang = df.groupby(ColumnName.LANG.value) unique_languages = df[ColumnName.LANG.value].unique() @@ -136,12 +135,12 @@ def mark(self): logger.info("Writing the dataset to a file.") df.to_csv(self.dataset_output_path) - - id_to_inspection = {value: index for index, value in self.inspection_to_id.items()} - - id_to_inspection_df = pd.DataFrame.from_dict(id_to_inspection, orient="index", columns=["inspection"]) - id_to_inspection_df.index.name = "id" - id_to_inspection_df.to_csv(self.inspections_output_path) + # + # id_to_inspection = {value: index for index, value in self.inspection_to_id.items()} + # + # id_to_inspection_df = pd.DataFrame.from_dict(id_to_inspection, orient="index", columns=["inspection"]) + # id_to_inspection_df.index.name = "id" + # id_to_inspection_df.to_csv(self.inspections_output_path) def _mark_language(self, df: DataFrame, language: LanguageVersion) -> DataFrame: number_of_chunks = 1 @@ -149,30 +148,43 @@ def _mark_language(self, df: DataFrame, language: LanguageVersion) -> DataFrame: number_of_chunks = ceil(df.shape[0] / self.chunk_size) chunks = np.array_split(df, number_of_chunks) + labeled_chunks = [] for index, chunk in enumerate(chunks): logger.info(f"Processing chunk: {index + 1} / {number_of_chunks}") - self._mark_chunk(chunk, language) + chunk = self._mark_chunk(chunk, language) + labeled_chunks.append(chunk) logger.info(f"{language} processing finished.") - result = pd.concat(chunks) + result = pd.concat(labeled_chunks) return result + @classmethod + def _extract_fragment_id(cls, folder_name: str) -> int: + numbers = re.findall(r'\d+', folder_name) + if len(numbers) != 1: + raise ValueError(f'Can npt extract fragment id from {folder_name}') + return numbers[0] + @classmethod def _get_fragment_id_from_fragment_file_path(cls, fragment_file_path: str) -> int: - pass + folder_name = get_name_from_path(get_parent_folder(fragment_file_path), with_extension=False) + return cls._extract_fragment_id(folder_name) @classmethod - def _parse_inspections_files(cls, inspections_files: List[Path]): + def _parse_inspections_files(cls, inspections_files: Set[Path]) -> Dict[int, List[QodanaIssue]]: + id_to_issues: Dict[int, List[QodanaIssue]] = defaultdict(list) for file in inspections_files: - issues = json.loads(str(file))['problems'] + issues = json.loads(get_content_from_file(file))['problems'] for issue in issues: + fragment_id = cls._get_fragment_id_from_fragment_file_path(issue['file']) qodana_issue = QodanaIssue(line=issue['line'], offset=issue['offset'], length=issue['length'], highlighted_element=issue['highlighted_element'], - description=issue['description']) - pass - pass + description=issue['description'], fragment_id=fragment_id, + problem_id=issue['problem_class']['id']) + id_to_issues[fragment_id].append(qodana_issue) + return id_to_issues - def _mark_chunk(self, chunk: DataFrame, language: LanguageVersion): + def _mark_chunk(self, chunk: DataFrame, language: LanguageVersion) -> pd.DataFrame: with new_temp_dir() as temp_dir: project_dir = temp_dir / "project" results_dir = temp_dir / "results" @@ -190,22 +202,15 @@ def _mark_chunk(self, chunk: DataFrame, language: LanguageVersion): logger.info("Running qodana") self._run_qodana(project_dir, results_dir) - logger.info("Getting unique inspections") - inspections = self._get_inspections_files(results_dir) - - # Todo: open all jsons and parse inspections + logger.info("Getting inspections") + inspections_files = self._get_inspections_files(results_dir) + inspections = self._parse_inspections_files(inspections_files) - existing_inspections = set(self.inspection_to_id.keys()) - new_inspections = inspections.difference(existing_inspections) + logger.info("Write inspections") + chunk[QodanaColumnName.INSPECTIONS.value] = chunk.apply( + lambda row: inspections.get(row[ColumnName.ID.value], []), axis=1) - for inspection in new_inspections: - self.inspection_to_id[inspection] = len(self.inspection_to_id) - - logger.info("Parsing the output of qodana") - solution_id_to_inspection_ids = self._parse(results_dir, inspections) - chunk["inspection_ids"] = "" - for solution_id, inspection_ids in solution_id_to_inspection_ids.items(): - chunk.loc[solution_id, "inspection_ids"] = ",".join(map(str, inspection_ids)) + return chunk @staticmethod def _copy_template(project_dir: Path, language: LanguageVersion): @@ -244,13 +249,15 @@ def _create_main_files(project_dir: Path, chunk: DataFrame, language: LanguageVe @staticmethod def _run_qodana(project_dir: Path, results_dir: Path): results_dir.mkdir() - - docker.run( - "jetbrains/qodana", - remove=True, - volumes=[(project_dir, "/data/project/"), (results_dir, "/data/results/")], - user=os.getuid(), - ) + try: + docker.run( + "jetbrains/qodana", + remove=True, + volumes=[(project_dir, "/data/project/"), (results_dir, "/data/results/")], + user=os.getuid(), + ) + except Exception as e: + logger.exception(f'Error during qodana running: {e}') @staticmethod def _get_inspections_files(results_dir: Path) -> Set[Path]: diff --git a/src/python/evaluation/qodana/util/qoadana_issue.py b/src/python/evaluation/qodana/util/models.py similarity index 61% rename from src/python/evaluation/qodana/util/qoadana_issue.py rename to src/python/evaluation/qodana/util/models.py index ff4db5d7..0cc7de2f 100644 --- a/src/python/evaluation/qodana/util/qoadana_issue.py +++ b/src/python/evaluation/qodana/util/models.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from enum import Enum, unique @dataclass(frozen=True) @@ -9,3 +10,9 @@ class QodanaIssue: length: int highlighted_element: str description: str + problem_id: str + + +@unique +class QodanaColumnName(Enum): + INSPECTIONS = 'inspections' diff --git a/src/python/review/common/file_system.py b/src/python/review/common/file_system.py index eb5bc768..a06daed7 100644 --- a/src/python/review/common/file_system.py +++ b/src/python/review/common/file_system.py @@ -85,7 +85,7 @@ def deserialize_data_from_file(path: Path) -> Any: # 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: +def get_name_from_path(path: Union[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) @@ -173,7 +173,7 @@ def add_slash(path: str) -> str: return path -def get_parent_folder(path: Path, to_add_slash: bool = False) -> Path: +def get_parent_folder(path: Union[Path, str], to_add_slash: bool = False) -> Path: path = remove_slash(str(path)) parent_folder = '/'.join(path.split('/')[:-1]) if to_add_slash: From c428b7859d2e4323f7dfef4734053b291638d9a7 Mon Sep 17 00:00:00 2001 From: "Anastasiia.Birillo" Date: Fri, 21 May 2021 12:26:12 +0300 Subject: [PATCH 13/73] Fix a bug with qodana --- .../evaluation/qodana/dataset_marking.py | 83 +++++++++---------- src/python/evaluation/qodana/util/models.py | 39 +++++++++ src/python/review/common/file_system.py | 6 ++ src/python/review/common/subprocess_runner.py | 5 ++ whitelist.txt | 1 + 5 files changed, 92 insertions(+), 42 deletions(-) diff --git a/src/python/evaluation/qodana/dataset_marking.py b/src/python/evaluation/qodana/dataset_marking.py index 7697fc23..574e7d07 100644 --- a/src/python/evaluation/qodana/dataset_marking.py +++ b/src/python/evaluation/qodana/dataset_marking.py @@ -17,13 +17,14 @@ import numpy as np import pandas as pd from pandas import DataFrame -from python_on_whales import docker +from src.python.evaluation.common.csv_util import write_dataframe_to_csv from src.python.evaluation.common.util import ColumnName -from src.python.evaluation.qodana.util.models import QodanaColumnName, QodanaIssue +from src.python.evaluation.qodana.util.models import QodanaColumnName, QodanaIssue, QodanaJsonField from src.python.review.application_config import LanguageVersion from src.python.review.common.file_system import ( - get_content_from_file, get_name_from_path, get_parent_folder, new_temp_dir, + create_directory, get_content_from_file, get_name_from_path, get_parent_folder, remove_directory, ) +from src.python.review.common.subprocess_runner import run_and_wait from src.python.review.run_tool import positive_int logger = logging.getLogger(__name__) @@ -134,13 +135,7 @@ def mark(self): df = pd.concat(groups) logger.info("Writing the dataset to a file.") - df.to_csv(self.dataset_output_path) - # - # id_to_inspection = {value: index for index, value in self.inspection_to_id.items()} - # - # id_to_inspection_df = pd.DataFrame.from_dict(id_to_inspection, orient="index", columns=["inspection"]) - # id_to_inspection_df.index.name = "id" - # id_to_inspection_df.to_csv(self.inspections_output_path) + write_dataframe_to_csv(self.dataset_output_path, df) def _mark_language(self, df: DataFrame, language: LanguageVersion) -> DataFrame: number_of_chunks = 1 @@ -149,10 +144,10 @@ def _mark_language(self, df: DataFrame, language: LanguageVersion) -> DataFrame: chunks = np.array_split(df, number_of_chunks) labeled_chunks = [] + # Todo: run this in parallel for index, chunk in enumerate(chunks): logger.info(f"Processing chunk: {index + 1} / {number_of_chunks}") - chunk = self._mark_chunk(chunk, language) - labeled_chunks.append(chunk) + labeled_chunks.append(self._mark_chunk(chunk, language, index)) logger.info(f"{language} processing finished.") result = pd.concat(labeled_chunks) @@ -176,7 +171,7 @@ def _parse_inspections_files(cls, inspections_files: Set[Path]) -> Dict[int, Lis for file in inspections_files: issues = json.loads(get_content_from_file(file))['problems'] for issue in issues: - fragment_id = cls._get_fragment_id_from_fragment_file_path(issue['file']) + fragment_id = int(cls._get_fragment_id_from_fragment_file_path(issue['file'])) qodana_issue = QodanaIssue(line=issue['line'], offset=issue['offset'], length=issue['length'], highlighted_element=issue['highlighted_element'], description=issue['description'], fragment_id=fragment_id, @@ -184,33 +179,43 @@ def _parse_inspections_files(cls, inspections_files: Set[Path]) -> Dict[int, Lis id_to_issues[fragment_id].append(qodana_issue) return id_to_issues - def _mark_chunk(self, chunk: DataFrame, language: LanguageVersion) -> pd.DataFrame: - with new_temp_dir() as temp_dir: - project_dir = temp_dir / "project" - results_dir = temp_dir / "results" + @classmethod + def _to_json(cls, issues: List[QodanaIssue]) -> str: + issues_json = { + QodanaJsonField.ISSUES.value: list(map(lambda i: i.to_json(), issues)), + } + return json.dumps(issues_json) + + def _mark_chunk(self, chunk: DataFrame, language: LanguageVersion, chunk_id: int) -> pd.DataFrame: + tmp_file_path = self.dataset_path.parent.absolute() / f'qodana_project_{chunk_id}' + create_directory(tmp_file_path) + + project_dir = tmp_file_path / "project" + results_dir = tmp_file_path / "results" - logger.info("Copying the template") - self._copy_template(project_dir, language) + logger.info("Copying the template") + self._copy_template(project_dir, language) - if self.config: - logger.info("Copying the config") - self._copy_config(project_dir) + if self.config: + logger.info("Copying the config") + self._copy_config(project_dir) - logger.info("Creating main files") - self._create_main_files(project_dir, chunk, language) + logger.info("Creating main files") + self._create_main_files(project_dir, chunk, language) - logger.info("Running qodana") - self._run_qodana(project_dir, results_dir) + logger.info("Running qodana") + self._run_qodana(project_dir, results_dir) - logger.info("Getting inspections") - inspections_files = self._get_inspections_files(results_dir) - inspections = self._parse_inspections_files(inspections_files) + logger.info("Getting inspections") + inspections_files = self._get_inspections_files(results_dir) + inspections = self._parse_inspections_files(inspections_files) - logger.info("Write inspections") - chunk[QodanaColumnName.INSPECTIONS.value] = chunk.apply( - lambda row: inspections.get(row[ColumnName.ID.value], []), axis=1) + logger.info("Write inspections") + chunk[QodanaColumnName.INSPECTIONS.value] = chunk.apply( + lambda row: self._to_json(inspections.get(row[ColumnName.ID.value], [])), axis=1) - return chunk + remove_directory(tmp_file_path) + return chunk @staticmethod def _copy_template(project_dir: Path, language: LanguageVersion): @@ -249,15 +254,9 @@ def _create_main_files(project_dir: Path, chunk: DataFrame, language: LanguageVe @staticmethod def _run_qodana(project_dir: Path, results_dir: Path): results_dir.mkdir() - try: - docker.run( - "jetbrains/qodana", - remove=True, - volumes=[(project_dir, "/data/project/"), (results_dir, "/data/results/")], - user=os.getuid(), - ) - except Exception as e: - logger.exception(f'Error during qodana running: {e}') + command = ['docker', 'run', '--rm', '-v', f'{project_dir}/:/data/project/', '-v', + f'{results_dir}/:/data/results/', 'jetbrains/qodana'] + run_and_wait(command) @staticmethod def _get_inspections_files(results_dir: Path) -> Set[Path]: diff --git a/src/python/evaluation/qodana/util/models.py b/src/python/evaluation/qodana/util/models.py index 0cc7de2f..f5b3a589 100644 --- a/src/python/evaluation/qodana/util/models.py +++ b/src/python/evaluation/qodana/util/models.py @@ -1,3 +1,4 @@ +import json from dataclasses import dataclass from enum import Enum, unique @@ -12,7 +13,45 @@ class QodanaIssue: description: str problem_id: str + def to_json(self) -> str: + issue = { + QodanaJsonField.FRAGMENT_ID.value: self.fragment_id, + QodanaJsonField.LINE.value: self.line, + QodanaJsonField.OFFSET.value: self.offset, + QodanaJsonField.LENGTH.value: self.length, + QodanaJsonField.HIGHLIGHTED_ELEMENT.value: self.highlighted_element, + QodanaJsonField.DESCRIPTION.value: self.description, + QodanaJsonField.PROBLEM_ID.value: self.problem_id, + } + return json.dumps(issue) + + @classmethod + def from_json(cls, str_json: str) -> 'QodanaIssue': + issue = json.loads(str_json) + return QodanaIssue( + fragment_id=issue[QodanaJsonField.FRAGMENT_ID.value], + line=issue[QodanaJsonField.LINE.value], + offset=issue[QodanaJsonField.OFFSET.value], + length=issue[QodanaJsonField.LENGTH.value], + highlighted_element=issue[QodanaJsonField.HIGHLIGHTED_ELEMENT.value], + description=issue[QodanaJsonField.DESCRIPTION.value], + problem_id=issue[QodanaJsonField.PROBLEM_ID.value], + ) + @unique class QodanaColumnName(Enum): INSPECTIONS = 'inspections' + + +@unique +class QodanaJsonField(Enum): + FRAGMENT_ID = 'fragment_id' + LINE = 'line' + OFFSET = 'offset' + LENGTH = 'length' + HIGHLIGHTED_ELEMENT = 'highlighted_element' + DESCRIPTION = 'description' + PROBLEM_ID = 'problem_id' + + ISSUES = 'issues' diff --git a/src/python/review/common/file_system.py b/src/python/review/common/file_system.py index a06daed7..3e2e8bce 100644 --- a/src/python/review/common/file_system.py +++ b/src/python/review/common/file_system.py @@ -2,6 +2,7 @@ import os import pickle import re +import shutil import tempfile from contextlib import contextmanager from enum import Enum, unique @@ -167,6 +168,11 @@ def remove_slash(path: str) -> str: return path.rstrip('/') +def remove_directory(directory: Union[str, Path]) -> None: + if os.path.isdir(directory): + shutil.rmtree(directory, ignore_errors=True) + + def add_slash(path: str) -> str: if not path.endswith('/'): path += '/' diff --git a/src/python/review/common/subprocess_runner.py b/src/python/review/common/subprocess_runner.py index a25cbdcd..2a89ad42 100644 --- a/src/python/review/common/subprocess_runner.py +++ b/src/python/review/common/subprocess_runner.py @@ -21,3 +21,8 @@ def run_in_subprocess(command: List[str]) -> str: logger.debug('%s\'s stderr:\n%s' % (command[0], stderr)) return stdout + + +def run_and_wait(command: List[str]) -> None: + process = subprocess.Popen(command) + process.wait() diff --git a/whitelist.txt b/whitelist.txt index bbf66332..3e331750 100644 --- a/whitelist.txt +++ b/whitelist.txt @@ -113,3 +113,4 @@ iterrows nrows groupby getuid +Popen From 8489c9d0564f0a780521345f18c2f016bfcb8791 Mon Sep 17 00:00:00 2001 From: "Anastasiia.Birillo" Date: Fri, 21 May 2021 13:46:55 +0300 Subject: [PATCH 14/73] Fix a bug with path to the gradle project --- src/python/evaluation/qodana/dataset_marking.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/python/evaluation/qodana/dataset_marking.py b/src/python/evaluation/qodana/dataset_marking.py index 574e7d07..00f17667 100644 --- a/src/python/evaluation/qodana/dataset_marking.py +++ b/src/python/evaluation/qodana/dataset_marking.py @@ -22,7 +22,7 @@ from src.python.evaluation.qodana.util.models import QodanaColumnName, QodanaIssue, QodanaJsonField from src.python.review.application_config import LanguageVersion from src.python.review.common.file_system import ( - create_directory, get_content_from_file, get_name_from_path, get_parent_folder, remove_directory, + create_directory, get_content_from_file, get_name_from_path, get_parent_folder, remove_directory, remove_slash, ) from src.python.review.common.subprocess_runner import run_and_wait from src.python.review.run_tool import positive_int @@ -225,7 +225,8 @@ def _copy_template(project_dir: Path, language: LanguageVersion): or language == LanguageVersion.JAVA_8 or language == LanguageVersion.JAVA_7 ): - shutil.copytree(Path("./project_templates/java"), project_dir, dirs_exist_ok=True) + shutil.copytree(Path(f"{remove_slash(os.path.dirname(os.path.abspath(__file__)))}/project_templates/java"), + project_dir, dirs_exist_ok=True) else: raise NotImplementedError From 371f985f8d6315b8568f48d66727a18f6447d3f8 Mon Sep 17 00:00:00 2001 From: Ilya Vlasov Date: Sat, 22 May 2021 11:31:37 +0300 Subject: [PATCH 15/73] Fixed PR issues --- .../evaluation/qodana/dataset_marking.py | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/python/evaluation/qodana/dataset_marking.py b/src/python/evaluation/qodana/dataset_marking.py index 00f17667..40f679bd 100644 --- a/src/python/evaluation/qodana/dataset_marking.py +++ b/src/python/evaluation/qodana/dataset_marking.py @@ -157,7 +157,7 @@ def _mark_language(self, df: DataFrame, language: LanguageVersion) -> DataFrame: def _extract_fragment_id(cls, folder_name: str) -> int: numbers = re.findall(r'\d+', folder_name) if len(numbers) != 1: - raise ValueError(f'Can npt extract fragment id from {folder_name}') + raise ValueError(f'Can not extract fragment id from {folder_name}') return numbers[0] @classmethod @@ -187,11 +187,11 @@ def _to_json(cls, issues: List[QodanaIssue]) -> str: return json.dumps(issues_json) def _mark_chunk(self, chunk: DataFrame, language: LanguageVersion, chunk_id: int) -> pd.DataFrame: - tmp_file_path = self.dataset_path.parent.absolute() / f'qodana_project_{chunk_id}' - create_directory(tmp_file_path) + tmp_dir_path = self.dataset_path.parent.absolute() / f'qodana_project_{chunk_id}' + create_directory(tmp_dir_path) - project_dir = tmp_file_path / "project" - results_dir = tmp_file_path / "results" + project_dir = tmp_dir_path / "project" + results_dir = tmp_dir_path / "results" logger.info("Copying the template") self._copy_template(project_dir, language) @@ -214,7 +214,7 @@ def _mark_chunk(self, chunk: DataFrame, language: LanguageVersion, chunk_id: int chunk[QodanaColumnName.INSPECTIONS.value] = chunk.apply( lambda row: self._to_json(inspections.get(row[ColumnName.ID.value], [])), axis=1) - remove_directory(tmp_file_path) + remove_directory(tmp_dir_path) return chunk @staticmethod @@ -242,12 +242,12 @@ def _create_main_files(project_dir: Path, chunk: DataFrame, language: LanguageVe or language == LanguageVersion.JAVA_7 ): working_dir = project_dir / "src" / "main" / "java" - for index, row in chunk.iterrows(): - solution_dir = working_dir / f"solution{index}" + for _, row in chunk.iterrows(): + solution_dir = working_dir / f"solution{row[ColumnName.ID.value]}" solution_dir.mkdir(parents=True) file_path = solution_dir / "Main.java" with open(file_path, "w") as file: - file.write(f"package solution{index};\n\n") + file.write(f"package solution{row[ColumnName.ID.value]};\n\n") file.write(row[ColumnName.CODE.value]) else: raise NotImplementedError @@ -255,7 +255,7 @@ def _create_main_files(project_dir: Path, chunk: DataFrame, language: LanguageVe @staticmethod def _run_qodana(project_dir: Path, results_dir: Path): results_dir.mkdir() - command = ['docker', 'run', '--rm', '-v', f'{project_dir}/:/data/project/', '-v', + command = ['docker', 'run', '-u', str(os.getuid()), '--rm', '-v', f'{project_dir}/:/data/project/', '-v', f'{results_dir}/:/data/results/', 'jetbrains/qodana'] run_and_wait(command) From 0dab1b7ca77b9a5c47d53dc49ddebbc940655b51 Mon Sep 17 00:00:00 2001 From: Nastya Birillo Date: Sat, 22 May 2021 11:45:29 +0300 Subject: [PATCH 16/73] Fix/qodana output (#33) * Update version to 1.2.0 * parse qodana output * Change qodana scipt output * Fix a bug with qodana * Fix a bug with path to the gradle project * Fixed PR issues Co-authored-by: Ilya Vlasov --- requirements-evaluation.txt | 1 + .../evaluation/qodana/dataset_marking.py | 147 ++++++++++-------- src/python/evaluation/qodana/util/__init__.py | 0 src/python/evaluation/qodana/util/models.py | 57 +++++++ src/python/review/common/file_system.py | 10 +- src/python/review/common/subprocess_runner.py | 5 + whitelist.txt | 1 + 7 files changed, 158 insertions(+), 63 deletions(-) create mode 100644 src/python/evaluation/qodana/util/__init__.py create mode 100644 src/python/evaluation/qodana/util/models.py diff --git a/requirements-evaluation.txt b/requirements-evaluation.txt index 4fcd70e8..cff892f6 100644 --- a/requirements-evaluation.txt +++ b/requirements-evaluation.txt @@ -4,3 +4,4 @@ pandarallel numpy~=1.20.2 python_on_whales~=0.17.1 +docker diff --git a/src/python/evaluation/qodana/dataset_marking.py b/src/python/evaluation/qodana/dataset_marking.py index 9abd5e44..40f679bd 100644 --- a/src/python/evaluation/qodana/dataset_marking.py +++ b/src/python/evaluation/qodana/dataset_marking.py @@ -10,17 +10,21 @@ from dataclasses import dataclass from math import ceil from pathlib import Path -from typing import Any, Dict, Optional, Set +from typing import Any, Dict, List, Optional, Set sys.path.append("../../../..") import numpy as np import pandas as pd from pandas import DataFrame -from python_on_whales import docker +from src.python.evaluation.common.csv_util import write_dataframe_to_csv from src.python.evaluation.common.util import ColumnName +from src.python.evaluation.qodana.util.models import QodanaColumnName, QodanaIssue, QodanaJsonField from src.python.review.application_config import LanguageVersion -from src.python.review.common.file_system import new_temp_dir +from src.python.review.common.file_system import ( + create_directory, get_content_from_file, get_name_from_path, get_parent_folder, remove_directory, remove_slash, +) +from src.python.review.common.subprocess_runner import run_and_wait from src.python.review.run_tool import positive_int logger = logging.getLogger(__name__) @@ -103,10 +107,8 @@ def __init__(self, args: Namespace): self.inspections_output_path = args.inspections_output_path - self.inspection_to_id = {} - def mark(self): - df = pd.read_csv(self.dataset_path, index_col=ColumnName.ID.value, nrows=self.limit) + df = pd.read_csv(self.dataset_path, nrows=self.limit) group_by_lang = df.groupby(ColumnName.LANG.value) unique_languages = df[ColumnName.LANG.value].unique() @@ -133,13 +135,7 @@ def mark(self): df = pd.concat(groups) logger.info("Writing the dataset to a file.") - df.to_csv(self.dataset_output_path) - - id_to_inspection = {value: index for index, value in self.inspection_to_id.items()} - - id_to_inspection_df = pd.DataFrame.from_dict(id_to_inspection, orient="index", columns=["inspection"]) - id_to_inspection_df.index.name = "id" - id_to_inspection_df.to_csv(self.inspections_output_path) + write_dataframe_to_csv(self.dataset_output_path, df) def _mark_language(self, df: DataFrame, language: LanguageVersion) -> DataFrame: number_of_chunks = 1 @@ -147,45 +143,79 @@ def _mark_language(self, df: DataFrame, language: LanguageVersion) -> DataFrame: number_of_chunks = ceil(df.shape[0] / self.chunk_size) chunks = np.array_split(df, number_of_chunks) + labeled_chunks = [] + # Todo: run this in parallel for index, chunk in enumerate(chunks): logger.info(f"Processing chunk: {index + 1} / {number_of_chunks}") - self._mark_chunk(chunk, language) + labeled_chunks.append(self._mark_chunk(chunk, language, index)) logger.info(f"{language} processing finished.") - result = pd.concat(chunks) + result = pd.concat(labeled_chunks) return result - def _mark_chunk(self, chunk: DataFrame, language: LanguageVersion): - with new_temp_dir() as temp_dir: - project_dir = temp_dir / "project" - results_dir = temp_dir / "results" - - logger.info("Copying the template") - self._copy_template(project_dir, language) - - if self.config: - logger.info("Copying the config") - self._copy_config(project_dir) - - logger.info("Creating main files") - self._create_main_files(project_dir, chunk, language) - - logger.info("Running qodana") - self._run_qodana(project_dir, results_dir) - - logger.info("Getting unique inspections") - inspections = self._get_inspections(results_dir) - existing_inspections = set(self.inspection_to_id.keys()) - new_inspections = inspections.difference(existing_inspections) - - for inspection in new_inspections: - self.inspection_to_id[inspection] = len(self.inspection_to_id) - - logger.info("Parsing the output of qodana") - solution_id_to_inspection_ids = self._parse(results_dir, inspections) - chunk["inspection_ids"] = "" - for solution_id, inspection_ids in solution_id_to_inspection_ids.items(): - chunk.loc[solution_id, "inspection_ids"] = ",".join(map(str, inspection_ids)) + @classmethod + def _extract_fragment_id(cls, folder_name: str) -> int: + numbers = re.findall(r'\d+', folder_name) + if len(numbers) != 1: + raise ValueError(f'Can not extract fragment id from {folder_name}') + return numbers[0] + + @classmethod + def _get_fragment_id_from_fragment_file_path(cls, fragment_file_path: str) -> int: + folder_name = get_name_from_path(get_parent_folder(fragment_file_path), with_extension=False) + return cls._extract_fragment_id(folder_name) + + @classmethod + def _parse_inspections_files(cls, inspections_files: Set[Path]) -> Dict[int, List[QodanaIssue]]: + id_to_issues: Dict[int, List[QodanaIssue]] = defaultdict(list) + for file in inspections_files: + issues = json.loads(get_content_from_file(file))['problems'] + for issue in issues: + fragment_id = int(cls._get_fragment_id_from_fragment_file_path(issue['file'])) + qodana_issue = QodanaIssue(line=issue['line'], offset=issue['offset'], length=issue['length'], + highlighted_element=issue['highlighted_element'], + description=issue['description'], fragment_id=fragment_id, + problem_id=issue['problem_class']['id']) + id_to_issues[fragment_id].append(qodana_issue) + return id_to_issues + + @classmethod + def _to_json(cls, issues: List[QodanaIssue]) -> str: + issues_json = { + QodanaJsonField.ISSUES.value: list(map(lambda i: i.to_json(), issues)), + } + return json.dumps(issues_json) + + def _mark_chunk(self, chunk: DataFrame, language: LanguageVersion, chunk_id: int) -> pd.DataFrame: + tmp_dir_path = self.dataset_path.parent.absolute() / f'qodana_project_{chunk_id}' + create_directory(tmp_dir_path) + + project_dir = tmp_dir_path / "project" + results_dir = tmp_dir_path / "results" + + logger.info("Copying the template") + self._copy_template(project_dir, language) + + if self.config: + logger.info("Copying the config") + self._copy_config(project_dir) + + logger.info("Creating main files") + self._create_main_files(project_dir, chunk, language) + + logger.info("Running qodana") + self._run_qodana(project_dir, results_dir) + + logger.info("Getting inspections") + inspections_files = self._get_inspections_files(results_dir) + inspections = self._parse_inspections_files(inspections_files) + + logger.info("Write inspections") + chunk[QodanaColumnName.INSPECTIONS.value] = chunk.apply( + lambda row: self._to_json(inspections.get(row[ColumnName.ID.value], [])), axis=1) + + remove_directory(tmp_dir_path) + return chunk @staticmethod def _copy_template(project_dir: Path, language: LanguageVersion): @@ -195,7 +225,8 @@ def _copy_template(project_dir: Path, language: LanguageVersion): or language == LanguageVersion.JAVA_8 or language == LanguageVersion.JAVA_7 ): - shutil.copytree(Path("./project_templates/java"), project_dir, dirs_exist_ok=True) + shutil.copytree(Path(f"{remove_slash(os.path.dirname(os.path.abspath(__file__)))}/project_templates/java"), + project_dir, dirs_exist_ok=True) else: raise NotImplementedError @@ -211,12 +242,12 @@ def _create_main_files(project_dir: Path, chunk: DataFrame, language: LanguageVe or language == LanguageVersion.JAVA_7 ): working_dir = project_dir / "src" / "main" / "java" - for index, row in chunk.iterrows(): - solution_dir = working_dir / f"solution{index}" + for _, row in chunk.iterrows(): + solution_dir = working_dir / f"solution{row[ColumnName.ID.value]}" solution_dir.mkdir(parents=True) file_path = solution_dir / "Main.java" with open(file_path, "w") as file: - file.write(f"package solution{index};\n\n") + file.write(f"package solution{row[ColumnName.ID.value]};\n\n") file.write(row[ColumnName.CODE.value]) else: raise NotImplementedError @@ -224,22 +255,16 @@ def _create_main_files(project_dir: Path, chunk: DataFrame, language: LanguageVe @staticmethod def _run_qodana(project_dir: Path, results_dir: Path): results_dir.mkdir() - - docker.run( - "jetbrains/qodana", - remove=True, - volumes=[(project_dir, "/data/project/"), (results_dir, "/data/results/")], - user=os.getuid(), - ) + command = ['docker', 'run', '-u', str(os.getuid()), '--rm', '-v', f'{project_dir}/:/data/project/', '-v', + f'{results_dir}/:/data/results/', 'jetbrains/qodana'] + run_and_wait(command) @staticmethod - def _get_inspections(results_dir: Path) -> Set[str]: + def _get_inspections_files(results_dir: Path) -> Set[Path]: files = os.listdir(results_dir) file_name_regex = re.compile(r"(\w*).json") - inspection_files = filter(lambda file: file_name_regex.match(file), files) - - return {file_name_regex.match(file).group(1) for file in inspection_files} + return set(map(lambda f: results_dir / f, filter(lambda file: file_name_regex.match(file), files))) def _parse(self, results_dir: Path, inspections: Set[str]): package_regex = re.compile(r"solution(\d*)") diff --git a/src/python/evaluation/qodana/util/__init__.py b/src/python/evaluation/qodana/util/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/python/evaluation/qodana/util/models.py b/src/python/evaluation/qodana/util/models.py new file mode 100644 index 00000000..f5b3a589 --- /dev/null +++ b/src/python/evaluation/qodana/util/models.py @@ -0,0 +1,57 @@ +import json +from dataclasses import dataclass +from enum import Enum, unique + + +@dataclass(frozen=True) +class QodanaIssue: + fragment_id: int + line: int + offset: int + length: int + highlighted_element: str + description: str + problem_id: str + + def to_json(self) -> str: + issue = { + QodanaJsonField.FRAGMENT_ID.value: self.fragment_id, + QodanaJsonField.LINE.value: self.line, + QodanaJsonField.OFFSET.value: self.offset, + QodanaJsonField.LENGTH.value: self.length, + QodanaJsonField.HIGHLIGHTED_ELEMENT.value: self.highlighted_element, + QodanaJsonField.DESCRIPTION.value: self.description, + QodanaJsonField.PROBLEM_ID.value: self.problem_id, + } + return json.dumps(issue) + + @classmethod + def from_json(cls, str_json: str) -> 'QodanaIssue': + issue = json.loads(str_json) + return QodanaIssue( + fragment_id=issue[QodanaJsonField.FRAGMENT_ID.value], + line=issue[QodanaJsonField.LINE.value], + offset=issue[QodanaJsonField.OFFSET.value], + length=issue[QodanaJsonField.LENGTH.value], + highlighted_element=issue[QodanaJsonField.HIGHLIGHTED_ELEMENT.value], + description=issue[QodanaJsonField.DESCRIPTION.value], + problem_id=issue[QodanaJsonField.PROBLEM_ID.value], + ) + + +@unique +class QodanaColumnName(Enum): + INSPECTIONS = 'inspections' + + +@unique +class QodanaJsonField(Enum): + FRAGMENT_ID = 'fragment_id' + LINE = 'line' + OFFSET = 'offset' + LENGTH = 'length' + HIGHLIGHTED_ELEMENT = 'highlighted_element' + DESCRIPTION = 'description' + PROBLEM_ID = 'problem_id' + + ISSUES = 'issues' diff --git a/src/python/review/common/file_system.py b/src/python/review/common/file_system.py index eb5bc768..3e2e8bce 100644 --- a/src/python/review/common/file_system.py +++ b/src/python/review/common/file_system.py @@ -2,6 +2,7 @@ import os import pickle import re +import shutil import tempfile from contextlib import contextmanager from enum import Enum, unique @@ -85,7 +86,7 @@ def deserialize_data_from_file(path: Path) -> Any: # 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: +def get_name_from_path(path: Union[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) @@ -167,13 +168,18 @@ def remove_slash(path: str) -> str: return path.rstrip('/') +def remove_directory(directory: Union[str, Path]) -> None: + if os.path.isdir(directory): + shutil.rmtree(directory, ignore_errors=True) + + 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: +def get_parent_folder(path: Union[Path, str], to_add_slash: bool = False) -> Path: path = remove_slash(str(path)) parent_folder = '/'.join(path.split('/')[:-1]) if to_add_slash: diff --git a/src/python/review/common/subprocess_runner.py b/src/python/review/common/subprocess_runner.py index a25cbdcd..2a89ad42 100644 --- a/src/python/review/common/subprocess_runner.py +++ b/src/python/review/common/subprocess_runner.py @@ -21,3 +21,8 @@ def run_in_subprocess(command: List[str]) -> str: logger.debug('%s\'s stderr:\n%s' % (command[0], stderr)) return stdout + + +def run_and_wait(command: List[str]) -> None: + process = subprocess.Popen(command) + process.wait() diff --git a/whitelist.txt b/whitelist.txt index bbf66332..3e331750 100644 --- a/whitelist.txt +++ b/whitelist.txt @@ -113,3 +113,4 @@ iterrows nrows groupby getuid +Popen From f9b418deb6541de63b48d28d19450c657c129c87 Mon Sep 17 00:00:00 2001 From: Ilya Vlasov Date: Sun, 23 May 2021 10:22:32 +0300 Subject: [PATCH 17/73] Added is_java function --- src/python/review/application_config.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/python/review/application_config.py b/src/python/review/application_config.py index f41d439d..995b0bc0 100644 --- a/src/python/review/application_config.py +++ b/src/python/review/application_config.py @@ -42,3 +42,11 @@ def language_to_extension_dict(cls) -> Dict['LanguageVersion', Extension]: def extension_by_language(self) -> Extension: return self.language_to_extension_dict()[self] + + def is_java(self) -> bool: + return ( + self == LanguageVersion.JAVA_7 + or self == LanguageVersion.JAVA_8 + or self == LanguageVersion.JAVA_9 + or self == LanguageVersion.JAVA_11 + ) From 96c05188f5b00ed7de2d1eb09b1418d4c24d494b Mon Sep 17 00:00:00 2001 From: Ilya Vlasov Date: Sun, 23 May 2021 10:24:08 +0300 Subject: [PATCH 18/73] 1) Added copy_directory and copy_file functions; 2) Added new extension: .json --- src/python/review/common/file_system.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/python/review/common/file_system.py b/src/python/review/common/file_system.py index 3e2e8bce..00ff2a41 100644 --- a/src/python/review/common/file_system.py +++ b/src/python/review/common/file_system.py @@ -35,6 +35,7 @@ class Extension(Enum): XLSX = '.xlsx' CSV = '.csv' PICKLE = '.pickle' + JSON = '.json' # Not empty extensions are returned with a dot, for example, '.txt' # If file has no extensions, an empty one ('') is returned @@ -185,3 +186,11 @@ def get_parent_folder(path: Union[Path, str], to_add_slash: bool = False) -> Pat if to_add_slash: parent_folder = add_slash(parent_folder) return Path(parent_folder) + + +def copy_directory(source: Union[str, Path], destination: Union[str, Path], dirs_exist_ok: bool = True): + shutil.copytree(source, destination, dirs_exist_ok=dirs_exist_ok) + + +def copy_file(source: Union[str, Path], destination: Union[str, Path]): + shutil.copy(source, destination) From 38a936a20b2ab66268b04c394bfd065ee43ffe08 Mon Sep 17 00:00:00 2001 From: Ilya Vlasov Date: Sun, 23 May 2021 10:24:30 +0300 Subject: [PATCH 19/73] Removed python_on_whales dependency --- requirements-evaluation.txt | 2 -- 1 file changed, 2 deletions(-) diff --git a/requirements-evaluation.txt b/requirements-evaluation.txt index cff892f6..5fe5b7eb 100644 --- a/requirements-evaluation.txt +++ b/requirements-evaluation.txt @@ -1,7 +1,5 @@ openpyxl==3.0.7 pandas==1.2.3 pandarallel - numpy~=1.20.2 -python_on_whales~=0.17.1 docker From 235e60fec7bab1b6eff3cce432cd4bc457bdff39 Mon Sep 17 00:00:00 2001 From: Ilya Vlasov Date: Sun, 23 May 2021 10:36:01 +0300 Subject: [PATCH 20/73] Fixed some PR issues --- .../evaluation/qodana/dataset_marking.py | 213 ++++++++---------- 1 file changed, 95 insertions(+), 118 deletions(-) diff --git a/src/python/evaluation/qodana/dataset_marking.py b/src/python/evaluation/qodana/dataset_marking.py index 40f679bd..b99d26b4 100644 --- a/src/python/evaluation/qodana/dataset_marking.py +++ b/src/python/evaluation/qodana/dataset_marking.py @@ -2,27 +2,32 @@ import logging import os import re -import shutil import sys import traceback from argparse import ArgumentParser, Namespace from collections import defaultdict -from dataclasses import dataclass from math import ceil from pathlib import Path -from typing import Any, Dict, List, Optional, Set +from typing import Dict, List, Optional, Set -sys.path.append("../../../..") +sys.path.append('../../../..') import numpy as np import pandas as pd -from pandas import DataFrame from src.python.evaluation.common.csv_util import write_dataframe_to_csv from src.python.evaluation.common.util import ColumnName from src.python.evaluation.qodana.util.models import QodanaColumnName, QodanaIssue, QodanaJsonField from src.python.review.application_config import LanguageVersion from src.python.review.common.file_system import ( - create_directory, get_content_from_file, get_name_from_path, get_parent_folder, remove_directory, remove_slash, + copy_directory, + copy_file, + create_directory, + Extension, + get_content_from_file, + get_name_from_path, + get_parent_folder, + remove_directory, + remove_slash, ) from src.python.review.common.subprocess_runner import run_and_wait from src.python.review.run_tool import positive_int @@ -33,7 +38,7 @@ def configure_arguments(parser: ArgumentParser) -> None: parser.add_argument( - "dataset_path", + 'dataset_path', type=lambda value: Path(value).absolute(), help=f"Dataset path. The dataset must contain at least three columns: '{ColumnName.ID.value}', " f"'{ColumnName.CODE.value}' and '{ColumnName.LANG.value}', where '{ColumnName.ID.value}' is a unique " @@ -44,49 +49,38 @@ def configure_arguments(parser: ArgumentParser) -> None: ) parser.add_argument( - "inspections_output_path", + 'inspections_output_path', type=lambda value: Path(value).absolute(), - help="Path where id of all found inspections will be saved.", + help='Path where id of all found inspections will be saved.', ) - parser.add_argument("-c", "--config", type=lambda value: Path(value).absolute(), help="Path to qodana.yaml") + parser.add_argument('-c', '--config', type=lambda value: Path(value).absolute(), help='Path to qodana.yaml') parser.add_argument( - "-l", - "--limit", + '-l', + '--limit', type=positive_int, - help="Allows you to read only the specified number of first rows from the dataset.", + help='Allows you to read only the specified number of first rows from the dataset.', ) parser.add_argument( - "-s", - "--chunk-size", + '-s', + '--chunk-size', type=positive_int, - help="The number of files that qodana will process at a time.", + help='The number of files that qodana will process at a time.', default=5000, ) parser.add_argument( - "-o", - "--dataset-output-path", + '-o', + '--output-path', type=lambda value: Path(value).absolute(), - help="The path where the marked dataset will be saved. " - "If not specified, the original dataset will be overwritten.", + help='The path where the labeled dataset will be saved. ' + 'If not specified, the labeled dataset will be saved next to the original one.', ) -@dataclass(init=False) -class InspectionData: - package: str - - def __init__(self, package: str, **kwargs: Any): - self.package = package - - def __str__(self): - return self.package - - -class DatasetMarker: +class DatasetLabel: dataset_path: Path config: Optional[Path] limit: Optional[int] @@ -101,19 +95,21 @@ def __init__(self, args: Namespace): self.limit = args.limit self.chunk_size = args.chunk_size - self.dataset_output_path = self.dataset_path - if args.dataset_output_path is not None: - self.dataset_output_path = args.dataset_output_path + self.output_path = args.output_path + if self.output_path is None: + output_dir = get_parent_folder(self.dataset_path) + dataset_name = get_name_from_path(self.dataset_path) + self.output_path = output_dir / f'labeled_{dataset_name}' self.inspections_output_path = args.inspections_output_path - def mark(self): - df = pd.read_csv(self.dataset_path, nrows=self.limit) + def label(self) -> None: + dataset = pd.read_csv(self.dataset_path, nrows=self.limit) - group_by_lang = df.groupby(ColumnName.LANG.value) - unique_languages = df[ColumnName.LANG.value].unique() + group_by_lang = dataset.groupby(ColumnName.LANG.value) + unique_languages = dataset[ColumnName.LANG.value].unique() - logger.info(f"Unique languages: {unique_languages}") + logger.info(f'Unique languages: {unique_languages}') groups = [] for language in unique_languages: @@ -121,23 +117,23 @@ def mark(self): if language in LanguageVersion.values(): try: - logger.info(f"Processing the language: {language}") - groups.append(self._mark_language(lang_group, LanguageVersion(language))) + logger.info(f'Processing the language: {language}') + groups.append(self._label_language(lang_group, LanguageVersion(language))) except NotImplementedError: - logger.warning(f"{language} needs implementation") + logger.warning(f'{language} needs implementation') groups.append(lang_group) else: - logger.warning(f"Unknown language: {language}") + logger.warning(f'Unknown language: {language}') groups.append(lang_group) - logger.info("Dataset processing finished") + logger.info('Dataset processing finished') - df = pd.concat(groups) + dataset = pd.concat(groups) - logger.info("Writing the dataset to a file.") - write_dataframe_to_csv(self.dataset_output_path, df) + logger.info('Writing the dataset to a file.') + write_dataframe_to_csv(self.dataset_output_path, dataset) - def _mark_language(self, df: DataFrame, language: LanguageVersion) -> DataFrame: + def _label_language(self, df: pd.DataFrame, language: LanguageVersion) -> pd.DataFrame: number_of_chunks = 1 if self.chunk_size is not None: number_of_chunks = ceil(df.shape[0] / self.chunk_size) @@ -146,10 +142,10 @@ def _mark_language(self, df: DataFrame, language: LanguageVersion) -> DataFrame: labeled_chunks = [] # Todo: run this in parallel for index, chunk in enumerate(chunks): - logger.info(f"Processing chunk: {index + 1} / {number_of_chunks}") - labeled_chunks.append(self._mark_chunk(chunk, language, index)) + logger.info(f'Processing chunk: {index + 1} / {number_of_chunks}') + labeled_chunks.append(self._label_chunk(chunk, language, index)) - logger.info(f"{language} processing finished.") + logger.info(f'{language} processing finished.') result = pd.concat(labeled_chunks) return result @@ -172,10 +168,15 @@ def _parse_inspections_files(cls, inspections_files: Set[Path]) -> Dict[int, Lis issues = json.loads(get_content_from_file(file))['problems'] for issue in issues: fragment_id = int(cls._get_fragment_id_from_fragment_file_path(issue['file'])) - qodana_issue = QodanaIssue(line=issue['line'], offset=issue['offset'], length=issue['length'], - highlighted_element=issue['highlighted_element'], - description=issue['description'], fragment_id=fragment_id, - problem_id=issue['problem_class']['id']) + qodana_issue = QodanaIssue( + line=issue['line'], + offset=issue['offset'], + length=issue['length'], + highlighted_element=issue['highlighted_element'], + description=issue['description'], + fragment_id=fragment_id, + problem_id=issue['problem_class']['id'], + ) id_to_issues[fragment_id].append(qodana_issue) return id_to_issues @@ -186,77 +187,74 @@ def _to_json(cls, issues: List[QodanaIssue]) -> str: } return json.dumps(issues_json) - def _mark_chunk(self, chunk: DataFrame, language: LanguageVersion, chunk_id: int) -> pd.DataFrame: + def _label_chunk(self, chunk: pd.DataFrame, language: LanguageVersion, chunk_id: int) -> pd.DataFrame: tmp_dir_path = self.dataset_path.parent.absolute() / f'qodana_project_{chunk_id}' create_directory(tmp_dir_path) - project_dir = tmp_dir_path / "project" - results_dir = tmp_dir_path / "results" + project_dir = tmp_dir_path / 'project' + results_dir = tmp_dir_path / 'results' - logger.info("Copying the template") + logger.info('Copying the template') self._copy_template(project_dir, language) if self.config: - logger.info("Copying the config") + logger.info('Copying the config') self._copy_config(project_dir) - logger.info("Creating main files") + logger.info('Creating main files') self._create_main_files(project_dir, chunk, language) - logger.info("Running qodana") + logger.info('Running qodana') self._run_qodana(project_dir, results_dir) - logger.info("Getting inspections") + logger.info('Getting inspections') inspections_files = self._get_inspections_files(results_dir) inspections = self._parse_inspections_files(inspections_files) - logger.info("Write inspections") + logger.info('Write inspections') chunk[QodanaColumnName.INSPECTIONS.value] = chunk.apply( - lambda row: self._to_json(inspections.get(row[ColumnName.ID.value], [])), axis=1) + lambda row: self._to_json(inspections.get(row[ColumnName.ID.value], [])), axis=1, + ) remove_directory(tmp_dir_path) return chunk @staticmethod - def _copy_template(project_dir: Path, language: LanguageVersion): - if ( - language == LanguageVersion.JAVA_11 - or language == LanguageVersion.JAVA_9 - or language == LanguageVersion.JAVA_8 - or language == LanguageVersion.JAVA_7 - ): - shutil.copytree(Path(f"{remove_slash(os.path.dirname(os.path.abspath(__file__)))}/project_templates/java"), - project_dir, dirs_exist_ok=True) + def _copy_template(project_dir: Path, language: LanguageVersion) -> None: + if language.is_java(): + source = f'{remove_slash(os.path.dirname(os.path.abspath(__file__)))}/project_templates/java' + copy_directory(source, project_dir) else: raise NotImplementedError - def _copy_config(self, project_dir: Path): - shutil.copy(self.config, project_dir) + def _copy_config(self, project_dir: Path) -> None: + copy_file(self.config, project_dir) @staticmethod - def _create_main_files(project_dir: Path, chunk: DataFrame, language: LanguageVersion): - if ( - language == LanguageVersion.JAVA_11 - or language == LanguageVersion.JAVA_9 - or language == LanguageVersion.JAVA_8 - or language == LanguageVersion.JAVA_7 - ): - working_dir = project_dir / "src" / "main" / "java" + def _create_main_files(project_dir: Path, chunk: pd.DataFrame, language: LanguageVersion) -> None: + if language.is_java(): + working_dir = project_dir / 'src' / 'main' / 'java' for _, row in chunk.iterrows(): - solution_dir = working_dir / f"solution{row[ColumnName.ID.value]}" - solution_dir.mkdir(parents=True) - file_path = solution_dir / "Main.java" - with open(file_path, "w") as file: - file.write(f"package solution{row[ColumnName.ID.value]};\n\n") + solution_dir = working_dir / f'solution{row[ColumnName.ID.value]}' + create_directory(solution_dir) + file_path = solution_dir / f'Main{Extension.JAVA.value}' + with open(file_path, 'w') as file: + file.write(f'package solution{row[ColumnName.ID.value]};\n\n') file.write(row[ColumnName.CODE.value]) else: raise NotImplementedError @staticmethod - def _run_qodana(project_dir: Path, results_dir: Path): + def _run_qodana(project_dir: Path, results_dir: Path) -> None: results_dir.mkdir() - command = ['docker', 'run', '-u', str(os.getuid()), '--rm', '-v', f'{project_dir}/:/data/project/', '-v', - f'{results_dir}/:/data/results/', 'jetbrains/qodana'] + command = [ + 'docker', 'run', + '-u', str(os.getuid()), + '--rm', + '-v', f'{project_dir}/:/data/project/', + '-v', f'{results_dir}/:/data/results/', + 'jetbrains/qodana', + ] run_and_wait(command) @staticmethod @@ -266,27 +264,6 @@ def _get_inspections_files(results_dir: Path) -> Set[Path]: file_name_regex = re.compile(r"(\w*).json") return set(map(lambda f: results_dir / f, filter(lambda file: file_name_regex.match(file), files))) - def _parse(self, results_dir: Path, inspections: Set[str]): - package_regex = re.compile(r"solution(\d*)") - - solution_id_to_inspections_ids = defaultdict(list) - for inspection in inspections: - inspection_id = self.inspection_to_id[inspection] - inspection_file_path = results_dir / f"{inspection}.json" - - with open(inspection_file_path) as file: - inspection_json = json.load(file) - - problems = inspection_json["problems"] - for problem in problems: - data = InspectionData(**problem) - package_match = package_regex.match(data.package) - if package_match: - solution_id = int(package_match.group(1)) - solution_id_to_inspections_ids[solution_id].append(inspection_id) - - return solution_id_to_inspections_ids - def main(): parser = ArgumentParser() @@ -294,14 +271,14 @@ def main(): try: args = parser.parse_args() - marker = DatasetMarker(args) - marker.mark() + dataset_label = DatasetLabel(args) + dataset_label.label() except Exception: traceback.print_exc() - logger.exception("An unexpected error") + logger.exception('An unexpected error') return 2 -if __name__ == "__main__": +if __name__ == '__main__': sys.exit(main()) From 32a8010a8f70a53bdbc13f0479635d9de0475f98 Mon Sep 17 00:00:00 2001 From: Ilya Vlasov Date: Mon, 24 May 2021 22:19:01 +0300 Subject: [PATCH 21/73] Moved project_templates to resource folders --- .../project_templates/java/build.gradle | 0 .../java/gradle/wrapper/gradle-wrapper.jar | Bin .../java/gradle/wrapper/gradle-wrapper.properties | 0 .../{ => resources}/project_templates/java/gradlew | 0 .../project_templates/java/gradlew.bat | 0 .../project_templates/java/settings.gradle | 0 6 files changed, 0 insertions(+), 0 deletions(-) rename src/python/evaluation/qodana/{ => resources}/project_templates/java/build.gradle (100%) rename src/python/evaluation/qodana/{ => resources}/project_templates/java/gradle/wrapper/gradle-wrapper.jar (100%) rename src/python/evaluation/qodana/{ => resources}/project_templates/java/gradle/wrapper/gradle-wrapper.properties (100%) rename src/python/evaluation/qodana/{ => resources}/project_templates/java/gradlew (100%) rename src/python/evaluation/qodana/{ => resources}/project_templates/java/gradlew.bat (100%) rename src/python/evaluation/qodana/{ => resources}/project_templates/java/settings.gradle (100%) diff --git a/src/python/evaluation/qodana/project_templates/java/build.gradle b/src/python/evaluation/qodana/resources/project_templates/java/build.gradle similarity index 100% rename from src/python/evaluation/qodana/project_templates/java/build.gradle rename to src/python/evaluation/qodana/resources/project_templates/java/build.gradle diff --git a/src/python/evaluation/qodana/project_templates/java/gradle/wrapper/gradle-wrapper.jar b/src/python/evaluation/qodana/resources/project_templates/java/gradle/wrapper/gradle-wrapper.jar similarity index 100% rename from src/python/evaluation/qodana/project_templates/java/gradle/wrapper/gradle-wrapper.jar rename to src/python/evaluation/qodana/resources/project_templates/java/gradle/wrapper/gradle-wrapper.jar diff --git a/src/python/evaluation/qodana/project_templates/java/gradle/wrapper/gradle-wrapper.properties b/src/python/evaluation/qodana/resources/project_templates/java/gradle/wrapper/gradle-wrapper.properties similarity index 100% rename from src/python/evaluation/qodana/project_templates/java/gradle/wrapper/gradle-wrapper.properties rename to src/python/evaluation/qodana/resources/project_templates/java/gradle/wrapper/gradle-wrapper.properties diff --git a/src/python/evaluation/qodana/project_templates/java/gradlew b/src/python/evaluation/qodana/resources/project_templates/java/gradlew similarity index 100% rename from src/python/evaluation/qodana/project_templates/java/gradlew rename to src/python/evaluation/qodana/resources/project_templates/java/gradlew diff --git a/src/python/evaluation/qodana/project_templates/java/gradlew.bat b/src/python/evaluation/qodana/resources/project_templates/java/gradlew.bat similarity index 100% rename from src/python/evaluation/qodana/project_templates/java/gradlew.bat rename to src/python/evaluation/qodana/resources/project_templates/java/gradlew.bat diff --git a/src/python/evaluation/qodana/project_templates/java/settings.gradle b/src/python/evaluation/qodana/resources/project_templates/java/settings.gradle similarity index 100% rename from src/python/evaluation/qodana/project_templates/java/settings.gradle rename to src/python/evaluation/qodana/resources/project_templates/java/settings.gradle From eced0209351f1daea67d91298165b35d8b517d76 Mon Sep 17 00:00:00 2001 From: Ilya Vlasov Date: Mon, 24 May 2021 22:45:16 +0300 Subject: [PATCH 22/73] Fixed some PR issues --- ...dataset_marking.py => dataset_labeling.py} | 64 ++++++++++--------- 1 file changed, 33 insertions(+), 31 deletions(-) rename src/python/evaluation/qodana/{dataset_marking.py => dataset_labeling.py} (85%) diff --git a/src/python/evaluation/qodana/dataset_marking.py b/src/python/evaluation/qodana/dataset_labeling.py similarity index 85% rename from src/python/evaluation/qodana/dataset_marking.py rename to src/python/evaluation/qodana/dataset_labeling.py index b99d26b4..318160a0 100644 --- a/src/python/evaluation/qodana/dataset_marking.py +++ b/src/python/evaluation/qodana/dataset_labeling.py @@ -27,7 +27,7 @@ get_name_from_path, get_parent_folder, remove_directory, - remove_slash, + create_file, ) from src.python.review.common.subprocess_runner import run_and_wait from src.python.review.run_tool import positive_int @@ -35,6 +35,8 @@ logger = logging.getLogger(__name__) logging.basicConfig(level=logging.INFO) +TEMPLATE_FOLDER = Path(os.path.dirname(os.path.abspath(__file__))) / 'resources' / 'project_templates' + def configure_arguments(parser: ArgumentParser) -> None: parser.add_argument( @@ -48,12 +50,6 @@ def configure_arguments(parser: ArgumentParser) -> None: f"If '{ColumnName.LANG.value}' is not equal to any of the values, the row will be skipped.", ) - parser.add_argument( - 'inspections_output_path', - type=lambda value: Path(value).absolute(), - help='Path where id of all found inspections will be saved.', - ) - parser.add_argument('-c', '--config', type=lambda value: Path(value).absolute(), help='Path to qodana.yaml') parser.add_argument( @@ -81,13 +77,17 @@ def configure_arguments(parser: ArgumentParser) -> None: class DatasetLabel: + """ + DatasetLabel allows you to label a dataset using the found Qodana inspections. + Accepts dataset_path, config, limit, chunk_size and output_path. + """ + dataset_path: Path config: Optional[Path] limit: Optional[int] chunk_size: Optional[int] inspection_to_id: Dict[str, int] - dataset_output_path: Path - inspections_output_path: Path + output_path: Path def __init__(self, args: Namespace): self.dataset_path = args.dataset_path @@ -101,9 +101,10 @@ def __init__(self, args: Namespace): dataset_name = get_name_from_path(self.dataset_path) self.output_path = output_dir / f'labeled_{dataset_name}' - self.inspections_output_path = args.inspections_output_path - def label(self) -> None: + """ + Runs Qodana on each row of the dataset and writes the found inspections in the 'inspections' column. + """ dataset = pd.read_csv(self.dataset_path, nrows=self.limit) group_by_lang = dataset.groupby(ColumnName.LANG.value) @@ -116,10 +117,13 @@ def label(self) -> None: lang_group = group_by_lang.get_group(language) if language in LanguageVersion.values(): + # TODO: languages need implementation try: logger.info(f'Processing the language: {language}') groups.append(self._label_language(lang_group, LanguageVersion(language))) except NotImplementedError: + # If we find a language that is in the LanguageVersion, + # but is not supported in this script, we should skip this fragment. logger.warning(f'{language} needs implementation') groups.append(lang_group) else: @@ -131,7 +135,7 @@ def label(self) -> None: dataset = pd.concat(groups) logger.info('Writing the dataset to a file.') - write_dataframe_to_csv(self.dataset_output_path, dataset) + write_dataframe_to_csv(self.output_path, dataset) def _label_language(self, df: pd.DataFrame, language: LanguageVersion) -> pd.DataFrame: number_of_chunks = 1 @@ -199,7 +203,7 @@ def _label_chunk(self, chunk: pd.DataFrame, language: LanguageVersion, chunk_id: if self.config: logger.info('Copying the config') - self._copy_config(project_dir) + copy_file(self.config, project_dir) logger.info('Creating main files') self._create_main_files(project_dir, chunk, language) @@ -222,27 +226,26 @@ def _label_chunk(self, chunk: pd.DataFrame, language: LanguageVersion, chunk_id: @staticmethod def _copy_template(project_dir: Path, language: LanguageVersion) -> None: if language.is_java(): - source = f'{remove_slash(os.path.dirname(os.path.abspath(__file__)))}/project_templates/java' - copy_directory(source, project_dir) + java_template = TEMPLATE_FOLDER / "java" + copy_directory(java_template, project_dir) else: - raise NotImplementedError - - def _copy_config(self, project_dir: Path) -> None: - copy_file(self.config, project_dir) + raise NotImplementedError(f'{language} needs implementation.') - @staticmethod - def _create_main_files(project_dir: Path, chunk: pd.DataFrame, language: LanguageVersion) -> None: + def _create_main_files(self, project_dir: Path, chunk: pd.DataFrame, language: LanguageVersion) -> None: if language.is_java(): working_dir = project_dir / 'src' / 'main' / 'java' - for _, row in chunk.iterrows(): - solution_dir = working_dir / f'solution{row[ColumnName.ID.value]}' - create_directory(solution_dir) - file_path = solution_dir / f'Main{Extension.JAVA.value}' - with open(file_path, 'w') as file: - file.write(f'package solution{row[ColumnName.ID.value]};\n\n') - file.write(row[ColumnName.CODE.value]) + + chunk.apply( + lambda row: next( + create_file( + file_path=(working_dir / f'solution{row[ColumnName.ID.value]}' / f'Main{Extension.JAVA.value}'), + content=row[ColumnName.CODE.value], + ) + ), + axis=1, + ) else: - raise NotImplementedError + raise NotImplementedError(f'{language} needs implementation.') @staticmethod def _run_qodana(project_dir: Path, results_dir: Path) -> None: @@ -260,8 +263,7 @@ def _run_qodana(project_dir: Path, results_dir: Path) -> None: @staticmethod def _get_inspections_files(results_dir: Path) -> Set[Path]: files = os.listdir(results_dir) - - file_name_regex = re.compile(r"(\w*).json") + file_name_regex = re.compile(r'(\w*).json') return set(map(lambda f: results_dir / f, filter(lambda file: file_name_regex.match(file), files))) From 4eb0c3272fdc1b656670fa7ee5211ecf2cc532d8 Mon Sep 17 00:00:00 2001 From: Ilya Vlasov Date: Mon, 24 May 2021 22:45:59 +0300 Subject: [PATCH 23/73] typo fix --- src/python/evaluation/qodana/README.md | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/python/evaluation/qodana/README.md b/src/python/evaluation/qodana/README.md index 35ccb782..f0097ca5 100644 --- a/src/python/evaluation/qodana/README.md +++ b/src/python/evaluation/qodana/README.md @@ -1,23 +1,21 @@ # Dataset label -This script allows you to mark up a dataset using the found [Qodana](https://github.com/JetBrains/Qodana) inspections. +This script allows you to label a dataset using the found [Qodana](https://github.com/JetBrains/Qodana) inspections. The dataset must contain at least three columns: `id`, `code` and `lang`, where `id` is a unique solution number, `lang` is the language in which the code is written in the `code` column. The `lang` must belong to one of the following values: `java7`, `java8`, `java9`, `java11`, `python3`, `kotlin`. If `lang` is not equal to any of the values, the row will be skipped. -The dataset must have the format `csv`. The marked dataset is also in `csv` format, with a new column `inpection_ids` added, which contains a list of id's of all found inspections. The table with found inspections, consists of two columns: `id` and `inspection`, and is also in `csv` format. +The dataset must have the format `csv`. The labeled dataset is also in `csv` format, with a new column `inspections` added, which contains a list of all found inspections. # Usage -Run the [dataset_marking.py](dataset_marking.py) with the arguments from command line. +Run the [dataset_labeling.py](dataset_labeling.py) with the arguments from command line. ### Required arguments `dataset_path` — path to dataset. -`inspections_output_path` — path where id of all found inspections will be saved. - ### Optional arguments | Argument | Description | |-|-| -| **‑c**, **‑‑config** | Path to qodana.yaml. If the path is not specified, Qodana will start without a configuration file| +| **‑c**, **‑‑config** | Path to qodana.yaml. If the path is not specified, Qodana will start without a configuration file. | | **‑l**, **‑‑limit** | Allows you to read only the specified number of first rows from the dataset. If no limit is specified, the whole dataset will be processed. | | **‑s**, **‑‑chunk‑size** | The number of files that Qodana will process at a time. Default is `5000`. | -| **‑o**, **‑‑dataset‑output‑path** | The path where the marked dataset will be saved. If not specified, the original dataset will be overwritten. | +| **‑o**, **‑‑output‑path** | The path where the labeled dataset will be saved. If not specified, the original dataset will be overwritten. | From fc114162c38482cc6e211a280d422c9666a9c868 Mon Sep 17 00:00:00 2001 From: Ilya Vlasov Date: Tue, 25 May 2021 11:46:19 +0300 Subject: [PATCH 24/73] Fixed flake8 issues --- src/python/evaluation/qodana/dataset_labeling.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/python/evaluation/qodana/dataset_labeling.py b/src/python/evaluation/qodana/dataset_labeling.py index 318160a0..b63686e2 100644 --- a/src/python/evaluation/qodana/dataset_labeling.py +++ b/src/python/evaluation/qodana/dataset_labeling.py @@ -22,12 +22,12 @@ copy_directory, copy_file, create_directory, + create_file, Extension, get_content_from_file, get_name_from_path, get_parent_folder, remove_directory, - create_file, ) from src.python.review.common.subprocess_runner import run_and_wait from src.python.review.run_tool import positive_int @@ -240,7 +240,7 @@ def _create_main_files(self, project_dir: Path, chunk: pd.DataFrame, language: L create_file( file_path=(working_dir / f'solution{row[ColumnName.ID.value]}' / f'Main{Extension.JAVA.value}'), content=row[ColumnName.CODE.value], - ) + ), ), axis=1, ) From 132b2691fe43cbfd1ef699a38f67638a4f5ce507 Mon Sep 17 00:00:00 2001 From: Ilya Vlasov Date: Sat, 29 May 2021 19:10:43 +0300 Subject: [PATCH 25/73] Fixed _get_inspections_files function --- src/python/evaluation/qodana/dataset_labeling.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/python/evaluation/qodana/dataset_labeling.py b/src/python/evaluation/qodana/dataset_labeling.py index b63686e2..b3df20a7 100644 --- a/src/python/evaluation/qodana/dataset_labeling.py +++ b/src/python/evaluation/qodana/dataset_labeling.py @@ -8,7 +8,7 @@ from collections import defaultdict from math import ceil from pathlib import Path -from typing import Dict, List, Optional, Set +from typing import Dict, List, Optional sys.path.append('../../../..') @@ -24,9 +24,11 @@ create_directory, create_file, Extension, + get_all_file_system_items, get_content_from_file, get_name_from_path, get_parent_folder, + match_condition, remove_directory, ) from src.python.review.common.subprocess_runner import run_and_wait @@ -166,7 +168,7 @@ def _get_fragment_id_from_fragment_file_path(cls, fragment_file_path: str) -> in return cls._extract_fragment_id(folder_name) @classmethod - def _parse_inspections_files(cls, inspections_files: Set[Path]) -> Dict[int, List[QodanaIssue]]: + def _parse_inspections_files(cls, inspections_files: List[Path]) -> Dict[int, List[QodanaIssue]]: id_to_issues: Dict[int, List[QodanaIssue]] = defaultdict(list) for file in inspections_files: issues = json.loads(get_content_from_file(file))['problems'] @@ -261,10 +263,9 @@ def _run_qodana(project_dir: Path, results_dir: Path) -> None: run_and_wait(command) @staticmethod - def _get_inspections_files(results_dir: Path) -> Set[Path]: - files = os.listdir(results_dir) - file_name_regex = re.compile(r'(\w*).json') - return set(map(lambda f: results_dir / f, filter(lambda file: file_name_regex.match(file), files))) + def _get_inspections_files(results_dir: Path) -> List[Path]: + condition = match_condition(r'\w*.json') + return get_all_file_system_items(results_dir, condition, without_subdirs=True) def main(): From e26161ef509c198d540ad828b339a480f1607047 Mon Sep 17 00:00:00 2001 From: Ilya Vlasov Date: Sat, 29 May 2021 19:12:19 +0300 Subject: [PATCH 26/73] Added option not to process subfolders in get_all_file_system_items function --- src/python/review/common/file_system.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/python/review/common/file_system.py b/src/python/review/common/file_system.py index 00ff2a41..42c152a8 100644 --- a/src/python/review/common/file_system.py +++ b/src/python/review/common/file_system.py @@ -53,8 +53,12 @@ def all_items_condition(name: str) -> bool: # To get all files or subdirs (depends on the last parameter) from root that match item_condition # Note that all subdirs or files already contain the full path for them -def get_all_file_system_items(root: Path, item_condition: ItemCondition = all_items_condition, - item_type: FileSystemItem = FileSystemItem.FILE) -> List[Path]: +def get_all_file_system_items( + root: Path, + item_condition: ItemCondition = all_items_condition, + item_type: FileSystemItem = FileSystemItem.FILE, + without_subdirs: bool = False, +) -> List[Path]: if not root.is_dir(): raise ValueError(f'The {root} is not a directory') @@ -63,6 +67,10 @@ def get_all_file_system_items(root: Path, item_condition: ItemCondition = all_it for item in fs_tuple[item_type.value]: if item_condition(item): items.append(Path(os.path.join(fs_tuple[FileSystemItem.PATH.value], item))) + + if without_subdirs: + break + return items From 074b5fc083eb9d277a9ddfe442a1914e4a03810f Mon Sep 17 00:00:00 2001 From: Nastya Birillo Date: Mon, 31 May 2021 17:25:42 +0300 Subject: [PATCH 27/73] Add penalty statistics (#37) * Gather and print penalty statistics --- src/python/evaluation/common/pandas_util.py | 6 +- src/python/evaluation/common/util.py | 4 ++ src/python/evaluation/inspectors/README.md | 61 ++++++++++++++++--- .../inspectors/common/statistics.py | 60 ++++++++++++++++-- .../evaluation/inspectors/diffs_between_df.py | 29 ++++++++- .../evaluation/inspectors/filter_issues.py | 6 +- .../inspectors/get_worse_public_examples.py | 4 +- .../inspectors/print_inspectors_statistics.py | 51 ++++++++++------ .../review/reviewers/utils/print_review.py | 6 +- .../diffs_between_df/test_diifs_between_df.py | 30 ++++++++- .../inspectors/diffs_between_df/new_5.csv | 10 +++ .../inspectors/diffs_between_df/old_5.csv | 10 +++ whitelist.txt | 1 + 13 files changed, 230 insertions(+), 48 deletions(-) create mode 100644 test/resources/evaluation/common/inspectors/diffs_between_df/new_5.csv create mode 100644 test/resources/evaluation/common/inspectors/diffs_between_df/old_5.csv diff --git a/src/python/evaluation/common/pandas_util.py b/src/python/evaluation/common/pandas_util.py index 987ef030..6184335d 100644 --- a/src/python/evaluation/common/pandas_util.py +++ b/src/python/evaluation/common/pandas_util.py @@ -8,9 +8,9 @@ from src.python.evaluation.common.csv_util import write_dataframe_to_csv 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.evaluation.inspectors.common.statistics import PenaltyIssue 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__) @@ -94,10 +94,10 @@ def write_df_to_file(df: pd.DataFrame, output_file_path: Path, extension: Extens remove_sheet(output_file_path, 'Sheet') -def get_issues_from_json(str_json: str) -> List[BaseIssue]: +def get_issues_from_json(str_json: str) -> List[PenaltyIssue]: 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]: +def get_issues_by_row(df: pd.DataFrame, row: int) -> List[PenaltyIssue]: 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 271956f1..2e443c31 100644 --- a/src/python/evaluation/common/util.py +++ b/src/python/evaluation/common/util.py @@ -16,6 +16,10 @@ class ColumnName(Enum): OLD = 'old' NEW = 'new' IS_PUBLIC = 'is_public' + DECREASED_GRADE = 'decreased_grade' + PENALTY = 'penalty' + USER = 'user' + HISTORY = 'history' @unique diff --git a/src/python/evaluation/inspectors/README.md b/src/python/evaluation/inspectors/README.md index a0de1314..0a1b9436 100644 --- a/src/python/evaluation/inspectors/README.md +++ b/src/python/evaluation/inspectors/README.md @@ -114,9 +114,11 @@ An example of the pickle` file is: ```json { grade: [2, 3], + decreased_grade: [1], + user: 2, traceback: { 1: { - BaseIssue( + PenaltyIssue( origin_class='C0305', description='Trailing newlines', line_no=15, @@ -125,7 +127,8 @@ An example of the pickle` file is: file_path=Path(), inspector_type=InspectorType.UNDEFINED, - ), BaseIssue( + influence_on_penalty=0, + ), PenaltyIssue( origin_class='E211', description='whitespace before \'(\'', line_no=1, @@ -134,13 +137,32 @@ An example of the pickle` file is: file_path=Path(), inspector_type=InspectorType.UNDEFINED, + influence_on_penalty=0.6, ), } }, + penalty: { + 1: { + PenaltyIssue( + origin_class='E211', + description='whitespace before \'(\'', + line_no=1, + column_no=6, + type=IssueType('CODE_STYLE'), + + file_path=Path(), + inspector_type=InspectorType.UNDEFINED, + influence_on_penalty=0.6, + ), + } + } } ``` In the `grade` field are stored fragments ids for which grade was increased in the new data. +In the `decreased_grade` field are stored fragments ids for which grade was decreased in the new data. +In the `user` field are stored count unique users in the new dataset. 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. +In the `penalty` field for fragments ids are stored set of issues. These issues have not zero `influence_on_penalty` coefficient. ___ @@ -168,21 +190,28 @@ 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 +- how many grades have decreased value; +- how many unique users was found in the new dataset; +- for new issues and for penalty statistics: + - 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 +- for each category base influence on the penalty statistics: min, max and median values An example of the printed statistics (without full categorized statistics): ```json SUCCESS! Was not found incorrect grades. +All grades are equal. ______ +NEW INSPECTIONS STATISTICS: 39830 fragments has additional issues 139 unique issues was found +4671 unique users was found! ______ Top 10 issues: SC200: 64435 times @@ -202,6 +231,20 @@ ERROR_PRONE: 17 issues, 2363 fragments COMPLEXITY: 17 issues, 13928 fragments COHESION: 1 issues, 3826 fragments ______ +______ +PENALTY INSPECTIONS STATISTICS; +Statistics is empty! +______ +______ +INFLUENCE ON PENALTY STATISTICS; +CODE_STYLE issues: min=1, max=100, median=86 +BEST_PRACTICES issues: min=1, max=100, median=98.0 +COMPLEXITY issues: min=1, max=100, median=16.0 +MAINTAINABILITY issues: min=1, max=7, median=2.0 +CYCLOMATIC_COMPLEXITY issues: min=1, max=58, median=11.5 +COHESION issues: min=1, max=100, median=56 +BOOL_EXPR_LEN issues: min=6, max=6, median=6 +______ ``` --- diff --git a/src/python/evaluation/inspectors/common/statistics.py b/src/python/evaluation/inspectors/common/statistics.py index a36cefb7..401a29a6 100644 --- a/src/python/evaluation/inspectors/common/statistics.py +++ b/src/python/evaluation/inspectors/common/statistics.py @@ -1,16 +1,45 @@ from collections import defaultdict from dataclasses import dataclass +from statistics import median from typing import Dict, List, Tuple -from src.python.review.inspectors.issue import IssueType, ShortIssue +from src.python.review.inspectors.issue import BaseIssue, IssueType, ShortIssue + + +@dataclass(frozen=True, eq=True) +class PenaltyIssue(BaseIssue): + influence_on_penalty: int @dataclass(frozen=True) class IssuesStatistics: stat: Dict[ShortIssue, int] - changed_grades_count: int + fragments_in_stat: int + + def print_full_statistics(self, n: int, full_stat: bool, separator: str = '') -> None: + if self.fragments_in_stat == 0: + print('Statistics is empty!') + return + + print(f'{self.fragments_in_stat} fragments has additional issues') + print(f'{self.count_unique_issues()} unique issues was found') + + self.print_top_n(n, separator) + self.print_short_categorized_statistics() + print(separator) + + if full_stat: + self.print_full_inspectors_statistics() + + def print_top_n(self, n: int, separator: str) -> None: + top_n = self.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 print_full_statistics(self, to_categorize: bool = True): + def print_full_inspectors_statistics(self, to_categorize: bool = True) -> None: if to_categorize: categorized_statistics: Dict[IssueType, Dict[ShortIssue, int]] = self.get_categorized_statistics() for category, issues in categorized_statistics.items(): @@ -20,7 +49,7 @@ def print_full_statistics(self, to_categorize: bool = True): self.__print_stat(self.stat) @classmethod - def __print_stat(cls, stat: Dict[ShortIssue, int]): + def __print_stat(cls, stat: Dict[ShortIssue, int]) -> None: for issue, freq in stat.items(): cls.print_issue_with_freq(issue, freq, prefix='- ') @@ -54,3 +83,26 @@ def get_top_n_issues(self, n: int) -> List[ShortIssue]: def count_unique_issues(self) -> int: return len(self.stat) + + +# Store list of penalty influences for each category +@dataclass +class PenaltyInfluenceStatistics: + stat: Dict[IssueType, List[float]] + + def __init__(self, issues_stat_dict: Dict[int, List[PenaltyIssue]]): + self.stat = defaultdict(list) + for _, issues in issues_stat_dict.items(): + for issue in issues: + self.stat[issue.type].append(issue.influence_on_penalty) + + def print_stat(self): + for category, issues in self.stat.items(): + print(f'{category.value} issues: min={min(issues)}, max={max(issues)}, median={median(issues)}') + + +@dataclass(frozen=True) +class GeneralInspectorsStatistics: + new_issues_stat: IssuesStatistics + penalty_issues_stat: IssuesStatistics + penalty_influence_stat: PenaltyInfluenceStatistics diff --git a/src/python/evaluation/inspectors/diffs_between_df.py b/src/python/evaluation/inspectors/diffs_between_df.py index c747175f..04269773 100644 --- a/src/python/evaluation/inspectors/diffs_between_df.py +++ b/src/python/evaluation/inspectors/diffs_between_df.py @@ -30,18 +30,35 @@ def configure_arguments(parser: argparse.ArgumentParser) -> None: # Find difference between two dataframes. Return dict: # { # grade: [list_of_fragment_ids], +# decreased_grade: [list_of_fragment_ids], +# user: count_unique_users, # traceback: { # fragment_id: [list of issues] # }, +# penalty: { +# fragment_id: [list of issues] +# }, # } # The key contains only fragments that increase quality in new df +# The key contains only fragments that decrease quality in new df +# The key count number of unique users in the new dataset # The key contains list of new issues for each fragment +# The key contains list of issues with not zero influence_on_penalty coefficient def find_diffs(old_df: pd.DataFrame, new_df: pd.DataFrame) -> dict: + if ColumnName.HISTORY.value in new_df.columns: + del new_df[ColumnName.HISTORY.value] + new_df = new_df.reindex(columns=old_df.columns) inconsistent_positions = get_inconsistent_positions(old_df, new_df) diffs = { ColumnName.GRADE.value: [], + ColumnName.DECREASED_GRADE.value: [], EvaluationArgument.TRACEBACK.value: {}, + ColumnName.PENALTY.value: {}, } + if ColumnName.USER.value in new_df.columns: + diffs[ColumnName.USER.value] = len(new_df[ColumnName.USER.value].unique()) + else: + diffs[ColumnName.USER.value] = 0 # 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] @@ -53,13 +70,21 @@ def find_diffs(old_df: pd.DataFrame, new_df: pd.DataFrame) -> dict: # It is an unexpected keys, we should check the algorithm diffs[ColumnName.GRADE.value].append(fragment_id) else: - # Find difference between issues + if new_quality < old_quality: + diffs[ColumnName.DECREASED_GRADE.value].append(fragment_id) old_issues = get_issues_by_row(old_df, row) new_issues = get_issues_by_row(new_df, row) + # Find difference between issues 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 + if len(difference) > 0: + diffs[EvaluationArgument.TRACEBACK.value][fragment_id] = difference + + # Find issues with influence_in_penalty > 0 + penalty = set(filter(lambda i: i.influence_on_penalty > 0, new_issues)) + if len(penalty) > 0: + diffs[ColumnName.PENALTY.value][fragment_id] = penalty return diffs diff --git a/src/python/evaluation/inspectors/filter_issues.py b/src/python/evaluation/inspectors/filter_issues.py index ca4b38b6..6cc7b0d9 100644 --- a/src/python/evaluation/inspectors/filter_issues.py +++ b/src/python/evaluation/inspectors/filter_issues.py @@ -6,8 +6,8 @@ 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.evaluation.inspectors.common.statistics import PenaltyIssue 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 @@ -30,12 +30,12 @@ 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]: +def __get_new_issues(traceback: str, new_issues_classes: Set[str]) -> List[PenaltyIssue]: 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: +def __add_issues_for_fragment(fragment_id: int, new_issues: List[PenaltyIssue], diffs: dict) -> None: if len(new_issues) > 0: diffs[TRACEBACK][fragment_id] = new_issues diff --git a/src/python/evaluation/inspectors/get_worse_public_examples.py b/src/python/evaluation/inspectors/get_worse_public_examples.py index 1bb036c5..4d018c3b 100644 --- a/src/python/evaluation/inspectors/get_worse_public_examples.py +++ b/src/python/evaluation/inspectors/get_worse_public_examples.py @@ -7,8 +7,8 @@ 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.evaluation.inspectors.common.statistics import PenaltyIssue 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: @@ -26,7 +26,7 @@ def configure_arguments(parser: argparse.ArgumentParser) -> None: default=10) -def __get_new_inspections(fragment_id_to_issues: Dict[int, List[BaseIssue]], fragment_id: int) -> str: +def __get_new_inspections(fragment_id_to_issues: Dict[int, List[PenaltyIssue]], fragment_id: int) -> str: return ','.join(set(map(lambda i: i.origin_class, fragment_id_to_issues.get(fragment_id, [])))) diff --git a/src/python/evaluation/inspectors/print_inspectors_statistics.py b/src/python/evaluation/inspectors/print_inspectors_statistics.py index 8b132a31..67281528 100644 --- a/src/python/evaluation/inspectors/print_inspectors_statistics.py +++ b/src/python/evaluation/inspectors/print_inspectors_statistics.py @@ -1,11 +1,13 @@ import argparse from collections import defaultdict from pathlib import Path -from typing import Dict +from typing import Dict, List 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.evaluation.inspectors.common.statistics import ( + GeneralInspectorsStatistics, IssuesStatistics, PenaltyInfluenceStatistics, PenaltyIssue, +) from src.python.review.common.file_system import deserialize_data_from_file from src.python.review.inspectors.issue import ShortIssue @@ -33,23 +35,25 @@ 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]) +def has_decreased_grades(diffs_dict: dict) -> bool: + return len(diffs_dict[ColumnName.DECREASED_GRADE.value]) > 0 + + +def __gather_issues_stat(issues_stat_dict: Dict[int, List[PenaltyIssue]]) -> IssuesStatistics: + fragments_in_stat = len(issues_stat_dict) issues_dict: Dict[ShortIssue, int] = defaultdict(int) - for _, issues in diffs_dict[EvaluationArgument.TRACEBACK.value].items(): + for _, issues in issues_stat_dict.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) + return IssuesStatistics(issues_dict, fragments_in_stat) -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 gather_statistics(diffs_dict: dict) -> GeneralInspectorsStatistics: + new_issues_stat = __gather_issues_stat(diffs_dict[EvaluationArgument.TRACEBACK.value]) + penalty_issues_stat = __gather_issues_stat(diffs_dict[ColumnName.PENALTY.value]) + return GeneralInspectorsStatistics(new_issues_stat, penalty_issues_stat, + PenaltyInfluenceStatistics(diffs_dict[ColumnName.PENALTY.value])) def main() -> None: @@ -64,20 +68,27 @@ def main() -> None: print(f'WARNING! Was found incorrect grades in the following fragments: {diffs[ColumnName.GRADE.value]}.') else: print('SUCCESS! Was not found incorrect grades.') + + if not has_decreased_grades(diffs): + print('All grades are equal.') + else: + print(f'Decreased grades was found in {len(diffs[ColumnName.DECREASED_GRADE.value])} fragments') + print(f'{diffs[ColumnName.USER.value]} unique users was found!') 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) + print('NEW INSPECTIONS STATISTICS:') + statistics.new_issues_stat.print_full_statistics(n, args.full_stat, separator) + print(separator) - statistics.print_short_categorized_statistics() + print('PENALTY INSPECTIONS STATISTICS;') + statistics.penalty_issues_stat.print_full_statistics(n, args.full_stat, separator) print(separator) - if args.full_stat: - statistics.print_full_statistics() + print('INFLUENCE ON PENALTY STATISTICS;') + statistics.penalty_influence_stat.print_stat() + print(separator) if __name__ == '__main__': diff --git a/src/python/review/reviewers/utils/print_review.py b/src/python/review/reviewers/utils/print_review.py index f67db761..4facf3fb 100644 --- a/src/python/review/reviewers/utils/print_review.py +++ b/src/python/review/reviewers/utils/print_review.py @@ -4,6 +4,7 @@ from pathlib import Path from typing import Any, Dict, List +from src.python.evaluation.inspectors.common.statistics import PenaltyIssue from src.python.review.common.file_system import get_file_line from src.python.review.inspectors.inspector_type import InspectorType from src.python.review.inspectors.issue import BaseIssue, IssueType @@ -135,11 +136,11 @@ def convert_issue_to_json(issue: BaseIssue, influence_on_penalty: int) -> Dict[s # It works only for old json format -def convert_json_to_issues(issues_json: List[dict]) -> List[BaseIssue]: +def convert_json_to_issues(issues_json: List[dict]) -> List[PenaltyIssue]: issues = [] for issue in issues_json: issues.append( - BaseIssue( + PenaltyIssue( origin_class=issue[IssueJsonFields.CODE.value], description=issue[IssueJsonFields.TEXT.value], line_no=int(issue[IssueJsonFields.LINE_NUMBER.value]), @@ -148,6 +149,7 @@ def convert_json_to_issues(issues_json: List[dict]) -> List[BaseIssue]: file_path=Path(), inspector_type=InspectorType.UNDEFINED, + influence_on_penalty=issue.get(IssueJsonFields.INFLUENCE_ON_PENALTY.value, 0), ), ) return issues 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 index 86af9105..8164134d 100644 --- 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 @@ -4,24 +4,31 @@ 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.common.statistics import PenaltyIssue 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 +from src.python.review.inspectors.issue import IssueType RESOURCES_PATH = INSPECTORS_DIR_PATH / 'diffs_between_df' EMPTY_DIFFS = { ColumnName.GRADE.value: [], + ColumnName.DECREASED_GRADE.value: [], + ColumnName.USER.value: 0, EvaluationArgument.TRACEBACK.value: {}, + ColumnName.PENALTY.value: {}, } INCORRECT_GRADE_DIFFS = { ColumnName.GRADE.value: [1, 2], + ColumnName.DECREASED_GRADE.value: [], + ColumnName.USER.value: 0, EvaluationArgument.TRACEBACK.value: {}, + ColumnName.PENALTY.value: {}, } ISSUES = { - BaseIssue( + PenaltyIssue( origin_class='C0305', description='Trailing newlines', line_no=15, @@ -30,7 +37,8 @@ file_path=Path(), inspector_type=InspectorType.UNDEFINED, - ), BaseIssue( + influence_on_penalty=0, + ), PenaltyIssue( origin_class='E211', description='whitespace before \'(\'', line_no=1, @@ -39,21 +47,36 @@ file_path=Path(), inspector_type=InspectorType.UNDEFINED, + influence_on_penalty=0, ), } ISSUES_DIFFS = { ColumnName.GRADE.value: [], + ColumnName.DECREASED_GRADE.value: [], + ColumnName.USER.value: 0, EvaluationArgument.TRACEBACK.value: { 1: ISSUES, }, + ColumnName.PENALTY.value: {}, } MIXED_DIFFS = { ColumnName.GRADE.value: [2, 3], + ColumnName.DECREASED_GRADE.value: [], + ColumnName.USER.value: 0, EvaluationArgument.TRACEBACK.value: { 1: ISSUES, }, + ColumnName.PENALTY.value: {}, +} + +DECREASED_GRADE = { + ColumnName.GRADE.value: [], + ColumnName.DECREASED_GRADE.value: [2, 3], + ColumnName.USER.value: 0, + EvaluationArgument.TRACEBACK.value: {}, + ColumnName.PENALTY.value: {}, } TEST_DATA = [ @@ -61,6 +84,7 @@ ('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), + ('old_5.csv', 'new_5.csv', DECREASED_GRADE), ] diff --git a/test/resources/evaluation/common/inspectors/diffs_between_df/new_5.csv b/test/resources/evaluation/common/inspectors/diffs_between_df/new_5.csv new file mode 100644 index 00000000..98da9077 --- /dev/null +++ b/test/resources/evaluation/common/inspectors/diffs_between_df/new_5.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,BAD,"{""quality"": {""code"": ""BAD"", ""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,BAD,"{""quality"": {""code"": ""BAD"", ""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_5.csv b/test/resources/evaluation/common/inspectors/diffs_between_df/old_5.csv new file mode 100644 index 00000000..c415b91a --- /dev/null +++ b/test/resources/evaluation/common/inspectors/diffs_between_df/old_5.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 3e331750..1b2e8602 100644 --- a/whitelist.txt +++ b/whitelist.txt @@ -114,3 +114,4 @@ nrows groupby getuid Popen +reindex From 8d12c868820e1c1b085a6fee56744954e830736c Mon Sep 17 00:00:00 2001 From: Nastya Birillo Date: Mon, 31 May 2021 17:52:05 +0300 Subject: [PATCH 28/73] Add Qodana handlers (#34) * Add a script for filtering inspections * Add handlers for getting unique inspections * Add a script to convert data for a model * Add a script for preprocessing data for the second qodana model (inspections line by line) --- src/python/common/tool_arguments.py | 7 + src/python/evaluation/README.md | 6 +- src/python/evaluation/common/util.py | 6 + src/python/evaluation/inspectors/README.md | 4 +- .../evaluation/inspectors/filter_issues.py | 11 +- .../inspectors/print_inspectors_statistics.py | 4 +- src/python/evaluation/qodana/README.md | 212 ++++++++++++++++++ .../evaluation/qodana/dataset_labeling.py | 12 +- .../evaluation/qodana/filter_inspections.py | 58 +++++ .../qodana/fragment_to_inspections_list.py | 33 +++ ...agment_to_inspections_list_line_by_line.py | 62 +++++ .../qodana/get_unique_inspectors.py | 94 ++++++++ src/python/evaluation/qodana/util/models.py | 9 + src/python/evaluation/qodana/util/util.py | 51 +++++ src/python/review/common/file_system.py | 9 +- whitelist.txt | 3 + 16 files changed, 557 insertions(+), 24 deletions(-) create mode 100644 src/python/evaluation/qodana/filter_inspections.py create mode 100644 src/python/evaluation/qodana/fragment_to_inspections_list.py create mode 100644 src/python/evaluation/qodana/fragment_to_inspections_list_line_by_line.py create mode 100644 src/python/evaluation/qodana/get_unique_inspectors.py create mode 100644 src/python/evaluation/qodana/util/util.py diff --git a/src/python/common/tool_arguments.py b/src/python/common/tool_arguments.py index d3048051..65af5a53 100644 --- a/src/python/common/tool_arguments.py +++ b/src/python/common/tool_arguments.py @@ -89,3 +89,10 @@ class RunToolArgument(Enum): DIFFS_FILE_PATH = ArgumentsInfo(None, 'diffs_file_path', 'Path to a file with serialized diffs that were founded by diffs_between_df.py') + + QODANA_SOLUTIONS_FILE_PATH = ArgumentsInfo(None, 'solutions_file_path', + 'Csv file with solutions. This file must be graded by Qodana.') + + QODANA_INSPECTIONS_PATH = ArgumentsInfo(None, 'inspections_path', 'Path to a CSV file with inspections list.') + + QODANA_DUPLICATES = ArgumentsInfo(None, '--remove-duplicates', 'Remove duplicates around inspections') diff --git a/src/python/evaluation/README.md b/src/python/evaluation/README.md index 5aa4bdf7..f8fafbe0 100644 --- a/src/python/evaluation/README.md +++ b/src/python/evaluation/README.md @@ -29,7 +29,7 @@ Optional arguments: 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` .| +|**‑tp**, **‑‑tool‑path**| Path to run-tool. Default is `src/python/review/run_tool.py` .| |**‑‑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`.| +|**‑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/util.py b/src/python/evaluation/common/util.py index 2e443c31..836074b9 100644 --- a/src/python/evaluation/common/util.py +++ b/src/python/evaluation/common/util.py @@ -1,4 +1,5 @@ from enum import Enum, unique +from typing import Set from src.python.review.application_config import LanguageVersion from src.python.review.common.file_system import Extension @@ -43,3 +44,8 @@ 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}.') + + +# Split string by separator +def parse_set_arg(str_arg: str, separator: str = ',') -> Set[str]: + return set(str_arg.split(separator)) diff --git a/src/python/evaluation/inspectors/README.md b/src/python/evaluation/inspectors/README.md index 0a1b9436..e32e390f 100644 --- a/src/python/evaluation/inspectors/README.md +++ b/src/python/evaluation/inspectors/README.md @@ -183,8 +183,8 @@ 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.| +|**‑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. diff --git a/src/python/evaluation/inspectors/filter_issues.py b/src/python/evaluation/inspectors/filter_issues.py index 6cc7b0d9..e0d7d86b 100644 --- a/src/python/evaluation/inspectors/filter_issues.py +++ b/src/python/evaluation/inspectors/filter_issues.py @@ -5,9 +5,10 @@ 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.evaluation.common.util import ColumnName, EvaluationArgument, parse_set_arg from src.python.evaluation.inspectors.common.statistics import PenaltyIssue 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 @@ -26,16 +27,12 @@ def configure_arguments(parser: argparse.ArgumentParser) -> None: 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[PenaltyIssue]: 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[PenaltyIssue], diffs: dict) -> None: +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 @@ -59,7 +56,7 @@ def main() -> None: solutions_file_path = args.solutions_file_path solutions_df = get_solutions_df_by_file_path(solutions_file_path) - issues = __parse_issues_arg(args.issues) + issues = parse_set_arg(args.issues) diffs = get_statistics_dict(solutions_df, issues) output_path = get_parent_folder(Path(solutions_file_path)) / f'diffs{Extension.PICKLE.value}' diff --git a/src/python/evaluation/inspectors/print_inspectors_statistics.py b/src/python/evaluation/inspectors/print_inspectors_statistics.py index 67281528..e072027c 100644 --- a/src/python/evaluation/inspectors/print_inspectors_statistics.py +++ b/src/python/evaluation/inspectors/print_inspectors_statistics.py @@ -21,12 +21,12 @@ def configure_arguments(parser: argparse.ArgumentParser) -> None: help='If True, statistics will be categorized by several categories.', action='store_true') - parser.add_argument('-n', '--top_n', + parser.add_argument('-n', '--top-n', help='The top N items will be printed', type=int, default=10) - parser.add_argument('--full_stat', + parser.add_argument('--full-stat', help='If True, full statistics will be printed.', action='store_true') diff --git a/src/python/evaluation/qodana/README.md b/src/python/evaluation/qodana/README.md index f0097ca5..b42ae860 100644 --- a/src/python/evaluation/qodana/README.md +++ b/src/python/evaluation/qodana/README.md @@ -19,3 +19,215 @@ Run the [dataset_labeling.py](dataset_labeling.py) with the arguments from comma | **‑l**, **‑‑limit** | Allows you to read only the specified number of first rows from the dataset. If no limit is specified, the whole dataset will be processed. | | **‑s**, **‑‑chunk‑size** | The number of files that Qodana will process at a time. Default is `5000`. | | **‑o**, **‑‑output‑path** | The path where the labeled dataset will be saved. If not specified, the original dataset will be overwritten. | + +--- + +# Postprocessing + +The model that imitates Qodana analysis gets input from a dataset in a special format. +This module allows preparing datasets that were graded by [dataset_marking.py](dataset_marking.py) script. + +Data processing consists of several stages: +- union several `csv` files that were graded by [dataset_marking.py](dataset_marking.py) script + and filter inspections list if it is necessary; +- get all unique inspections from the dataset; +- convert `csv` file into a special format. + +## Filter inspections + +This stage allow you to union several `csv` files that were graded by [dataset_marking.py](dataset_marking.py) script + and filter inspections list if it is necessary. + +Please, note that your all input files must be graded by [dataset_marking.py](dataset_marking.py) script +and have `inspections` column. + +Output file is a new `csv` file with the all columns from the input files. + +#### Usage + +Run the [filter_inspections.py](filter_inspections.py) with the arguments from command line. + +Required arguments: + +`dataset_folder` — path to a folder with csv files graded by Qodana. Each file must have `inspections` column. + +Optional arguments: +Argument | Description +--- | --- +|**‑i**, **‑‑inspections**| Set of inspections ids to exclude from the dataset separated by comma. By default all inspections remain. | + +The resulting file will be stored in the `dataset_folder`. + +___ + +## Get all unique inspections + +This stage allow you to get all unique inspections from a `csv` file graded by Qodana. +Please, note that your input file must be graded by [dataset_marking.py](dataset_marking.py) script +and has `inspections` column. + +Output file is a new `csv` file with four columns: `id`, `inspection_id`, `count_all`, `count_uniq`. +`id` is unique number for each inspection, minimal value is 1. +`inspection_id` is unique Qoadana id for each inspection. +`count_all` count all fragments where was this inspection (with duplicates). +`count_uniq` count all fragments where was this inspection (without duplicates). + +#### Usage + +Run the [get_unique_inspectors.py](get_unique_inspectors.py) with the arguments from command line. + +Required arguments: + +`solutions_file_path` — path to csv-file with code samples graded by [dataset_marking.py](dataset_marking.py) script. + +Optional arguments: +Argument | Description +--- | --- +|**‑‑uniq**| To count all fragments for each inspection where was this inspection (without duplicates). By default it disabled. | + +The resulting file will be stored in the same folder as the input file. + +An example of the output file: + +```json +id | inspection_id | count_all | count_unique +-----|---------------------|--------------|-------------- +1 | SystemOutErr | 5 | 2 +2 | ConstantExpression | 1 | 1 +``` + +___ + +#### Convert `csv` file into a special format + +This block describes what format can be converted csv-file with code samples +graded by [dataset_marking.py](dataset_marking.py) script. + +We have two different formats: +- fragment to inspections list; +- fragment to inspections list with positions. + + +#### Fragment to inspections list + +This data representation match code fragments to a list with ids of inspections. + +Please, note that your input file must be graded by [dataset_marking.py](dataset_marking.py) script +and has `inspections` column. + +Output file is a new `csv` file with a new `inspections` column with list with ids of inspections. +If the list of inspections for the fragment is empty, then write 0. + +#### Usage + +Run the [fragment_to_inspections_list.py](fragment_to_inspections_list.py) with the arguments from command line. + +Required arguments: + +- `solutions_file_path` — path to csv-file with code samples graded by [dataset_marking.py](dataset_marking.py) script, +- `inspections_path` — path to csv-file with inspections list from the input file. You can get this file by [get_unique_inspectors.py](get_unique_inspectors.py) script. + +Optional arguments: +Argument | Description +--- | --- +|**‑‑remove‑duplicates**| Remove duplicates around inspections in each row. Default value is `False`. | + +The resulting file will be stored in the same folder as the input file. + +An example of the input file: + +```json +id | code | lang | inspections +-----|-------------------|---------------|----------------- +2 | "// some code" | java11 | "{""issues"": []}" +3 | "// some code" | java11 | "{""issues"": [""{\"... \""problem_id\"": \""SystemOutErr\""}""]}" +0 | "// some code" | java11 | "{""issues"": [""{\"...\""problem_id\"": \""ConstantExpression\""}"",""{\"...\""problem_id\"": \""ConstantExpression\""}""]}" +1 | "// some code" | java11 | "{""issues"": []}" +``` + +with the inspections file: + +```json +id | inspection_id +-----|------------------- +1 | SystemOutErr +2 | ConstantExpression +``` + +An example of the output file: + +```json +id | code | lang | inspections +-----|-------------------|---------------|----------------- +2 | "// some code" | java11 | 0 +3 | "// some code" | java11 | 1 +0 | "// some code" | java11 | 2,2 +1 | "// some code" | java11 | 0 + +``` + +--- + +#### Fragment to inspections list with positions + +This data representation match each line in code fragments to a list with ids of inspections in this line. + +Please, note that your input file must be graded by [dataset_marking.py](dataset_marking.py) script +and has `inspections` column. + +Output file is a new `csv` file with a new `inspections` column with list with ids of inspections. +If the list of inspections for the fragment is empty, then write 0. +Note, that each line in code fragments in the new file is stored in a separate row. +All indents as well as blank lines are keeped. + +#### Usage + +Run the [fragment_to_inspections_list_line_by_line.py](fragment_to_inspections_list_line_by_line.py) with the arguments from command line. + +Required arguments: + +- `solutions_file_path` — path to csv-file with code samples graded by [dataset_marking.py](dataset_marking.py) script, +- `inspections_path` — path to csv-file with inspections list from the input file. You can get this file by [get_unique_inspectors.py](get_unique_inspectors.py) script. + +Optional arguments: +Argument | Description +--- | --- +|**‑‑remove‑duplicates**| Remove duplicates around inspections in each row. Default value is `False`. | + +The resulting file will be stored in the same folder as the input file. + +An example of the input file: + +```json +id | code | lang | inspections +-----|-------------------|---------------|----------------- +2 | "// some code" | java11 | "{""issues"": []}" +3 | "// some code" | java11 | "{""issues"": [""{\"... \""problem_id\"": \""SystemOutErr\""}""]}" +0 | "// some code" | java11 | "{""issues"": [""{\"...\""problem_id\"": \""ConstantExpression\""}"",""{\"...\""problem_id\"": \""ConstantExpression\""}""]}" +1 | "// some code" | java11 | "{""issues"": []}" +``` + +with the inspections file: + +```json +id | inspection_id +-----|------------------- +1 | SystemOutErr +2 | ConstantExpression +``` + +An example of the output file: + +```json +id | code | lang | inspections +-----|----------------------------------------|---------------|----------------- +2 | "// first line from code with id 2" | java11 | 0 +2 | "// second line from code with id 2" | java11 | 0 +3 | "// first line from code with id 3" | java11 | 1 +3 | "// second line from code with id 3" | java11 | 0 +0 | "// first line from code with id 0" | java11 | 0 +0 | "// second line from code with id 0" | java11 | 2,2 +1 | "// first line from code with id 1" | java11 | 0 +1 | "// second line from code with id 1" | java11 | 0 + +``` diff --git a/src/python/evaluation/qodana/dataset_labeling.py b/src/python/evaluation/qodana/dataset_labeling.py index b3df20a7..12425af0 100644 --- a/src/python/evaluation/qodana/dataset_labeling.py +++ b/src/python/evaluation/qodana/dataset_labeling.py @@ -16,7 +16,8 @@ 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.qodana.util.models import QodanaColumnName, QodanaIssue, QodanaJsonField +from src.python.evaluation.qodana.util.models import QodanaColumnName, QodanaIssue +from src.python.evaluation.qodana.util.util import to_json from src.python.review.application_config import LanguageVersion from src.python.review.common.file_system import ( copy_directory, @@ -186,13 +187,6 @@ def _parse_inspections_files(cls, inspections_files: List[Path]) -> Dict[int, Li id_to_issues[fragment_id].append(qodana_issue) return id_to_issues - @classmethod - def _to_json(cls, issues: List[QodanaIssue]) -> str: - issues_json = { - QodanaJsonField.ISSUES.value: list(map(lambda i: i.to_json(), issues)), - } - return json.dumps(issues_json) - def _label_chunk(self, chunk: pd.DataFrame, language: LanguageVersion, chunk_id: int) -> pd.DataFrame: tmp_dir_path = self.dataset_path.parent.absolute() / f'qodana_project_{chunk_id}' create_directory(tmp_dir_path) @@ -219,7 +213,7 @@ def _label_chunk(self, chunk: pd.DataFrame, language: LanguageVersion, chunk_id: logger.info('Write inspections') chunk[QodanaColumnName.INSPECTIONS.value] = chunk.apply( - lambda row: self._to_json(inspections.get(row[ColumnName.ID.value], [])), axis=1, + lambda row: to_json(inspections.get(row[ColumnName.ID.value], [])), axis=1, ) remove_directory(tmp_dir_path) diff --git a/src/python/evaluation/qodana/filter_inspections.py b/src/python/evaluation/qodana/filter_inspections.py new file mode 100644 index 00000000..9321a7eb --- /dev/null +++ b/src/python/evaluation/qodana/filter_inspections.py @@ -0,0 +1,58 @@ +import argparse +from pathlib import Path +from typing import List + +import pandas as pd +from src.python.evaluation.common.csv_util import write_dataframe_to_csv +from src.python.evaluation.common.pandas_util import get_solutions_df_by_file_path +from src.python.evaluation.common.util import parse_set_arg +from src.python.evaluation.qodana.util.models import QodanaColumnName, QodanaIssue +from src.python.evaluation.qodana.util.util import to_json +from src.python.review.common.file_system import Extension, extension_file_condition, get_all_file_system_items + + +def configure_arguments(parser: argparse.ArgumentParser) -> None: + parser.add_argument('dataset_folder', + type=lambda value: Path(value).absolute(), + help='Path to a folder with csv files graded by Qodana. ' + 'Each file must have "inspections" column.') + + parser.add_argument('-i', '--inspections', + help='Set of inspections ids to exclude from the dataset', + type=str, + default='') + + +def __get_qodana_dataset(root: Path) -> pd.DataFrame: + if not root.is_dir(): + raise ValueError(f'The {root} is not a directory') + dataset_files = get_all_file_system_items(root, extension_file_condition(Extension.CSV)) + datasets = [] + for file_path in dataset_files: + datasets.append(get_solutions_df_by_file_path(file_path)) + return pd.concat(datasets) + + +def __filter_inspections(json_issues: str, inspections_to_keep: List[str]) -> str: + issues_list = QodanaIssue.parse_list_issues_from_json(json_issues) + filtered_issues = list(filter(lambda i: i.problem_id not in inspections_to_keep, issues_list)) + return to_json(filtered_issues) + + +def main() -> None: + parser = argparse.ArgumentParser() + configure_arguments(parser) + args = parser.parse_args() + + dataset_folder = args.dataset_folder + full_dataset = __get_qodana_dataset(dataset_folder) + inspections_to_keep = parse_set_arg(args.inspections) + + full_dataset[QodanaColumnName.INSPECTIONS.value] = full_dataset.apply( + lambda row: __filter_inspections(row[QodanaColumnName.INSPECTIONS.value], inspections_to_keep), axis=1) + + write_dataframe_to_csv(dataset_folder / f'filtered_issues{Extension.CSV.value}', full_dataset) + + +if __name__ == '__main__': + main() diff --git a/src/python/evaluation/qodana/fragment_to_inspections_list.py b/src/python/evaluation/qodana/fragment_to_inspections_list.py new file mode 100644 index 00000000..42fe3ec6 --- /dev/null +++ b/src/python/evaluation/qodana/fragment_to_inspections_list.py @@ -0,0 +1,33 @@ +import argparse +from pathlib import Path + +from src.python.evaluation.common.csv_util import write_dataframe_to_csv +from src.python.evaluation.common.pandas_util import get_solutions_df_by_file_path +from src.python.evaluation.qodana.util.models import QodanaColumnName, QodanaIssue +from src.python.evaluation.qodana.util.util import ( + configure_model_converter_arguments, get_inspections_dict, replace_inspections_on_its_ids, +) +from src.python.review.common.file_system import Extension, get_parent_folder + +INSPECTIONS = QodanaColumnName.INSPECTIONS.value + + +def main() -> None: + parser = argparse.ArgumentParser() + configure_model_converter_arguments(parser) + args = parser.parse_args() + + solutions_file_path = args.solutions_file_path + solutions_df = get_solutions_df_by_file_path(solutions_file_path) + inspections_dict = get_inspections_dict(args.inspections_path) + + solutions_df[INSPECTIONS] = solutions_df.apply( + lambda row: replace_inspections_on_its_ids(QodanaIssue.parse_list_issues_from_json(row[INSPECTIONS]), + inspections_dict, args.remove_duplicates), axis=1) + + output_path = get_parent_folder(Path(solutions_file_path)) + write_dataframe_to_csv(output_path / f'numbered_ids{Extension.CSV.value}', solutions_df) + + +if __name__ == '__main__': + main() diff --git a/src/python/evaluation/qodana/fragment_to_inspections_list_line_by_line.py b/src/python/evaluation/qodana/fragment_to_inspections_list_line_by_line.py new file mode 100644 index 00000000..c70d9ba1 --- /dev/null +++ b/src/python/evaluation/qodana/fragment_to_inspections_list_line_by_line.py @@ -0,0 +1,62 @@ +import argparse +import os +from itertools import groupby +from pathlib import Path +from typing import Dict, List + +import pandas as pd +from src.python.evaluation.common.csv_util import write_dataframe_to_csv +from src.python.evaluation.common.pandas_util import get_solutions_df_by_file_path +from src.python.evaluation.common.util import ColumnName +from src.python.evaluation.qodana.util.models import QodanaColumnName, QodanaIssue +from src.python.evaluation.qodana.util.util import ( + configure_model_converter_arguments, get_inspections_dict, replace_inspections_on_its_ids, +) +from src.python.review.common.file_system import Extension, get_parent_folder + + +INSPECTIONS = QodanaColumnName.INSPECTIONS.value +CODE = ColumnName.CODE.value + + +# Make a new dataframe where code fragment is separated line by line and inspections are grouped line by line +def __replace_inspections_to_its_ids_in_row(row: pd.Series, inspections_dict: Dict[str, int], + to_remove_duplicates: bool) -> pd.DataFrame: + row_df = pd.DataFrame(row).transpose() + fragment_lines = row_df.iloc[0][CODE].split(os.linesep) + fragment_df = row_df.loc[row_df.index.repeat(len(fragment_lines))].reset_index(drop=True) + + issues_list = QodanaIssue.parse_list_issues_from_json(row_df.iloc[0][INSPECTIONS]) + line_number_to_issues = {k: list(v) for k, v in groupby(issues_list, key=lambda i: i.line)} + for index, fragment_line in enumerate(fragment_lines): + issues = line_number_to_issues.get(index + 1, []) + fragment_df.iloc[index][CODE] = fragment_line + fragment_df.iloc[index][INSPECTIONS] = replace_inspections_on_its_ids(issues, inspections_dict, + to_remove_duplicates) + return fragment_df + + +def __append_df(df: pd.DataFrame, df_list: List[pd.DataFrame]) -> None: + df_list.append(df) + + +def main() -> None: + parser = argparse.ArgumentParser() + configure_model_converter_arguments(parser) + args = parser.parse_args() + + solutions_file_path = args.solutions_file_path + solutions_df = get_solutions_df_by_file_path(solutions_file_path) + inspections_dict = get_inspections_dict(args.inspections_path) + + fragment_df_list = [] + solutions_df.apply( + lambda row: __append_df(__replace_inspections_to_its_ids_in_row(row, inspections_dict, args.remove_duplicates), + fragment_df_list), axis=1) + + output_path = get_parent_folder(Path(solutions_file_path)) + write_dataframe_to_csv(output_path / f'numbered_ids_line_by_line{Extension.CSV.value}', pd.concat(fragment_df_list)) + + +if __name__ == '__main__': + main() diff --git a/src/python/evaluation/qodana/get_unique_inspectors.py b/src/python/evaluation/qodana/get_unique_inspectors.py new file mode 100644 index 00000000..35c32bdb --- /dev/null +++ b/src/python/evaluation/qodana/get_unique_inspectors.py @@ -0,0 +1,94 @@ +import argparse +import itertools +from collections import defaultdict +from pathlib import Path +from typing import Dict, List, Optional + +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 get_solutions_df_by_file_path +from src.python.evaluation.qodana.util.models import QodanaColumnName, QodanaIssue +from src.python.review.common.file_system import Extension, get_parent_folder + + +INSPECTION_ID = QodanaColumnName.INSPECTION_ID.value +INSPECTIONS = QodanaColumnName.INSPECTIONS.value +COUNT_ALL = QodanaColumnName.COUNT_ALL.value +COUNT_UNIQUE = QodanaColumnName.COUNT_UNIQUE.value +ID = QodanaColumnName.ID.value + + +def configure_arguments(parser: argparse.ArgumentParser) -> None: + parser.add_argument(RunToolArgument.QODANA_SOLUTIONS_FILE_PATH.value.long_name, + type=lambda value: Path(value).absolute(), + help=RunToolArgument.QODANA_SOLUTIONS_FILE_PATH.value.description) + + parser.add_argument('--uniq', + help='If True, count fragments for eash inspection in which this inspection was.', + action='store_true') + + +def __get_inspections_ids(json_issues: str) -> List[str]: + issues_list = QodanaIssue.parse_list_issues_from_json(json_issues) + return list(map(lambda i: i.problem_id, issues_list)) + + +def __get_inspections_from_df(solutions_df: pd.DataFrame) -> List[str]: + inspections = solutions_df.apply(lambda row: __get_inspections_ids(row[INSPECTIONS]), axis=1) + return list(itertools.chain.from_iterable(inspections.values)) + + +def __count_uniq_inspections_in_fragment(json_issues: str, inspection_id_to_fragments: Dict[str, int]) -> None: + issues_list = set(__get_inspections_ids(json_issues)) + for issue in issues_list: + inspection_id_to_fragments[issue] += 1 + + +def __get_uniq_inspections_in_all_fragments(solutions_df: pd.DataFrame) -> Dict[str, int]: + inspection_id_to_fragments: Dict[str, int] = defaultdict(int) + solutions_df.apply(lambda row: __count_uniq_inspections_in_fragment(row[INSPECTIONS], inspection_id_to_fragments), + axis=1) + + return inspection_id_to_fragments + + +def __get_all_inspections_by_inspection_id(inspection_id: str, all_inspections: List[str]) -> List[str]: + return list(filter(lambda i: i == inspection_id, all_inspections)) + + +def __create_unique_inspections_df(inspections: List[str], + inspection_id_to_fragments: Optional[Dict[str, int]]) -> pd.DataFrame: + id_to_inspection = {} + for index, inspection in enumerate(set(inspections)): + id_to_inspection[index + 1] = inspection + inspections_df = pd.DataFrame(id_to_inspection.items(), columns=[ID, INSPECTION_ID]) + inspections_df[COUNT_ALL] = inspections_df.apply(lambda row: len(__get_all_inspections_by_inspection_id( + row[INSPECTION_ID], inspections)), axis=1) + if inspection_id_to_fragments is None: + inspections_df[COUNT_UNIQUE] = 0 + else: + inspections_df[COUNT_UNIQUE] = inspections_df.apply(lambda row: inspection_id_to_fragments.get( + row[INSPECTION_ID], 0), axis=1) + return inspections_df + + +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) + if args.uniq: + inspection_id_to_fragments = __get_uniq_inspections_in_all_fragments(solutions_df) + else: + inspection_id_to_fragments = None + inspections_df = __create_unique_inspections_df(__get_inspections_from_df(solutions_df), inspection_id_to_fragments) + + output_path = get_parent_folder(Path(solutions_file_path)) + write_dataframe_to_csv(output_path / f'inspections{Extension.CSV.value}', inspections_df) + + +if __name__ == '__main__': + main() diff --git a/src/python/evaluation/qodana/util/models.py b/src/python/evaluation/qodana/util/models.py index f5b3a589..08ce4c9f 100644 --- a/src/python/evaluation/qodana/util/models.py +++ b/src/python/evaluation/qodana/util/models.py @@ -1,6 +1,7 @@ import json from dataclasses import dataclass from enum import Enum, unique +from typing import List @dataclass(frozen=True) @@ -38,10 +39,18 @@ def from_json(cls, str_json: str) -> 'QodanaIssue': problem_id=issue[QodanaJsonField.PROBLEM_ID.value], ) + @classmethod + def parse_list_issues_from_json(cls, str_json: str) -> List['QodanaIssue']: + return list(map(lambda i: QodanaIssue.from_json(i), json.loads(str_json)[QodanaJsonField.ISSUES.value])) + @unique class QodanaColumnName(Enum): INSPECTIONS = 'inspections' + ID = 'id' + INSPECTION_ID = 'inspection_id' + COUNT_ALL = 'count_all' + COUNT_UNIQUE = 'count_unique' @unique diff --git a/src/python/evaluation/qodana/util/util.py b/src/python/evaluation/qodana/util/util.py new file mode 100644 index 00000000..3766b09d --- /dev/null +++ b/src/python/evaluation/qodana/util/util.py @@ -0,0 +1,51 @@ +import argparse +import json +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.qodana.util.models import QodanaColumnName, QodanaIssue, QodanaJsonField + + +def to_json(issues: List[QodanaIssue]) -> str: + issues_json = { + QodanaJsonField.ISSUES.value: list(map(lambda i: i.to_json(), issues)), + } + return json.dumps(issues_json) + + +# Get a dictionary: Qodana inspection_id -> inspection_id from csv file with two columns: id, inspection_id +def get_inspections_dict(inspections_path: str) -> Dict[str, int]: + inspections_df = pd.read_csv(inspections_path) + inspections_dict = inspections_df.set_index(QodanaColumnName.INSPECTION_ID.value).T.to_dict('list') + for qodana_id, id_list in inspections_dict.items(): + inspections_dict[qodana_id] = id_list[0] + return inspections_dict + + +def replace_inspections_on_its_ids(issues_list: List[QodanaIssue], inspections_dict: Dict[str, int], + to_remove_duplicates: bool) -> str: + if len(issues_list) == 0: + inspections = '0' + else: + problem_id_list = list(map(lambda i: inspections_dict[i.problem_id], issues_list)) + if to_remove_duplicates: + problem_id_list = list(set(problem_id_list)) + problem_id_list.sort() + inspections = ','.join(str(p) for p in problem_id_list) + return inspections + + +def configure_model_converter_arguments(parser: argparse.ArgumentParser) -> None: + parser.add_argument(RunToolArgument.QODANA_SOLUTIONS_FILE_PATH.value.long_name, + type=lambda value: Path(value).absolute(), + help=RunToolArgument.QODANA_SOLUTIONS_FILE_PATH.value.description) + + parser.add_argument(RunToolArgument.QODANA_INSPECTIONS_PATH.value.long_name, + type=lambda value: Path(value).absolute(), + help=RunToolArgument.QODANA_INSPECTIONS_PATH.value.description) + + parser.add_argument(RunToolArgument.QODANA_DUPLICATES.value.long_name, + help=RunToolArgument.QODANA_DUPLICATES.value.description, + action='store_true') diff --git a/src/python/review/common/file_system.py b/src/python/review/common/file_system.py index 42c152a8..2d756804 100644 --- a/src/python/review/common/file_system.py +++ b/src/python/review/common/file_system.py @@ -51,6 +51,13 @@ def all_items_condition(name: str) -> bool: return True +def extension_file_condition(extension: Extension) -> ItemCondition: + def has_this_extension(name: str) -> bool: + return get_extension_from_file(name) == extension + + return has_this_extension + + # To get all files or subdirs (depends on the last parameter) from root that match item_condition # Note that all subdirs or files already contain the full path for them def get_all_file_system_items( @@ -158,7 +165,7 @@ def get_content_from_file(file_path: Path, encoding: str = Encoding.ISO_ENCODING # Not empty extensions are returned with a dot, for example, '.txt' # If file has no extensions, an empty one ('') is returned -def get_extension_from_file(file: Path) -> Extension: +def get_extension_from_file(file: Union[Path, str]) -> Extension: return Extension(os.path.splitext(file)[1]) diff --git a/whitelist.txt b/whitelist.txt index 1b2e8602..b7c7b613 100644 --- a/whitelist.txt +++ b/whitelist.txt @@ -115,3 +115,6 @@ groupby getuid Popen reindex +datasets +usecols +linesep From b70e63cac2b7783b14518f3756aa0b9af1483319 Mon Sep 17 00:00:00 2001 From: Ilya Vlasov Date: Tue, 1 Jun 2021 14:39:28 +0300 Subject: [PATCH 29/73] Move java template to another resource folder --- .../qodana}/project_templates/java/build.gradle | 0 .../java/gradle/wrapper/gradle-wrapper.jar | Bin .../java/gradle/wrapper/gradle-wrapper.properties | 0 .../qodana}/project_templates/java/gradlew | 0 .../qodana}/project_templates/java/gradlew.bat | 0 .../qodana}/project_templates/java/settings.gradle | 0 6 files changed, 0 insertions(+), 0 deletions(-) rename src/{python/evaluation/qodana/resources => resources/evaluation/qodana}/project_templates/java/build.gradle (100%) rename src/{python/evaluation/qodana/resources => resources/evaluation/qodana}/project_templates/java/gradle/wrapper/gradle-wrapper.jar (100%) rename src/{python/evaluation/qodana/resources => resources/evaluation/qodana}/project_templates/java/gradle/wrapper/gradle-wrapper.properties (100%) rename src/{python/evaluation/qodana/resources => resources/evaluation/qodana}/project_templates/java/gradlew (100%) rename src/{python/evaluation/qodana/resources => resources/evaluation/qodana}/project_templates/java/gradlew.bat (100%) rename src/{python/evaluation/qodana/resources => resources/evaluation/qodana}/project_templates/java/settings.gradle (100%) diff --git a/src/python/evaluation/qodana/resources/project_templates/java/build.gradle b/src/resources/evaluation/qodana/project_templates/java/build.gradle similarity index 100% rename from src/python/evaluation/qodana/resources/project_templates/java/build.gradle rename to src/resources/evaluation/qodana/project_templates/java/build.gradle diff --git a/src/python/evaluation/qodana/resources/project_templates/java/gradle/wrapper/gradle-wrapper.jar b/src/resources/evaluation/qodana/project_templates/java/gradle/wrapper/gradle-wrapper.jar similarity index 100% rename from src/python/evaluation/qodana/resources/project_templates/java/gradle/wrapper/gradle-wrapper.jar rename to src/resources/evaluation/qodana/project_templates/java/gradle/wrapper/gradle-wrapper.jar diff --git a/src/python/evaluation/qodana/resources/project_templates/java/gradle/wrapper/gradle-wrapper.properties b/src/resources/evaluation/qodana/project_templates/java/gradle/wrapper/gradle-wrapper.properties similarity index 100% rename from src/python/evaluation/qodana/resources/project_templates/java/gradle/wrapper/gradle-wrapper.properties rename to src/resources/evaluation/qodana/project_templates/java/gradle/wrapper/gradle-wrapper.properties diff --git a/src/python/evaluation/qodana/resources/project_templates/java/gradlew b/src/resources/evaluation/qodana/project_templates/java/gradlew similarity index 100% rename from src/python/evaluation/qodana/resources/project_templates/java/gradlew rename to src/resources/evaluation/qodana/project_templates/java/gradlew diff --git a/src/python/evaluation/qodana/resources/project_templates/java/gradlew.bat b/src/resources/evaluation/qodana/project_templates/java/gradlew.bat similarity index 100% rename from src/python/evaluation/qodana/resources/project_templates/java/gradlew.bat rename to src/resources/evaluation/qodana/project_templates/java/gradlew.bat diff --git a/src/python/evaluation/qodana/resources/project_templates/java/settings.gradle b/src/resources/evaluation/qodana/project_templates/java/settings.gradle similarity index 100% rename from src/python/evaluation/qodana/resources/project_templates/java/settings.gradle rename to src/resources/evaluation/qodana/project_templates/java/settings.gradle From bec4ed6713f04998a1839c9371613fa762f479ef Mon Sep 17 00:00:00 2001 From: Ilya Vlasov Date: Tue, 1 Jun 2021 14:39:48 +0300 Subject: [PATCH 30/73] Change template folder --- src/python/evaluation/qodana/dataset_labeling.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/python/evaluation/qodana/dataset_labeling.py b/src/python/evaluation/qodana/dataset_labeling.py index 12425af0..429f0060 100644 --- a/src/python/evaluation/qodana/dataset_labeling.py +++ b/src/python/evaluation/qodana/dataset_labeling.py @@ -38,7 +38,7 @@ logger = logging.getLogger(__name__) logging.basicConfig(level=logging.INFO) -TEMPLATE_FOLDER = Path(os.path.dirname(os.path.abspath(__file__))) / 'resources' / 'project_templates' +TEMPLATE_FOLDER = Path(__file__).parents[3] / 'resources' / 'evaluation' / 'qodana' / 'project_templates' def configure_arguments(parser: ArgumentParser) -> None: From 7d0f7cefdc8cb2932fb95b490020510957fc7793 Mon Sep 17 00:00:00 2001 From: Ilya Vlasov Date: Tue, 1 Jun 2021 14:40:06 +0300 Subject: [PATCH 31/73] Remove docker package --- requirements-evaluation.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/requirements-evaluation.txt b/requirements-evaluation.txt index 5fe5b7eb..82f0fb4c 100644 --- a/requirements-evaluation.txt +++ b/requirements-evaluation.txt @@ -2,4 +2,3 @@ openpyxl==3.0.7 pandas==1.2.3 pandarallel numpy~=1.20.2 -docker From 1d113d47d6f925c22840e203808064786b748684 Mon Sep 17 00:00:00 2001 From: Ilya Vlasov Date: Thu, 3 Jun 2021 14:29:27 +0300 Subject: [PATCH 32/73] Added plotly requirements --- requirements-evaluation.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/requirements-evaluation.txt b/requirements-evaluation.txt index 82f0fb4c..95528ba3 100644 --- a/requirements-evaluation.txt +++ b/requirements-evaluation.txt @@ -2,3 +2,5 @@ openpyxl==3.0.7 pandas==1.2.3 pandarallel numpy~=1.20.2 +plotly +kaleido From 9f5f3da78ce7f2b4d7a9145a99631cc22c460493 Mon Sep 17 00:00:00 2001 From: Ilya Vlasov Date: Thu, 3 Jun 2021 14:30:25 +0300 Subject: [PATCH 33/73] Added a script for graphing --- src/python/evaluation/plots/__init__.py | 0 src/python/evaluation/plots/diffs_plotter.py | 152 +++++++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 src/python/evaluation/plots/__init__.py create mode 100644 src/python/evaluation/plots/diffs_plotter.py diff --git a/src/python/evaluation/plots/__init__.py b/src/python/evaluation/plots/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/python/evaluation/plots/diffs_plotter.py b/src/python/evaluation/plots/diffs_plotter.py new file mode 100644 index 00000000..da261485 --- /dev/null +++ b/src/python/evaluation/plots/diffs_plotter.py @@ -0,0 +1,152 @@ +import argparse +from pathlib import Path +from statistics import median +from typing import Any, Callable, Dict + +import pandas as pd +import plotly.graph_objects as go + +from src.python.common.tool_arguments import RunToolArgument +from src.python.evaluation.inspectors.common.statistics import IssuesStatistics, PenaltyInfluenceStatistics +from src.python.evaluation.inspectors.print_inspectors_statistics import gather_statistics +from src.python.evaluation.plots.common import create_bar_plot, save_plot, create_box_plot +from src.python.review.common.file_system import deserialize_data_from_file, create_directory + + +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( + "save_dir", + type=lambda value: Path(value).absolute(), + help="The directory where the plotted charts will be saved", + ) + + +def _get_dataframe_from_dict( + data_dict: Dict[Any, Any], + key_name: str, + value_name: str, + key_mapper: Callable = lambda x: x, + value_mapper: Callable = lambda y: y, +): + """ + Converts 'data_dict' to a dataframe consisting of two columns: 'key_name', 'value_name'. + 'key_name' contains all keys of 'data_dict', 'value_name' contains all corresponding + values of 'data_dict'. With the functions 'key_mapper' and 'value_mapper' you can + additionally convert keys and values respectively. + """ + converted_dict = { + key_name: list(map(key_mapper, data_dict.keys())), + value_name: list(map(value_mapper, data_dict.values())), + } + + return pd.DataFrame.from_dict(converted_dict) + + +def get_unique_issues_by_category( + statistics: IssuesStatistics, + x_axis_name: str = "Categories", + y_axis_name: str = "Number of unique issues", + limit: int = 0, +) -> go.Figure: + categorized_statistics = statistics.get_short_categorized_statistics() + filtered_stats = {issue_type: stat[0] for issue_type, stat in categorized_statistics.items() if stat[0] >= limit} + + df = _get_dataframe_from_dict( + filtered_stats, + key_name=x_axis_name, + value_name=y_axis_name, + key_mapper=lambda issue_type: issue_type.name, + ) + + return create_bar_plot(df, x_axis_name, y_axis_name) + + +def get_issues_by_category( + statistics: IssuesStatistics, + x_axis_name: str = "Categories", + y_axis_name: str = "Number of issues", + limit: int = 0, +) -> go.Figure: + categorized_statistics = statistics.get_short_categorized_statistics() + filtered_stats = {issue_type: stat[1] for issue_type, stat in categorized_statistics.items() if stat[1] >= limit} + + df = _get_dataframe_from_dict( + filtered_stats, + key_name=x_axis_name, + value_name=y_axis_name, + key_mapper=lambda issue_type: issue_type.name, + ) + + return create_bar_plot(df, x_axis_name, y_axis_name) + + +def get_median_penalty_influence_by_category( + statistics: PenaltyInfluenceStatistics, + x_axis_name: str = "Categories", + y_axis_name: str = "Penalty influence (%)", + limit: int = 0, +) -> go.Figure: + stat = statistics.stat + filtered_stats = {issue_type: influence for issue_type, influence in stat.items() if median(influence) >= limit} + + df = _get_dataframe_from_dict( + filtered_stats, + key_name=x_axis_name, + value_name=y_axis_name, + key_mapper=lambda issue_type: issue_type.name, + value_mapper=lambda influence: median(influence), + ) + + return create_bar_plot(df, x_axis_name, y_axis_name) + + +def get_penalty_influence_distribution( + statistics: PenaltyInfluenceStatistics, x_axis_name: str = "Categories", y_axis_name: str = "Penalty influence (%)" +): + stat = statistics.stat + + df = _get_dataframe_from_dict( + stat, key_name=x_axis_name, value_name=y_axis_name, key_mapper=lambda issue_type: issue_type.name + ) + df = df.explode(y_axis_name) + + return create_box_plot(df, x_axis_name, y_axis_name) + + +def main(): + parser = argparse.ArgumentParser() + configure_arguments(parser) + args = parser.parse_args() + + diffs = deserialize_data_from_file(args.diffs_file_path) + statistics = gather_statistics(diffs) + + create_directory(args.save_dir) + + plot = get_unique_issues_by_category(statistics.new_issues_stat) + save_plot(plot, args.save_dir, plot_name="unique-issues-by-category") + + plot = get_issues_by_category(statistics.new_issues_stat) + save_plot(plot, args.save_dir, plot_name="issues-by-category") + + plot = get_unique_issues_by_category(statistics.penalty_issues_stat, y_axis_name="Number of unique penalty issues") + save_plot(plot, args.save_dir, plot_name="unique-penalty-issues-by-category") + + plot = get_issues_by_category(statistics.penalty_issues_stat, y_axis_name="Number of penalty issues") + save_plot(plot, args.save_dir, plot_name="penalty-issues-by-category") + + plot = get_median_penalty_influence_by_category(statistics.penalty_influence_stat) + save_plot(plot, args.save_dir, plot_name="median-penalty-influence-by-category") + + plot = get_penalty_influence_distribution(statistics.penalty_influence_stat) + save_plot(plot, args.save_dir, plot_name="penalty_influence_distribution") + + +if __name__ == "__main__": + main() From a2f6f6c542b9f9adcd175785b1f9976f953f9839 Mon Sep 17 00:00:00 2001 From: Ilya Vlasov Date: Thu, 3 Jun 2021 14:30:37 +0300 Subject: [PATCH 34/73] Added common --- src/python/evaluation/plots/common.py | 46 +++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 src/python/evaluation/plots/common.py diff --git a/src/python/evaluation/plots/common.py b/src/python/evaluation/plots/common.py new file mode 100644 index 00000000..9b01e424 --- /dev/null +++ b/src/python/evaluation/plots/common.py @@ -0,0 +1,46 @@ +from enum import Enum +from pathlib import Path + +import pandas as pd +import plotly.express as px +import plotly.graph_objects as go + +from src.python.review.common.file_system import create_directory + + +class Extension(Enum): + PNG = '.png' + JPG = '.jpg' + JPEG = '.jpeg' + WEBP = '.webp' + SVG = '.svg' + PDF = '.pdf' + EPS = '.eps' + JSON = '.json' + + +def create_bar_plot(df: pd.DataFrame, x_axis: str, y_axis: str) -> go.Figure: + fig = px.bar(df, x=x_axis, y=y_axis, text=y_axis) + + fig.update_layout( + xaxis={"categoryorder": "total descending"}, + margin=dict(l=0, r=0, b=0, t=0), + ) + + return fig + + +def create_box_plot(df: pd.DataFrame, x_axis: str, y_axis: str) -> go.Figure: + fig = px.box(df, x=x_axis, y=y_axis) + + fig.update_layout(margin=dict(l=0, r=0, b=0, t=0)) + + return fig + + +def save_plot( + fig: go.Figure, dir_path: Path, plot_name: str = "result_plot", extension: Extension = Extension.SVG +) -> None: + create_directory(dir_path) + file = dir_path / f"{plot_name}{extension.value}" + fig.write_image(str(file)) From 0d36a13f6b9ddc64f2dfb3acb531240179d1e028 Mon Sep 17 00:00:00 2001 From: Ilya Vlasov Date: Thu, 3 Jun 2021 14:37:11 +0300 Subject: [PATCH 35/73] Fix flake8 issues --- src/python/evaluation/plots/common.py | 7 +++---- src/python/evaluation/plots/diffs_plotter.py | 9 ++++----- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/src/python/evaluation/plots/common.py b/src/python/evaluation/plots/common.py index 9b01e424..62b1832d 100644 --- a/src/python/evaluation/plots/common.py +++ b/src/python/evaluation/plots/common.py @@ -4,7 +4,6 @@ import pandas as pd import plotly.express as px import plotly.graph_objects as go - from src.python.review.common.file_system import create_directory @@ -24,7 +23,7 @@ def create_bar_plot(df: pd.DataFrame, x_axis: str, y_axis: str) -> go.Figure: fig.update_layout( xaxis={"categoryorder": "total descending"}, - margin=dict(l=0, r=0, b=0, t=0), + margin={"l": 0, "r": 0, "b": 0, "t": 0}, ) return fig @@ -33,13 +32,13 @@ def create_bar_plot(df: pd.DataFrame, x_axis: str, y_axis: str) -> go.Figure: def create_box_plot(df: pd.DataFrame, x_axis: str, y_axis: str) -> go.Figure: fig = px.box(df, x=x_axis, y=y_axis) - fig.update_layout(margin=dict(l=0, r=0, b=0, t=0)) + fig.update_layout(margin={"l": 0, "r": 0, "b": 0, "t": 0}) return fig def save_plot( - fig: go.Figure, dir_path: Path, plot_name: str = "result_plot", extension: Extension = Extension.SVG + fig: go.Figure, dir_path: Path, plot_name: str = "result_plot", extension: Extension = Extension.SVG, ) -> None: create_directory(dir_path) file = dir_path / f"{plot_name}{extension.value}" diff --git a/src/python/evaluation/plots/diffs_plotter.py b/src/python/evaluation/plots/diffs_plotter.py index da261485..c48be918 100644 --- a/src/python/evaluation/plots/diffs_plotter.py +++ b/src/python/evaluation/plots/diffs_plotter.py @@ -5,12 +5,11 @@ import pandas as pd import plotly.graph_objects as go - from src.python.common.tool_arguments import RunToolArgument from src.python.evaluation.inspectors.common.statistics import IssuesStatistics, PenaltyInfluenceStatistics from src.python.evaluation.inspectors.print_inspectors_statistics import gather_statistics -from src.python.evaluation.plots.common import create_bar_plot, save_plot, create_box_plot -from src.python.review.common.file_system import deserialize_data_from_file, create_directory +from src.python.evaluation.plots.common import create_bar_plot, create_box_plot, save_plot +from src.python.review.common.file_system import create_directory, deserialize_data_from_file def configure_arguments(parser: argparse.ArgumentParser) -> None: @@ -107,12 +106,12 @@ def get_median_penalty_influence_by_category( def get_penalty_influence_distribution( - statistics: PenaltyInfluenceStatistics, x_axis_name: str = "Categories", y_axis_name: str = "Penalty influence (%)" + statistics: PenaltyInfluenceStatistics, x_axis_name: str = "Categories", y_axis_name: str = "Penalty influence (%)", ): stat = statistics.stat df = _get_dataframe_from_dict( - stat, key_name=x_axis_name, value_name=y_axis_name, key_mapper=lambda issue_type: issue_type.name + stat, key_name=x_axis_name, value_name=y_axis_name, key_mapper=lambda issue_type: issue_type.name, ) df = df.explode(y_axis_name) From 01758e5e9850a1e13a9e99c2aea4b8fdf5bbcaae Mon Sep 17 00:00:00 2001 From: Ilya Vlasov Date: Thu, 3 Jun 2021 14:37:32 +0300 Subject: [PATCH 36/73] Add new words --- whitelist.txt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/whitelist.txt b/whitelist.txt index b7c7b613..585b154b 100644 --- a/whitelist.txt +++ b/whitelist.txt @@ -118,3 +118,9 @@ reindex datasets usecols linesep +plotly +JPG +WEBP +SVG +EPS +xaxis From 8f4ab333712c26cc9dc03fca49520d07a363173e Mon Sep 17 00:00:00 2001 From: Ilya Vlasov Date: Thu, 3 Jun 2021 15:05:21 +0300 Subject: [PATCH 37/73] Added ability to select output file extension --- src/python/evaluation/plots/diffs_plotter.py | 38 ++++++++++++++------ 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/src/python/evaluation/plots/diffs_plotter.py b/src/python/evaluation/plots/diffs_plotter.py index c48be918..9d27a19b 100644 --- a/src/python/evaluation/plots/diffs_plotter.py +++ b/src/python/evaluation/plots/diffs_plotter.py @@ -1,15 +1,18 @@ import argparse +import sys from pathlib import Path from statistics import median from typing import Any, Callable, Dict +sys.path.append('../../../..') + import pandas as pd import plotly.graph_objects as go from src.python.common.tool_arguments import RunToolArgument from src.python.evaluation.inspectors.common.statistics import IssuesStatistics, PenaltyInfluenceStatistics from src.python.evaluation.inspectors.print_inspectors_statistics import gather_statistics -from src.python.evaluation.plots.common import create_bar_plot, create_box_plot, save_plot -from src.python.review.common.file_system import create_directory, deserialize_data_from_file +from src.python.evaluation.plots.common import create_bar_plot, create_box_plot, Extension, save_plot +from src.python.review.common.file_system import deserialize_data_from_file def configure_arguments(parser: argparse.ArgumentParser) -> None: @@ -25,6 +28,14 @@ def configure_arguments(parser: argparse.ArgumentParser) -> None: help="The directory where the plotted charts will be saved", ) + parser.add_argument( + "--file-extension", + type=str, + default=Extension.SVG.value, + choices=Extension.values(), + help="Allows you to select the extension of output files", + ) + def _get_dataframe_from_dict( data_dict: Dict[Any, Any], @@ -106,12 +117,17 @@ def get_median_penalty_influence_by_category( def get_penalty_influence_distribution( - statistics: PenaltyInfluenceStatistics, x_axis_name: str = "Categories", y_axis_name: str = "Penalty influence (%)", + statistics: PenaltyInfluenceStatistics, + x_axis_name: str = "Categories", + y_axis_name: str = "Penalty influence (%)", ): stat = statistics.stat df = _get_dataframe_from_dict( - stat, key_name=x_axis_name, value_name=y_axis_name, key_mapper=lambda issue_type: issue_type.name, + stat, + key_name=x_axis_name, + value_name=y_axis_name, + key_mapper=lambda issue_type: issue_type.name, ) df = df.explode(y_axis_name) @@ -126,25 +142,25 @@ def main(): diffs = deserialize_data_from_file(args.diffs_file_path) statistics = gather_statistics(diffs) - create_directory(args.save_dir) + extension = Extension(args.file_extension) plot = get_unique_issues_by_category(statistics.new_issues_stat) - save_plot(plot, args.save_dir, plot_name="unique-issues-by-category") + save_plot(plot, args.save_dir, plot_name="unique-issues-by-category", extension=extension) plot = get_issues_by_category(statistics.new_issues_stat) - save_plot(plot, args.save_dir, plot_name="issues-by-category") + save_plot(plot, args.save_dir, plot_name="issues-by-category", extension=extension) plot = get_unique_issues_by_category(statistics.penalty_issues_stat, y_axis_name="Number of unique penalty issues") - save_plot(plot, args.save_dir, plot_name="unique-penalty-issues-by-category") + save_plot(plot, args.save_dir, plot_name="unique-penalty-issues-by-category", extension=extension) plot = get_issues_by_category(statistics.penalty_issues_stat, y_axis_name="Number of penalty issues") - save_plot(plot, args.save_dir, plot_name="penalty-issues-by-category") + save_plot(plot, args.save_dir, plot_name="penalty-issues-by-category", extension=extension) plot = get_median_penalty_influence_by_category(statistics.penalty_influence_stat) - save_plot(plot, args.save_dir, plot_name="median-penalty-influence-by-category") + save_plot(plot, args.save_dir, plot_name="median-penalty-influence-by-category", extension=extension) plot = get_penalty_influence_distribution(statistics.penalty_influence_stat) - save_plot(plot, args.save_dir, plot_name="penalty_influence_distribution") + save_plot(plot, args.save_dir, plot_name="penalty_influence_distribution", extension=extension) if __name__ == "__main__": From 1580242b5a3e6bb4c7c44749ed9b313467d7bd92 Mon Sep 17 00:00:00 2001 From: Ilya Vlasov Date: Thu, 3 Jun 2021 15:06:03 +0300 Subject: [PATCH 38/73] Added 'values' method to Extension --- src/python/evaluation/plots/common.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/python/evaluation/plots/common.py b/src/python/evaluation/plots/common.py index 62b1832d..02613a76 100644 --- a/src/python/evaluation/plots/common.py +++ b/src/python/evaluation/plots/common.py @@ -1,5 +1,6 @@ from enum import Enum from pathlib import Path +from typing import List import pandas as pd import plotly.express as px @@ -17,6 +18,10 @@ class Extension(Enum): EPS = '.eps' JSON = '.json' + @classmethod + def values(cls) -> List[str]: + return [member.value for member in Extension] + def create_bar_plot(df: pd.DataFrame, x_axis: str, y_axis: str) -> go.Figure: fig = px.bar(df, x=x_axis, y=y_axis, text=y_axis) From 35e0f725cec671c491fa6353279b3c4d3ac95a52 Mon Sep 17 00:00:00 2001 From: "Anastasiia.Birillo" Date: Fri, 4 Jun 2021 12:02:36 +0300 Subject: [PATCH 39/73] Fix merge conflicts --- src/python/evaluation/plots/common.py | 4 ++-- whitelist.txt | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/python/evaluation/plots/common.py b/src/python/evaluation/plots/common.py index 02613a76..dde60d53 100644 --- a/src/python/evaluation/plots/common.py +++ b/src/python/evaluation/plots/common.py @@ -1,3 +1,4 @@ +import os from enum import Enum from pathlib import Path from typing import List @@ -5,7 +6,6 @@ import pandas as pd import plotly.express as px import plotly.graph_objects as go -from src.python.review.common.file_system import create_directory class Extension(Enum): @@ -45,6 +45,6 @@ def create_box_plot(df: pd.DataFrame, x_axis: str, y_axis: str) -> go.Figure: def save_plot( fig: go.Figure, dir_path: Path, plot_name: str = "result_plot", extension: Extension = Extension.SVG, ) -> None: - create_directory(dir_path) + os.makedirs(dir_path, exist_ok=True) file = dir_path / f"{plot_name}{extension.value}" fig.write_image(str(file)) diff --git a/whitelist.txt b/whitelist.txt index cbf8344b..941a5665 100644 --- a/whitelist.txt +++ b/whitelist.txt @@ -150,4 +150,5 @@ idx QodanaDataset cuda f1 -WANDB \ No newline at end of file +WANDB +PNG From 348470af690d92d670a20c70bbfb32f355183c83 Mon Sep 17 00:00:00 2001 From: Ilya Vlasov Date: Fri, 4 Jun 2021 16:17:00 +0300 Subject: [PATCH 40/73] Fixed PR issues --- src/python/evaluation/plots/common.py | 43 ++++++++++++++------------- 1 file changed, 23 insertions(+), 20 deletions(-) diff --git a/src/python/evaluation/plots/common.py b/src/python/evaluation/plots/common.py index dde60d53..2a8159db 100644 --- a/src/python/evaluation/plots/common.py +++ b/src/python/evaluation/plots/common.py @@ -1,49 +1,52 @@ import os -from enum import Enum from pathlib import Path from typing import List import pandas as pd import plotly.express as px import plotly.graph_objects as go +from src.python.evaluation.plots import plotly_consts +from src.python.review.common.file_system import Extension -class Extension(Enum): - PNG = '.png' - JPG = '.jpg' - JPEG = '.jpeg' - WEBP = '.webp' - SVG = '.svg' - PDF = '.pdf' - EPS = '.eps' - JSON = '.json' +def get_supported_image_extensions() -> List[str]: + extensions = Extension.get_image_extensions() + extensions.append(Extension.JSON) + return [extension.value for extension in extensions] - @classmethod - def values(cls) -> List[str]: - return [member.value for member in Extension] - -def create_bar_plot(df: pd.DataFrame, x_axis: str, y_axis: str) -> go.Figure: +def create_bar_plot( + df: pd.DataFrame, + x_axis: str, + y_axis: str, + margin: plotly_consts.MARGIN = plotly_consts.MARGIN.ZERO, + sort_order: plotly_consts.SORT_ORDER = plotly_consts.SORT_ORDER.TOTAL_DESCENDING, +) -> go.Figure: fig = px.bar(df, x=x_axis, y=y_axis, text=y_axis) fig.update_layout( - xaxis={"categoryorder": "total descending"}, - margin={"l": 0, "r": 0, "b": 0, "t": 0}, + xaxis={'categoryorder': sort_order}, + margin=margin, ) return fig -def create_box_plot(df: pd.DataFrame, x_axis: str, y_axis: str) -> go.Figure: +def create_box_plot( + df: pd.DataFrame, x_axis: str, y_axis: str, margin: plotly_consts.MARGIN = plotly_consts.MARGIN.ZERO, +) -> go.Figure: fig = px.box(df, x=x_axis, y=y_axis) - fig.update_layout(margin={"l": 0, "r": 0, "b": 0, "t": 0}) + fig.update_layout(margin=margin) return fig def save_plot( - fig: go.Figure, dir_path: Path, plot_name: str = "result_plot", extension: Extension = Extension.SVG, + fig: go.Figure, + dir_path: Path, + plot_name: str = "result_plot", + extension: Extension = Extension.SVG, ) -> None: os.makedirs(dir_path, exist_ok=True) file = dir_path / f"{plot_name}{extension.value}" From db0cc67a074d9ad82983bf7564d418d563d69987 Mon Sep 17 00:00:00 2001 From: Ilya Vlasov Date: Fri, 4 Jun 2021 16:17:38 +0300 Subject: [PATCH 41/73] Fixed double quotes --- src/python/evaluation/plots/diffs_plotter.py | 52 +++++++++++--------- 1 file changed, 29 insertions(+), 23 deletions(-) diff --git a/src/python/evaluation/plots/diffs_plotter.py b/src/python/evaluation/plots/diffs_plotter.py index 9d27a19b..cfca58fd 100644 --- a/src/python/evaluation/plots/diffs_plotter.py +++ b/src/python/evaluation/plots/diffs_plotter.py @@ -11,7 +11,13 @@ from src.python.common.tool_arguments import RunToolArgument from src.python.evaluation.inspectors.common.statistics import IssuesStatistics, PenaltyInfluenceStatistics from src.python.evaluation.inspectors.print_inspectors_statistics import gather_statistics -from src.python.evaluation.plots.common import create_bar_plot, create_box_plot, Extension, save_plot +from src.python.evaluation.plots.common import ( + create_bar_plot, + create_box_plot, + Extension, + get_supported_image_extensions, + save_plot, +) from src.python.review.common.file_system import deserialize_data_from_file @@ -23,17 +29,17 @@ def configure_arguments(parser: argparse.ArgumentParser) -> None: ) parser.add_argument( - "save_dir", + 'save_dir', type=lambda value: Path(value).absolute(), - help="The directory where the plotted charts will be saved", + help='The directory where the plotted charts will be saved', ) parser.add_argument( - "--file-extension", + '--file-extension', type=str, default=Extension.SVG.value, - choices=Extension.values(), - help="Allows you to select the extension of output files", + choices=get_supported_image_extensions(), + help='Allows you to select the extension of output files', ) @@ -60,8 +66,8 @@ def _get_dataframe_from_dict( def get_unique_issues_by_category( statistics: IssuesStatistics, - x_axis_name: str = "Categories", - y_axis_name: str = "Number of unique issues", + x_axis_name: str = 'Categories', + y_axis_name: str = 'Number of unique issues', limit: int = 0, ) -> go.Figure: categorized_statistics = statistics.get_short_categorized_statistics() @@ -79,8 +85,8 @@ def get_unique_issues_by_category( def get_issues_by_category( statistics: IssuesStatistics, - x_axis_name: str = "Categories", - y_axis_name: str = "Number of issues", + x_axis_name: str = 'Categories', + y_axis_name: str = 'Number of issues', limit: int = 0, ) -> go.Figure: categorized_statistics = statistics.get_short_categorized_statistics() @@ -98,8 +104,8 @@ def get_issues_by_category( def get_median_penalty_influence_by_category( statistics: PenaltyInfluenceStatistics, - x_axis_name: str = "Categories", - y_axis_name: str = "Penalty influence (%)", + x_axis_name: str = 'Categories', + y_axis_name: str = 'Penalty influence (%)', limit: int = 0, ) -> go.Figure: stat = statistics.stat @@ -118,8 +124,8 @@ def get_median_penalty_influence_by_category( def get_penalty_influence_distribution( statistics: PenaltyInfluenceStatistics, - x_axis_name: str = "Categories", - y_axis_name: str = "Penalty influence (%)", + x_axis_name: str = 'Categories', + y_axis_name: str = 'Penalty influence (%)', ): stat = statistics.stat @@ -145,23 +151,23 @@ def main(): extension = Extension(args.file_extension) plot = get_unique_issues_by_category(statistics.new_issues_stat) - save_plot(plot, args.save_dir, plot_name="unique-issues-by-category", extension=extension) + save_plot(plot, args.save_dir, plot_name='unique-issues-by-category', extension=extension) plot = get_issues_by_category(statistics.new_issues_stat) - save_plot(plot, args.save_dir, plot_name="issues-by-category", extension=extension) + save_plot(plot, args.save_dir, plot_name='issues-by-category', extension=extension) - plot = get_unique_issues_by_category(statistics.penalty_issues_stat, y_axis_name="Number of unique penalty issues") - save_plot(plot, args.save_dir, plot_name="unique-penalty-issues-by-category", extension=extension) + plot = get_unique_issues_by_category(statistics.penalty_issues_stat, y_axis_name='Number of unique penalty issues') + save_plot(plot, args.save_dir, plot_name='unique-penalty-issues-by-category', extension=extension) - plot = get_issues_by_category(statistics.penalty_issues_stat, y_axis_name="Number of penalty issues") - save_plot(plot, args.save_dir, plot_name="penalty-issues-by-category", extension=extension) + plot = get_issues_by_category(statistics.penalty_issues_stat, y_axis_name='Number of penalty issues') + save_plot(plot, args.save_dir, plot_name='penalty-issues-by-category', extension=extension) plot = get_median_penalty_influence_by_category(statistics.penalty_influence_stat) - save_plot(plot, args.save_dir, plot_name="median-penalty-influence-by-category", extension=extension) + save_plot(plot, args.save_dir, plot_name='median-penalty-influence-by-category', extension=extension) plot = get_penalty_influence_distribution(statistics.penalty_influence_stat) - save_plot(plot, args.save_dir, plot_name="penalty_influence_distribution", extension=extension) + save_plot(plot, args.save_dir, plot_name='penalty_influence_distribution', extension=extension) -if __name__ == "__main__": +if __name__ == '__main__': main() From 4ee00e195d5f80d604a285867be7ca22aa15d81b Mon Sep 17 00:00:00 2001 From: Ilya Vlasov Date: Fri, 4 Jun 2021 16:19:19 +0300 Subject: [PATCH 42/73] Added image extensions --- src/python/review/common/file_system.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/python/review/common/file_system.py b/src/python/review/common/file_system.py index 802ccf83..c8941f5f 100644 --- a/src/python/review/common/file_system.py +++ b/src/python/review/common/file_system.py @@ -37,12 +37,33 @@ class Extension(Enum): PICKLE = '.pickle' JSON = '.json' + # Image extensions + PNG = '.png' + JPG = '.jpg' + JPEG = '.jpeg' + WEBP = '.webp' + SVG = '.svg' + PDF = '.pdf' + EPS = '.eps' + # 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]) + @classmethod + def get_image_extensions(cls) -> List['Extension']: + return [ + Extension.PNG, + Extension.JPG, + Extension.JPEG, + Extension.WEBP, + Extension.SVG, + Extension.PDF, + Extension.EPS, + ] + ItemCondition = Callable[[str], bool] From 81977abf83da2be7854bdea5ff6d9a99558d7cad Mon Sep 17 00:00:00 2001 From: Ilya Vlasov Date: Fri, 4 Jun 2021 16:19:29 +0300 Subject: [PATCH 43/73] Added 'consts' --- whitelist.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/whitelist.txt b/whitelist.txt index 941a5665..cf21e35f 100644 --- a/whitelist.txt +++ b/whitelist.txt @@ -152,3 +152,4 @@ cuda f1 WANDB PNG +consts From 005975bbb777fcd8170e09b0582af4d2520d7a50 Mon Sep 17 00:00:00 2001 From: Ilya Vlasov Date: Fri, 4 Jun 2021 16:19:47 +0300 Subject: [PATCH 44/73] Added plotly_consts.py --- src/python/evaluation/plots/plotly_consts.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 src/python/evaluation/plots/plotly_consts.py diff --git a/src/python/evaluation/plots/plotly_consts.py b/src/python/evaluation/plots/plotly_consts.py new file mode 100644 index 00000000..347fdb0c --- /dev/null +++ b/src/python/evaluation/plots/plotly_consts.py @@ -0,0 +1,13 @@ +from enum import Enum + + +class MARGIN(Enum): + ZERO = {'l': 0, 'r': 0, 'b': 0, 't': 0} + DEFAULT = {} + + +class SORT_ORDER(Enum): # noqa: N801 + CATEGORY_ASCENDING = 'category ascending' + CATEGORY_DESCENDING = 'category descending' + TOTAL_ASCENDING = 'total ascending' + TOTAL_DESCENDING = 'total descending' From ed2b9fccf687ac196601c9d90c7d4c5d471009ae Mon Sep 17 00:00:00 2001 From: Ilya Vlasov Date: Sat, 5 Jun 2021 12:28:31 +0300 Subject: [PATCH 45/73] Added parse_yaml function --- src/python/review/common/file_system.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/python/review/common/file_system.py b/src/python/review/common/file_system.py index c8941f5f..bbdeeda6 100644 --- a/src/python/review/common/file_system.py +++ b/src/python/review/common/file_system.py @@ -9,6 +9,8 @@ from pathlib import Path from typing import Any, Callable, List, Optional, Tuple, Union +import yaml + @unique class FileSystemItem(Enum): @@ -121,6 +123,11 @@ def deserialize_data_from_file(path: Path) -> Any: return u.load() +def parse_yaml(path: Union[Path, str]) -> Any: + with open(path) as file: + return yaml.safe_load(file) + + # 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: Union[Path, str], with_extension: bool = True) -> str: From c096d4e49cdf9b22e367118504085cc5e6fa50f8 Mon Sep 17 00:00:00 2001 From: Ilya Vlasov Date: Sat, 5 Jun 2021 12:28:46 +0300 Subject: [PATCH 46/73] Added pyyaml --- requirements-evaluation.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements-evaluation.txt b/requirements-evaluation.txt index 95528ba3..413e363b 100644 --- a/requirements-evaluation.txt +++ b/requirements-evaluation.txt @@ -4,3 +4,4 @@ pandarallel numpy~=1.20.2 plotly kaleido +pyyaml From 75ba546bedab2d461d5efb3b2362ccc2c294af36 Mon Sep 17 00:00:00 2001 From: Ilya Vlasov Date: Sat, 5 Jun 2021 12:29:27 +0300 Subject: [PATCH 47/73] Removed DEFAULT enum type from MARGIN --- src/python/evaluation/plots/{ => common}/plotly_consts.py | 1 - 1 file changed, 1 deletion(-) rename src/python/evaluation/plots/{ => common}/plotly_consts.py (94%) diff --git a/src/python/evaluation/plots/plotly_consts.py b/src/python/evaluation/plots/common/plotly_consts.py similarity index 94% rename from src/python/evaluation/plots/plotly_consts.py rename to src/python/evaluation/plots/common/plotly_consts.py index 347fdb0c..209ae4e0 100644 --- a/src/python/evaluation/plots/plotly_consts.py +++ b/src/python/evaluation/plots/common/plotly_consts.py @@ -3,7 +3,6 @@ class MARGIN(Enum): ZERO = {'l': 0, 'r': 0, 'b': 0, 't': 0} - DEFAULT = {} class SORT_ORDER(Enum): # noqa: N801 From 151ce8b2db58b93a3a646ceecd55f0740ade47d6 Mon Sep 17 00:00:00 2001 From: Ilya Vlasov Date: Sat, 5 Jun 2021 12:31:06 +0300 Subject: [PATCH 48/73] Added update_layout function --- .../plots/{common.py => common/utils.py} | 36 +++++++++++++------ 1 file changed, 26 insertions(+), 10 deletions(-) rename src/python/evaluation/plots/{common.py => common/utils.py} (53%) diff --git a/src/python/evaluation/plots/common.py b/src/python/evaluation/plots/common/utils.py similarity index 53% rename from src/python/evaluation/plots/common.py rename to src/python/evaluation/plots/common/utils.py index 2a8159db..8e02896f 100644 --- a/src/python/evaluation/plots/common.py +++ b/src/python/evaluation/plots/common/utils.py @@ -1,11 +1,11 @@ import os from pathlib import Path -from typing import List +from typing import List, Optional import pandas as pd import plotly.express as px import plotly.graph_objects as go -from src.python.evaluation.plots import plotly_consts +from src.python.evaluation.plots.common import plotly_consts from src.python.review.common.file_system import Extension @@ -19,29 +19,45 @@ def create_bar_plot( df: pd.DataFrame, x_axis: str, y_axis: str, - margin: plotly_consts.MARGIN = plotly_consts.MARGIN.ZERO, - sort_order: plotly_consts.SORT_ORDER = plotly_consts.SORT_ORDER.TOTAL_DESCENDING, + margin: Optional[plotly_consts.MARGIN] = None, + sort_order: Optional[plotly_consts.SORT_ORDER] = None, ) -> go.Figure: fig = px.bar(df, x=x_axis, y=y_axis, text=y_axis) - fig.update_layout( - xaxis={'categoryorder': sort_order}, - margin=margin, - ) + update_layout(fig, margin, sort_order) return fig def create_box_plot( - df: pd.DataFrame, x_axis: str, y_axis: str, margin: plotly_consts.MARGIN = plotly_consts.MARGIN.ZERO, + df: pd.DataFrame, + x_axis: str, + y_axis: str, + margin: Optional[plotly_consts.MARGIN] = None, ) -> go.Figure: fig = px.box(df, x=x_axis, y=y_axis) - fig.update_layout(margin=margin) + update_layout(fig, margin) # TODO: sort_order ? return fig +def update_layout( + fig: go.Figure, + margin: Optional[plotly_consts.MARGIN] = None, + sort_order: Optional[plotly_consts.SORT_ORDER] = None, +) -> None: + new_layout = {} + + if margin is not None: + new_layout["margin"] = margin.value + + if sort_order is not None: + new_layout["xaxis"] = {"categoryorder": sort_order.value} + + fig.update_layout(**new_layout) + + def save_plot( fig: go.Figure, dir_path: Path, From c94b3822e61e46bdf122e1aba01d2b04b92334e2 Mon Sep 17 00:00:00 2001 From: Ilya Vlasov Date: Sat, 5 Jun 2021 12:32:03 +0300 Subject: [PATCH 49/73] Fixed PR issues --- src/python/evaluation/plots/diffs_plotter.py | 218 +++++++++---------- 1 file changed, 102 insertions(+), 116 deletions(-) diff --git a/src/python/evaluation/plots/diffs_plotter.py b/src/python/evaluation/plots/diffs_plotter.py index cfca58fd..3701dd14 100644 --- a/src/python/evaluation/plots/diffs_plotter.py +++ b/src/python/evaluation/plots/diffs_plotter.py @@ -1,24 +1,74 @@ import argparse import sys +from enum import Enum, unique from pathlib import Path -from statistics import median -from typing import Any, Callable, Dict +from typing import Any, Callable, Dict, Union sys.path.append('../../../..') -import pandas as pd import plotly.graph_objects as go from src.python.common.tool_arguments import RunToolArgument -from src.python.evaluation.inspectors.common.statistics import IssuesStatistics, PenaltyInfluenceStatistics +from src.python.evaluation.inspectors.common.statistics import ( + GeneralInspectorsStatistics, + IssuesStatistics, + PenaltyInfluenceStatistics, +) from src.python.evaluation.inspectors.print_inspectors_statistics import gather_statistics -from src.python.evaluation.plots.common import ( - create_bar_plot, - create_box_plot, - Extension, - get_supported_image_extensions, - save_plot, +from src.python.evaluation.plots.common import plotly_consts +from src.python.evaluation.plots.common.plotters import ( + get_issues_by_category, + get_median_penalty_influence_by_category, + get_penalty_influence_distribution, + get_unique_issues_by_category, ) -from src.python.review.common.file_system import deserialize_data_from_file +from src.python.evaluation.plots.common.utils import get_supported_image_extensions, save_plot +from src.python.review.common.file_system import deserialize_data_from_file, Extension, parse_yaml + + +@unique +class ConfigFields(Enum): + X_AXIS_NAME = 'x_axis_name' + Y_AXIS_NAME = 'y_axis_name' + LIMIT = 'limit' + MARGIN = 'margin' + SORT_ORDER = 'sort_order' + + +@unique +class PlotTypes(Enum): + UNIQUE_ISSUES_BY_CATEGORY = 'unique_issues_by_category' + ISSUES_BY_CATEGORY = 'issues_by_category' + UNIQUE_PENALTY_ISSUES_BY_CATEGORY = 'unique_penalty_issues_by_category' + PENALTY_ISSUES_BY_CATEGORY = 'penalty_issues_by_category' + MEDIAN_PENALTY_INFLUENCE_BY_CATEGORY = 'median_penalty_influence_by_category' + PENALTY_INFLUENCE_DISTRIBUTION = 'penalty_influence_distribution' + + def to_plotter_function(self) -> Callable[..., go.Figure]: + type_to_function = { + PlotTypes.UNIQUE_ISSUES_BY_CATEGORY: get_unique_issues_by_category, + PlotTypes.ISSUES_BY_CATEGORY: get_issues_by_category, + PlotTypes.UNIQUE_PENALTY_ISSUES_BY_CATEGORY: get_unique_issues_by_category, + PlotTypes.PENALTY_ISSUES_BY_CATEGORY: get_issues_by_category, + PlotTypes.MEDIAN_PENALTY_INFLUENCE_BY_CATEGORY: get_median_penalty_influence_by_category, + PlotTypes.PENALTY_INFLUENCE_DISTRIBUTION: get_penalty_influence_distribution, + } + + return type_to_function[self] + + def extract_statistics( + self, + statistics: GeneralInspectorsStatistics, + ) -> Union[IssuesStatistics, PenaltyInfluenceStatistics]: + type_to_statistics = { + PlotTypes.UNIQUE_ISSUES_BY_CATEGORY: statistics.new_issues_stat, + PlotTypes.ISSUES_BY_CATEGORY: statistics.new_issues_stat, + PlotTypes.UNIQUE_PENALTY_ISSUES_BY_CATEGORY: statistics.penalty_issues_stat, + PlotTypes.PENALTY_ISSUES_BY_CATEGORY: statistics.penalty_issues_stat, + PlotTypes.MEDIAN_PENALTY_INFLUENCE_BY_CATEGORY: statistics.penalty_influence_stat, + PlotTypes.PENALTY_INFLUENCE_DISTRIBUTION: statistics.penalty_influence_stat, + } + + return type_to_statistics[self] def configure_arguments(parser: argparse.ArgumentParser) -> None: @@ -34,6 +84,12 @@ def configure_arguments(parser: argparse.ArgumentParser) -> None: help='The directory where the plotted charts will be saved', ) + parser.add_argument( + 'config_path', + type=lambda value: Path(value).absolute(), + help='Path to the yaml file containing information about the graphs to be plotted.', + ) + parser.add_argument( '--file-extension', type=str, @@ -43,130 +99,60 @@ def configure_arguments(parser: argparse.ArgumentParser) -> None: ) -def _get_dataframe_from_dict( - data_dict: Dict[Any, Any], - key_name: str, - value_name: str, - key_mapper: Callable = lambda x: x, - value_mapper: Callable = lambda y: y, -): - """ - Converts 'data_dict' to a dataframe consisting of two columns: 'key_name', 'value_name'. - 'key_name' contains all keys of 'data_dict', 'value_name' contains all corresponding - values of 'data_dict'. With the functions 'key_mapper' and 'value_mapper' you can - additionally convert keys and values respectively. - """ - converted_dict = { - key_name: list(map(key_mapper, data_dict.keys())), - value_name: list(map(value_mapper, data_dict.values())), - } - - return pd.DataFrame.from_dict(converted_dict) - - -def get_unique_issues_by_category( - statistics: IssuesStatistics, - x_axis_name: str = 'Categories', - y_axis_name: str = 'Number of unique issues', - limit: int = 0, -) -> go.Figure: - categorized_statistics = statistics.get_short_categorized_statistics() - filtered_stats = {issue_type: stat[0] for issue_type, stat in categorized_statistics.items() if stat[0] >= limit} - - df = _get_dataframe_from_dict( - filtered_stats, - key_name=x_axis_name, - value_name=y_axis_name, - key_mapper=lambda issue_type: issue_type.name, - ) - - return create_bar_plot(df, x_axis_name, y_axis_name) - - -def get_issues_by_category( - statistics: IssuesStatistics, - x_axis_name: str = 'Categories', - y_axis_name: str = 'Number of issues', - limit: int = 0, -) -> go.Figure: - categorized_statistics = statistics.get_short_categorized_statistics() - filtered_stats = {issue_type: stat[1] for issue_type, stat in categorized_statistics.items() if stat[1] >= limit} +def get_plot_params(config: Dict, plot_type: PlotTypes) -> Dict[str, Any]: + config_params = config.get(plot_type.value) + params = {} - df = _get_dataframe_from_dict( - filtered_stats, - key_name=x_axis_name, - value_name=y_axis_name, - key_mapper=lambda issue_type: issue_type.name, - ) + if config_params is None: + return params - return create_bar_plot(df, x_axis_name, y_axis_name) + if config_params.get(ConfigFields.MARGIN.value) is not None: + margin_value = config_params.get(ConfigFields.MARGIN.value).upper() + params[ConfigFields.MARGIN.value] = plotly_consts.MARGIN[margin_value] + if config_params.get(ConfigFields.SORT_ORDER.value) is not None: + sort_order_value = config_params.get(ConfigFields.SORT_ORDER.value) + params[ConfigFields.SORT_ORDER.value] = plotly_consts.SORT_ORDER(sort_order_value) -def get_median_penalty_influence_by_category( - statistics: PenaltyInfluenceStatistics, - x_axis_name: str = 'Categories', - y_axis_name: str = 'Penalty influence (%)', - limit: int = 0, -) -> go.Figure: - stat = statistics.stat - filtered_stats = {issue_type: influence for issue_type, influence in stat.items() if median(influence) >= limit} + if config_params.get(ConfigFields.LIMIT.value) is not None: + params[ConfigFields.LIMIT.value] = config_params.get(ConfigFields.LIMIT.value) - df = _get_dataframe_from_dict( - filtered_stats, - key_name=x_axis_name, - value_name=y_axis_name, - key_mapper=lambda issue_type: issue_type.name, - value_mapper=lambda influence: median(influence), - ) + if config_params.get(ConfigFields.X_AXIS_NAME.value) is not None: + params[ConfigFields.X_AXIS_NAME.value] = config_params.get(ConfigFields.X_AXIS_NAME.value) - return create_bar_plot(df, x_axis_name, y_axis_name) + if config_params.get(ConfigFields.Y_AXIS_NAME.value) is not None: + params[ConfigFields.Y_AXIS_NAME.value] = config_params.get(ConfigFields.Y_AXIS_NAME.value) + return params -def get_penalty_influence_distribution( - statistics: PenaltyInfluenceStatistics, - x_axis_name: str = 'Categories', - y_axis_name: str = 'Penalty influence (%)', -): - stat = statistics.stat - df = _get_dataframe_from_dict( - stat, - key_name=x_axis_name, - value_name=y_axis_name, - key_mapper=lambda issue_type: issue_type.name, - ) - df = df.explode(y_axis_name) +def plot_and_save( + config: Dict, + general_statistics: GeneralInspectorsStatistics, + save_dir: Path, + extension: Extension, +) -> None: + for plot_type in PlotTypes: + if plot_type.value in config: + params = get_plot_params(config, plot_type) + plotter_function = plot_type.to_plotter_function() + statistics = plot_type.extract_statistics(general_statistics) + plot = plotter_function(statistics, **params) + save_plot(plot, save_dir, plot_name=plot_type.value, extension=extension) - return create_box_plot(df, x_axis_name, y_axis_name) - -def main(): +def main() -> None: parser = argparse.ArgumentParser() configure_arguments(parser) args = parser.parse_args() diffs = deserialize_data_from_file(args.diffs_file_path) - statistics = gather_statistics(diffs) + general_statistics = gather_statistics(diffs) extension = Extension(args.file_extension) + config = parse_yaml(args.config_path) - plot = get_unique_issues_by_category(statistics.new_issues_stat) - save_plot(plot, args.save_dir, plot_name='unique-issues-by-category', extension=extension) - - plot = get_issues_by_category(statistics.new_issues_stat) - save_plot(plot, args.save_dir, plot_name='issues-by-category', extension=extension) - - plot = get_unique_issues_by_category(statistics.penalty_issues_stat, y_axis_name='Number of unique penalty issues') - save_plot(plot, args.save_dir, plot_name='unique-penalty-issues-by-category', extension=extension) - - plot = get_issues_by_category(statistics.penalty_issues_stat, y_axis_name='Number of penalty issues') - save_plot(plot, args.save_dir, plot_name='penalty-issues-by-category', extension=extension) - - plot = get_median_penalty_influence_by_category(statistics.penalty_influence_stat) - save_plot(plot, args.save_dir, plot_name='median-penalty-influence-by-category', extension=extension) - - plot = get_penalty_influence_distribution(statistics.penalty_influence_stat) - save_plot(plot, args.save_dir, plot_name='penalty_influence_distribution', extension=extension) + plot_and_save(config, general_statistics, args.save_dir, extension) if __name__ == '__main__': From fdfb2c3a14dda4c84a888df1560598761d9ab8e2 Mon Sep 17 00:00:00 2001 From: Ilya Vlasov Date: Sat, 5 Jun 2021 12:32:30 +0300 Subject: [PATCH 50/73] Added plotters.py --- .../evaluation/plots/common/__init__.py | 0 .../evaluation/plots/common/plotters.py | 121 ++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 src/python/evaluation/plots/common/__init__.py create mode 100644 src/python/evaluation/plots/common/plotters.py diff --git a/src/python/evaluation/plots/common/__init__.py b/src/python/evaluation/plots/common/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/python/evaluation/plots/common/plotters.py b/src/python/evaluation/plots/common/plotters.py new file mode 100644 index 00000000..ff111d78 --- /dev/null +++ b/src/python/evaluation/plots/common/plotters.py @@ -0,0 +1,121 @@ +from statistics import median +from typing import Any, Callable, Dict, Optional + +import pandas as pd +import plotly.graph_objects as go +from src.python.evaluation.inspectors.common.statistics import IssuesStatistics, PenaltyInfluenceStatistics +from src.python.evaluation.plots.common import plotly_consts +from src.python.evaluation.plots.common.utils import create_bar_plot, create_box_plot + + +def _get_dataframe_from_dict( + data_dict: Dict[Any, Any], + key_name: str, + value_name: str, + key_mapper: Callable = lambda x: x, + value_mapper: Callable = lambda y: y, +): + """ + Converts 'data_dict' to a dataframe consisting of two columns: 'key_name', 'value_name'. + 'key_name' contains all keys of 'data_dict', 'value_name' contains all corresponding + values of 'data_dict'. With the functions 'key_mapper' and 'value_mapper' you can + additionally convert keys and values respectively. + """ + converted_dict = { + key_name: list(map(key_mapper, data_dict.keys())), + value_name: list(map(value_mapper, data_dict.values())), + } + + return pd.DataFrame.from_dict(converted_dict) + + +def _extract_stats_from_issues_statistics(statistics: IssuesStatistics, limit: int, only_unique: bool): + categorized_statistics = statistics.get_short_categorized_statistics() + + # If you want to get only unique issues, you should use position 0 of the tuple, otherwise 1. + position = int(not only_unique) + + return { + issue_type: stat[position] for issue_type, stat in categorized_statistics.items() if stat[position] >= limit + } + + +def get_unique_issues_by_category( + statistics: IssuesStatistics, + x_axis_name: str = 'Categories', + y_axis_name: str = 'Number of unique issues', + limit: int = 0, + margin: Optional[plotly_consts.MARGIN] = None, + sort_order: Optional[plotly_consts.SORT_ORDER] = None, +) -> go.Figure: + filtered_stats = _extract_stats_from_issues_statistics(statistics, limit, only_unique=True) + + df = _get_dataframe_from_dict( + filtered_stats, + key_name=x_axis_name, + value_name=y_axis_name, + key_mapper=lambda issue_type: issue_type.name, + ) + + return create_bar_plot(df, x_axis_name, y_axis_name, margin, sort_order) + + +def get_issues_by_category( + statistics: IssuesStatistics, + margin: Optional[plotly_consts.MARGIN] = None, + sort_order: Optional[plotly_consts.SORT_ORDER] = None, + x_axis_name: str = 'Categories', + y_axis_name: str = 'Number of issues', + limit: int = 0, +) -> go.Figure: + filtered_stats = _extract_stats_from_issues_statistics(statistics, limit, only_unique=False) + + df = _get_dataframe_from_dict( + filtered_stats, + key_name=x_axis_name, + value_name=y_axis_name, + key_mapper=lambda issue_type: issue_type.name, + ) + + return create_bar_plot(df, x_axis_name, y_axis_name, margin, sort_order) + + +def get_median_penalty_influence_by_category( + statistics: PenaltyInfluenceStatistics, + margin: Optional[plotly_consts.MARGIN] = None, + sort_order: Optional[plotly_consts.SORT_ORDER] = None, + x_axis_name: str = 'Categories', + y_axis_name: str = 'Penalty influence (%)', + limit: int = 0, +) -> go.Figure: + stat = statistics.stat + filtered_stats = {issue_type: influence for issue_type, influence in stat.items() if median(influence) >= limit} + + df = _get_dataframe_from_dict( + filtered_stats, + key_name=x_axis_name, + value_name=y_axis_name, + key_mapper=lambda issue_type: issue_type.name, + value_mapper=lambda influence: median(influence), + ) + + return create_bar_plot(df, x_axis_name, y_axis_name, margin, sort_order) + + +def get_penalty_influence_distribution( + statistics: PenaltyInfluenceStatistics, + margin: Optional[plotly_consts.MARGIN] = None, + x_axis_name: str = 'Categories', + y_axis_name: str = 'Penalty influence (%)', +): + stat = statistics.stat + + df = _get_dataframe_from_dict( + stat, + key_name=x_axis_name, + value_name=y_axis_name, + key_mapper=lambda issue_type: issue_type.name, + ) + df = df.explode(y_axis_name) + + return create_box_plot(df, x_axis_name, y_axis_name, margin) From 1e620f9ee91289ac8533f146c6aaf489b518e139 Mon Sep 17 00:00:00 2001 From: Ilya Vlasov Date: Sat, 5 Jun 2021 12:39:32 +0300 Subject: [PATCH 51/73] Remove TODO --- src/python/evaluation/plots/common/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/python/evaluation/plots/common/utils.py b/src/python/evaluation/plots/common/utils.py index 8e02896f..fa2b4393 100644 --- a/src/python/evaluation/plots/common/utils.py +++ b/src/python/evaluation/plots/common/utils.py @@ -37,7 +37,7 @@ def create_box_plot( ) -> go.Figure: fig = px.box(df, x=x_axis, y=y_axis) - update_layout(fig, margin) # TODO: sort_order ? + update_layout(fig, margin) return fig From 2980087fb740aaf5c2320160f12a244c17dd3548 Mon Sep 17 00:00:00 2001 From: Ilya Vlasov Date: Sat, 5 Jun 2021 21:24:41 +0300 Subject: [PATCH 52/73] Renamed get_supported_image_extensions to get_image_extensions --- src/python/evaluation/plots/diffs_plotter.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/python/evaluation/plots/diffs_plotter.py b/src/python/evaluation/plots/diffs_plotter.py index 3701dd14..a42d1f37 100644 --- a/src/python/evaluation/plots/diffs_plotter.py +++ b/src/python/evaluation/plots/diffs_plotter.py @@ -21,7 +21,7 @@ get_penalty_influence_distribution, get_unique_issues_by_category, ) -from src.python.evaluation.plots.common.utils import get_supported_image_extensions, save_plot +from src.python.evaluation.plots.common.utils import get_supported_extensions, save_plot from src.python.review.common.file_system import deserialize_data_from_file, Extension, parse_yaml @@ -94,7 +94,7 @@ def configure_arguments(parser: argparse.ArgumentParser) -> None: '--file-extension', type=str, default=Extension.SVG.value, - choices=get_supported_image_extensions(), + choices=get_supported_extensions(), help='Allows you to select the extension of output files', ) From f747cced53194bdfd59e34d461e3cb5ad384cb3a Mon Sep 17 00:00:00 2001 From: Ilya Vlasov Date: Sat, 5 Jun 2021 21:25:56 +0300 Subject: [PATCH 53/73] Code refactoring 1) Renamed get_supported_image_extensions to get_image_extensions; 2) Removed unnecessary spaces; 3) Added sort_order to create_box_plot function. --- src/python/evaluation/plots/common/utils.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/python/evaluation/plots/common/utils.py b/src/python/evaluation/plots/common/utils.py index fa2b4393..983fe057 100644 --- a/src/python/evaluation/plots/common/utils.py +++ b/src/python/evaluation/plots/common/utils.py @@ -9,7 +9,7 @@ from src.python.review.common.file_system import Extension -def get_supported_image_extensions() -> List[str]: +def get_supported_extensions() -> List[str]: extensions = Extension.get_image_extensions() extensions.append(Extension.JSON) return [extension.value for extension in extensions] @@ -23,9 +23,7 @@ def create_bar_plot( sort_order: Optional[plotly_consts.SORT_ORDER] = None, ) -> go.Figure: fig = px.bar(df, x=x_axis, y=y_axis, text=y_axis) - update_layout(fig, margin, sort_order) - return fig @@ -34,11 +32,10 @@ def create_box_plot( x_axis: str, y_axis: str, margin: Optional[plotly_consts.MARGIN] = None, + sort_order: Optional[plotly_consts.SORT_ORDER] = None, ) -> go.Figure: fig = px.box(df, x=x_axis, y=y_axis) - - update_layout(fig, margin) - + update_layout(fig, margin, sort_order) return fig From 6a9ac78b3b3d2d0d800f5b50d4941d26517c3c78 Mon Sep 17 00:00:00 2001 From: Ilya Vlasov Date: Sat, 5 Jun 2021 21:28:37 +0300 Subject: [PATCH 54/73] Fixed arguments order --- .../evaluation/plots/common/plotters.py | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/src/python/evaluation/plots/common/plotters.py b/src/python/evaluation/plots/common/plotters.py index ff111d78..28156163 100644 --- a/src/python/evaluation/plots/common/plotters.py +++ b/src/python/evaluation/plots/common/plotters.py @@ -6,6 +6,7 @@ from src.python.evaluation.inspectors.common.statistics import IssuesStatistics, PenaltyInfluenceStatistics from src.python.evaluation.plots.common import plotly_consts from src.python.evaluation.plots.common.utils import create_bar_plot, create_box_plot +from src.python.review.inspectors.issue import IssueType def _get_dataframe_from_dict( @@ -29,7 +30,9 @@ def _get_dataframe_from_dict( return pd.DataFrame.from_dict(converted_dict) -def _extract_stats_from_issues_statistics(statistics: IssuesStatistics, limit: int, only_unique: bool): +def _extract_stats_from_issues_statistics( + statistics: IssuesStatistics, limit: int, only_unique: bool +) -> Dict[IssueType, int]: categorized_statistics = statistics.get_short_categorized_statistics() # If you want to get only unique issues, you should use position 0 of the tuple, otherwise 1. @@ -62,11 +65,11 @@ def get_unique_issues_by_category( def get_issues_by_category( statistics: IssuesStatistics, - margin: Optional[plotly_consts.MARGIN] = None, - sort_order: Optional[plotly_consts.SORT_ORDER] = None, x_axis_name: str = 'Categories', y_axis_name: str = 'Number of issues', limit: int = 0, + margin: Optional[plotly_consts.MARGIN] = None, + sort_order: Optional[plotly_consts.SORT_ORDER] = None, ) -> go.Figure: filtered_stats = _extract_stats_from_issues_statistics(statistics, limit, only_unique=False) @@ -82,11 +85,11 @@ def get_issues_by_category( def get_median_penalty_influence_by_category( statistics: PenaltyInfluenceStatistics, - margin: Optional[plotly_consts.MARGIN] = None, - sort_order: Optional[plotly_consts.SORT_ORDER] = None, x_axis_name: str = 'Categories', y_axis_name: str = 'Penalty influence (%)', limit: int = 0, + margin: Optional[plotly_consts.MARGIN] = None, + sort_order: Optional[plotly_consts.SORT_ORDER] = None, ) -> go.Figure: stat = statistics.stat filtered_stats = {issue_type: influence for issue_type, influence in stat.items() if median(influence) >= limit} @@ -104,18 +107,21 @@ def get_median_penalty_influence_by_category( def get_penalty_influence_distribution( statistics: PenaltyInfluenceStatistics, - margin: Optional[plotly_consts.MARGIN] = None, x_axis_name: str = 'Categories', y_axis_name: str = 'Penalty influence (%)', + limit: int = 0, + margin: Optional[plotly_consts.MARGIN] = None, + sort_order: Optional[plotly_consts.SORT_ORDER] = None, ): stat = statistics.stat + filtered_stats = {issue_type: influence for issue_type, influence in stat.items() if len(influence) >= limit} df = _get_dataframe_from_dict( - stat, + filtered_stats, key_name=x_axis_name, value_name=y_axis_name, key_mapper=lambda issue_type: issue_type.name, ) df = df.explode(y_axis_name) - return create_box_plot(df, x_axis_name, y_axis_name, margin) + return create_box_plot(df, x_axis_name, y_axis_name, margin, sort_order) From ed4c313fb683af6e93b8fa565b8e2b84c9e39751 Mon Sep 17 00:00:00 2001 From: Ilya Vlasov Date: Sat, 5 Jun 2021 22:34:46 +0300 Subject: [PATCH 55/73] Added color support --- .../evaluation/plots/common/plotly_consts.py | 23 ++++++++++++ .../evaluation/plots/common/plotters.py | 12 ++++-- src/python/evaluation/plots/common/utils.py | 6 ++- src/python/evaluation/plots/diffs_plotter.py | 37 +++++++++++++------ 4 files changed, 60 insertions(+), 18 deletions(-) diff --git a/src/python/evaluation/plots/common/plotly_consts.py b/src/python/evaluation/plots/common/plotly_consts.py index 209ae4e0..c05e4423 100644 --- a/src/python/evaluation/plots/common/plotly_consts.py +++ b/src/python/evaluation/plots/common/plotly_consts.py @@ -1,5 +1,7 @@ from enum import Enum +import plotly.express as px + class MARGIN(Enum): ZERO = {'l': 0, 'r': 0, 'b': 0, 't': 0} @@ -10,3 +12,24 @@ class SORT_ORDER(Enum): # noqa: N801 CATEGORY_DESCENDING = 'category descending' TOTAL_ASCENDING = 'total ascending' TOTAL_DESCENDING = 'total descending' + + +class COLOR(Enum): + D3 = px.colors.qualitative.D3 + G10 = px.colors.qualitative.G10 + T10 = px.colors.qualitative.T10 + ALPHABET = px.colors.qualitative.Alphabet + DARK24 = px.colors.qualitative.Dark24 + LIGHT24 = px.colors.qualitative.Light24 + ANTIQUE = px.colors.qualitative.Antique + BOLD = px.colors.qualitative.Bold + PASTEL = px.colors.qualitative.Pastel + PASTEL1 = px.colors.qualitative.Pastel1 + PASTEL2 = px.colors.qualitative.Pastel2 + PRISM = px.colors.qualitative.Prism + SAFE = px.colors.qualitative.Safe + VIVID = px.colors.qualitative.Vivid + SET1 = px.colors.qualitative.Set1 + SET2 = px.colors.qualitative.Set2 + SET3 = px.colors.qualitative.Set3 + DARK2 = px.colors.qualitative.Dark2 diff --git a/src/python/evaluation/plots/common/plotters.py b/src/python/evaluation/plots/common/plotters.py index 28156163..fa78b41d 100644 --- a/src/python/evaluation/plots/common/plotters.py +++ b/src/python/evaluation/plots/common/plotters.py @@ -50,6 +50,7 @@ def get_unique_issues_by_category( limit: int = 0, margin: Optional[plotly_consts.MARGIN] = None, sort_order: Optional[plotly_consts.SORT_ORDER] = None, + color: Optional[plotly_consts.COLOR] = None, ) -> go.Figure: filtered_stats = _extract_stats_from_issues_statistics(statistics, limit, only_unique=True) @@ -60,7 +61,7 @@ def get_unique_issues_by_category( key_mapper=lambda issue_type: issue_type.name, ) - return create_bar_plot(df, x_axis_name, y_axis_name, margin, sort_order) + return create_bar_plot(df, x_axis_name, y_axis_name, margin, sort_order, color) def get_issues_by_category( @@ -70,6 +71,7 @@ def get_issues_by_category( limit: int = 0, margin: Optional[plotly_consts.MARGIN] = None, sort_order: Optional[plotly_consts.SORT_ORDER] = None, + color: Optional[plotly_consts.COLOR] = None, ) -> go.Figure: filtered_stats = _extract_stats_from_issues_statistics(statistics, limit, only_unique=False) @@ -80,7 +82,7 @@ def get_issues_by_category( key_mapper=lambda issue_type: issue_type.name, ) - return create_bar_plot(df, x_axis_name, y_axis_name, margin, sort_order) + return create_bar_plot(df, x_axis_name, y_axis_name, margin, sort_order, color) def get_median_penalty_influence_by_category( @@ -90,6 +92,7 @@ def get_median_penalty_influence_by_category( limit: int = 0, margin: Optional[plotly_consts.MARGIN] = None, sort_order: Optional[plotly_consts.SORT_ORDER] = None, + color: Optional[plotly_consts.COLOR] = None, ) -> go.Figure: stat = statistics.stat filtered_stats = {issue_type: influence for issue_type, influence in stat.items() if median(influence) >= limit} @@ -102,7 +105,7 @@ def get_median_penalty_influence_by_category( value_mapper=lambda influence: median(influence), ) - return create_bar_plot(df, x_axis_name, y_axis_name, margin, sort_order) + return create_bar_plot(df, x_axis_name, y_axis_name, margin, sort_order, color) def get_penalty_influence_distribution( @@ -112,6 +115,7 @@ def get_penalty_influence_distribution( limit: int = 0, margin: Optional[plotly_consts.MARGIN] = None, sort_order: Optional[plotly_consts.SORT_ORDER] = None, + color: Optional[plotly_consts.COLOR] = None, ): stat = statistics.stat filtered_stats = {issue_type: influence for issue_type, influence in stat.items() if len(influence) >= limit} @@ -124,4 +128,4 @@ def get_penalty_influence_distribution( ) df = df.explode(y_axis_name) - return create_box_plot(df, x_axis_name, y_axis_name, margin, sort_order) + return create_box_plot(df, x_axis_name, y_axis_name, margin, sort_order, color) diff --git a/src/python/evaluation/plots/common/utils.py b/src/python/evaluation/plots/common/utils.py index 983fe057..657052f7 100644 --- a/src/python/evaluation/plots/common/utils.py +++ b/src/python/evaluation/plots/common/utils.py @@ -21,8 +21,9 @@ def create_bar_plot( y_axis: str, margin: Optional[plotly_consts.MARGIN] = None, sort_order: Optional[plotly_consts.SORT_ORDER] = None, + color: Optional[plotly_consts.COLOR] = None, ) -> go.Figure: - fig = px.bar(df, x=x_axis, y=y_axis, text=y_axis) + fig = px.bar(df, x=x_axis, y=y_axis, text=y_axis, color_discrete_sequence=color.value) update_layout(fig, margin, sort_order) return fig @@ -33,8 +34,9 @@ def create_box_plot( y_axis: str, margin: Optional[plotly_consts.MARGIN] = None, sort_order: Optional[plotly_consts.SORT_ORDER] = None, + color: Optional[plotly_consts.COLOR] = None, ) -> go.Figure: - fig = px.box(df, x=x_axis, y=y_axis) + fig = px.box(df, x=x_axis, y=y_axis, color_discrete_sequence=color.value) update_layout(fig, margin, sort_order) return fig diff --git a/src/python/evaluation/plots/diffs_plotter.py b/src/python/evaluation/plots/diffs_plotter.py index a42d1f37..500b018e 100644 --- a/src/python/evaluation/plots/diffs_plotter.py +++ b/src/python/evaluation/plots/diffs_plotter.py @@ -32,6 +32,15 @@ class ConfigFields(Enum): LIMIT = 'limit' MARGIN = 'margin' SORT_ORDER = 'sort_order' + COLOR = 'color' + + +X_AXIS_NAME = ConfigFields.X_AXIS_NAME.value +Y_AXIS_NAME = ConfigFields.Y_AXIS_NAME.value +LIMIT = ConfigFields.LIMIT.value +MARGIN = ConfigFields.MARGIN.value +SORT_ORDER = ConfigFields.SORT_ORDER.value +COLOR = ConfigFields.COLOR.value @unique @@ -106,22 +115,26 @@ def get_plot_params(config: Dict, plot_type: PlotTypes) -> Dict[str, Any]: if config_params is None: return params - if config_params.get(ConfigFields.MARGIN.value) is not None: - margin_value = config_params.get(ConfigFields.MARGIN.value).upper() - params[ConfigFields.MARGIN.value] = plotly_consts.MARGIN[margin_value] + if config_params.get(MARGIN) is not None: + margin_value = config_params.get(MARGIN).upper() + params[MARGIN] = plotly_consts.MARGIN[margin_value] + + if config_params.get(SORT_ORDER) is not None: + sort_order_value = config_params.get(SORT_ORDER) + params[SORT_ORDER] = plotly_consts.SORT_ORDER(sort_order_value) - if config_params.get(ConfigFields.SORT_ORDER.value) is not None: - sort_order_value = config_params.get(ConfigFields.SORT_ORDER.value) - params[ConfigFields.SORT_ORDER.value] = plotly_consts.SORT_ORDER(sort_order_value) + if config_params.get(LIMIT) is not None: + params[LIMIT] = config_params.get(LIMIT) - if config_params.get(ConfigFields.LIMIT.value) is not None: - params[ConfigFields.LIMIT.value] = config_params.get(ConfigFields.LIMIT.value) + if config_params.get(X_AXIS_NAME) is not None: + params[X_AXIS_NAME] = config_params.get(X_AXIS_NAME) - if config_params.get(ConfigFields.X_AXIS_NAME.value) is not None: - params[ConfigFields.X_AXIS_NAME.value] = config_params.get(ConfigFields.X_AXIS_NAME.value) + if config_params.get(Y_AXIS_NAME) is not None: + params[Y_AXIS_NAME] = config_params.get(Y_AXIS_NAME) - if config_params.get(ConfigFields.Y_AXIS_NAME.value) is not None: - params[ConfigFields.Y_AXIS_NAME.value] = config_params.get(ConfigFields.Y_AXIS_NAME.value) + if config_params.get(COLOR) is not None: + color_value = config_params.get(COLOR) + params[COLOR] = plotly_consts.COLOR[color_value] return params From c35aef8cec317d38db92cc08ed6ba1b3cf1e574d Mon Sep 17 00:00:00 2001 From: Ilya Vlasov Date: Sat, 5 Jun 2021 22:45:41 +0300 Subject: [PATCH 56/73] Added trailing comma --- src/python/evaluation/plots/common/plotters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/python/evaluation/plots/common/plotters.py b/src/python/evaluation/plots/common/plotters.py index fa78b41d..e97ceb94 100644 --- a/src/python/evaluation/plots/common/plotters.py +++ b/src/python/evaluation/plots/common/plotters.py @@ -31,7 +31,7 @@ def _get_dataframe_from_dict( def _extract_stats_from_issues_statistics( - statistics: IssuesStatistics, limit: int, only_unique: bool + statistics: IssuesStatistics, limit: int, only_unique: bool, ) -> Dict[IssueType, int]: categorized_statistics = statistics.get_short_categorized_statistics() From 36476c773ad4fbb1fd8d2f423d4ff24e478fed22 Mon Sep 17 00:00:00 2001 From: Ilya Vlasov Date: Sat, 5 Jun 2021 22:45:49 +0300 Subject: [PATCH 57/73] Added new words --- whitelist.txt | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/whitelist.txt b/whitelist.txt index cf21e35f..bc93f275 100644 --- a/whitelist.txt +++ b/whitelist.txt @@ -153,3 +153,14 @@ f1 WANDB PNG consts +D3 +G10 +T10 +Dark24 +Dark2 +Light24 +Pastel1 +Pastel2 +Set1 +Set2 +Set3 From 8a4fb889c9fc35436c41e4ba7a983dcd8163a9c3 Mon Sep 17 00:00:00 2001 From: Ilya Vlasov <55441714+GirZ0n@users.noreply.github.com> Date: Sat, 5 Jun 2021 23:24:13 +0300 Subject: [PATCH 58/73] Added README.md --- src/python/evaluation/plots/README.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 src/python/evaluation/plots/README.md diff --git a/src/python/evaluation/plots/README.md b/src/python/evaluation/plots/README.md new file mode 100644 index 00000000..d0b61674 --- /dev/null +++ b/src/python/evaluation/plots/README.md @@ -0,0 +1,20 @@ +# Hyperstyle evaluation: plots +This module allows you to visualize the data obtained with the [inspectors](inspectors) module + +## [diffs_plotter.py](diffs_plotter.py) +This script allows you to visualize a dataset obtained with [diffs_between_df.py](../inspectors/diffs_between_df.py). + +### Usage +Run the [diffs_plotter.py](diffs_plotter.py) with the arguments from command line. + +**Required arguments**: +1. `diffs_file_path` — path to a file with serialized diffs that were founded by [diffs_plotter.py](diffs_plotter.py). +2. `save_dir` — directory where the plotted charts will be saved. +3. `config_path` — path to the yaml file containing information about the graphs to be plotted. + + +**Optional arguments**: + +Argument | Description +--- | --- +**‑‑file‑extension** | allows you to select the extension of output files. Available extensions: `.png`, `.jpg`, `.jpeg`, `.webp`, `.svg`, `.pdf`, `.eps`, `.json`. Default is `.svg`. From 3fae17e1d45832e635ecdea1396abfa8514b6f6e Mon Sep 17 00:00:00 2001 From: Ilya Vlasov Date: Sun, 6 Jun 2021 21:22:12 +0300 Subject: [PATCH 59/73] Replaced qualitative sequences with simple colors --- .../evaluation/plots/common/plotly_consts.py | 31 +++++++------------ 1 file changed, 11 insertions(+), 20 deletions(-) diff --git a/src/python/evaluation/plots/common/plotly_consts.py b/src/python/evaluation/plots/common/plotly_consts.py index c05e4423..fc4f703a 100644 --- a/src/python/evaluation/plots/common/plotly_consts.py +++ b/src/python/evaluation/plots/common/plotly_consts.py @@ -1,7 +1,5 @@ from enum import Enum -import plotly.express as px - class MARGIN(Enum): ZERO = {'l': 0, 'r': 0, 'b': 0, 't': 0} @@ -15,21 +13,14 @@ class SORT_ORDER(Enum): # noqa: N801 class COLOR(Enum): - D3 = px.colors.qualitative.D3 - G10 = px.colors.qualitative.G10 - T10 = px.colors.qualitative.T10 - ALPHABET = px.colors.qualitative.Alphabet - DARK24 = px.colors.qualitative.Dark24 - LIGHT24 = px.colors.qualitative.Light24 - ANTIQUE = px.colors.qualitative.Antique - BOLD = px.colors.qualitative.Bold - PASTEL = px.colors.qualitative.Pastel - PASTEL1 = px.colors.qualitative.Pastel1 - PASTEL2 = px.colors.qualitative.Pastel2 - PRISM = px.colors.qualitative.Prism - SAFE = px.colors.qualitative.Safe - VIVID = px.colors.qualitative.Vivid - SET1 = px.colors.qualitative.Set1 - SET2 = px.colors.qualitative.Set2 - SET3 = px.colors.qualitative.Set3 - DARK2 = px.colors.qualitative.Dark2 + # Colors from px.colors.DEFAULT_PLOTLY_COLORS + BLUE = "rgb(31, 119, 180)" + ORANGE = "rgb(255, 127, 14)" + GREEN = "rgb(44, 160, 44)" + RED = "rgb(214, 39, 40)" + PURPLE = "rgb(148, 103, 189)" + BROWN = "rgb(140, 86, 75)" + PINK = "rgb(227, 119, 194)" + GRAY = "rgb(127, 127, 127)" + YELLOW = "rgb(188, 189, 34)" + CYAN = "rgb(23, 190, 207)" From 1c09630d6b782667b2b492d02d974ae3f3dc1268 Mon Sep 17 00:00:00 2001 From: Ilya Vlasov Date: Sun, 6 Jun 2021 21:23:15 +0300 Subject: [PATCH 60/73] Replaced unnecessary words --- whitelist.txt | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/whitelist.txt b/whitelist.txt index bc93f275..cf21e35f 100644 --- a/whitelist.txt +++ b/whitelist.txt @@ -153,14 +153,3 @@ f1 WANDB PNG consts -D3 -G10 -T10 -Dark24 -Dark2 -Light24 -Pastel1 -Pastel2 -Set1 -Set2 -Set3 From 5a2086ce04cbf8bad48ce4e68583fb5763225e8e Mon Sep 17 00:00:00 2001 From: Ilya Vlasov Date: Sun, 6 Jun 2021 21:24:15 +0300 Subject: [PATCH 61/73] Fixed incorrect color setting --- src/python/evaluation/plots/common/utils.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/python/evaluation/plots/common/utils.py b/src/python/evaluation/plots/common/utils.py index 657052f7..8fb1673e 100644 --- a/src/python/evaluation/plots/common/utils.py +++ b/src/python/evaluation/plots/common/utils.py @@ -23,8 +23,8 @@ def create_bar_plot( sort_order: Optional[plotly_consts.SORT_ORDER] = None, color: Optional[plotly_consts.COLOR] = None, ) -> go.Figure: - fig = px.bar(df, x=x_axis, y=y_axis, text=y_axis, color_discrete_sequence=color.value) - update_layout(fig, margin, sort_order) + fig = px.bar(df, x=x_axis, y=y_axis, text=y_axis) + update_figure(fig, margin, sort_order, color) return fig @@ -36,15 +36,16 @@ def create_box_plot( sort_order: Optional[plotly_consts.SORT_ORDER] = None, color: Optional[plotly_consts.COLOR] = None, ) -> go.Figure: - fig = px.box(df, x=x_axis, y=y_axis, color_discrete_sequence=color.value) - update_layout(fig, margin, sort_order) + fig = px.box(df, x=x_axis, y=y_axis) + update_figure(fig, margin, sort_order, color) return fig -def update_layout( +def update_figure( fig: go.Figure, margin: Optional[plotly_consts.MARGIN] = None, sort_order: Optional[plotly_consts.SORT_ORDER] = None, + color: Optional[plotly_consts.COLOR] = None, ) -> None: new_layout = {} @@ -56,6 +57,13 @@ def update_layout( fig.update_layout(**new_layout) + new_trace = {} + + if color is not None: + new_trace["marker"] = {"color": color.value} + + fig.update_traces(**new_trace) + def save_plot( fig: go.Figure, From 386a9c7e22b54fa20d7ff3007709371815e33e1a Mon Sep 17 00:00:00 2001 From: Ilya Vlasov Date: Sun, 6 Jun 2021 22:10:50 +0300 Subject: [PATCH 62/73] Added examples of graphs --- .../plots/examples/issues_by_category.png | Bin 0 -> 27515 bytes .../median_penalty_influence_by_category.png | Bin 0 -> 29328 bytes .../examples/penalty_influence_distribution.png | Bin 0 -> 31956 bytes .../examples/penalty_issues_by_category.png | Bin 0 -> 27515 bytes .../examples/unique_issues_by_category.png | Bin 0 -> 26022 bytes .../unique_penalty_issues_by_category.png | Bin 0 -> 26022 bytes 6 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 src/resources/plots/examples/issues_by_category.png create mode 100644 src/resources/plots/examples/median_penalty_influence_by_category.png create mode 100644 src/resources/plots/examples/penalty_influence_distribution.png create mode 100644 src/resources/plots/examples/penalty_issues_by_category.png create mode 100644 src/resources/plots/examples/unique_issues_by_category.png create mode 100644 src/resources/plots/examples/unique_penalty_issues_by_category.png diff --git a/src/resources/plots/examples/issues_by_category.png b/src/resources/plots/examples/issues_by_category.png new file mode 100644 index 0000000000000000000000000000000000000000..3e55aa124bfd56f920d9be50d38fcfb919791952 GIT binary patch literal 27515 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 eO;?bl#pGYB`CeY{*X!)zHLa`ER4i0`_Uxfn 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 literal 0 HcmV?d00001 diff --git a/src/resources/plots/examples/penalty_issues_by_category.png b/src/resources/plots/examples/penalty_issues_by_category.png new file mode 100644 index 0000000000000000000000000000000000000000..3e55aa124bfd56f920d9be50d38fcfb919791952 GIT binary patch literal 27515 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 eO;?bl#0LU~1*AxC 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 literal 0 HcmV?d00001 diff --git a/src/resources/plots/examples/unique_penalty_issues_by_category.png b/src/resources/plots/examples/unique_penalty_issues_by_category.png new file mode 100644 index 0000000000000000000000000000000000000000..ab69b22fe1d2c1358bced1fe939d9eca309078b9 GIT binary patch literal 26022 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 literal 0 HcmV?d00001 From 872a2f28dd4786d1f965a74b0139dc1c830277a3 Mon Sep 17 00:00:00 2001 From: Ilya Vlasov Date: Sun, 6 Jun 2021 22:21:38 +0300 Subject: [PATCH 63/73] Moved to another folder --- .../plots/examples/issues_by_category.png | Bin .../median_penalty_influence_by_category.png | Bin .../examples/penalty_influence_distribution.png | Bin .../plots/examples/penalty_issues_by_category.png | Bin .../plots/examples/unique_issues_by_category.png | Bin .../examples/unique_penalty_issues_by_category.png | Bin 6 files changed, 0 insertions(+), 0 deletions(-) rename src/{resources => python/evaluation}/plots/examples/issues_by_category.png (100%) rename src/{resources => python/evaluation}/plots/examples/median_penalty_influence_by_category.png (100%) rename src/{resources => python/evaluation}/plots/examples/penalty_influence_distribution.png (100%) rename src/{resources => python/evaluation}/plots/examples/penalty_issues_by_category.png (100%) rename src/{resources => python/evaluation}/plots/examples/unique_issues_by_category.png (100%) rename src/{resources => python/evaluation}/plots/examples/unique_penalty_issues_by_category.png (100%) diff --git a/src/resources/plots/examples/issues_by_category.png b/src/python/evaluation/plots/examples/issues_by_category.png similarity index 100% rename from src/resources/plots/examples/issues_by_category.png rename to src/python/evaluation/plots/examples/issues_by_category.png diff --git a/src/resources/plots/examples/median_penalty_influence_by_category.png b/src/python/evaluation/plots/examples/median_penalty_influence_by_category.png similarity index 100% rename from src/resources/plots/examples/median_penalty_influence_by_category.png rename to src/python/evaluation/plots/examples/median_penalty_influence_by_category.png diff --git a/src/resources/plots/examples/penalty_influence_distribution.png b/src/python/evaluation/plots/examples/penalty_influence_distribution.png similarity index 100% rename from src/resources/plots/examples/penalty_influence_distribution.png rename to src/python/evaluation/plots/examples/penalty_influence_distribution.png diff --git a/src/resources/plots/examples/penalty_issues_by_category.png b/src/python/evaluation/plots/examples/penalty_issues_by_category.png similarity index 100% rename from src/resources/plots/examples/penalty_issues_by_category.png rename to src/python/evaluation/plots/examples/penalty_issues_by_category.png diff --git a/src/resources/plots/examples/unique_issues_by_category.png b/src/python/evaluation/plots/examples/unique_issues_by_category.png similarity index 100% rename from src/resources/plots/examples/unique_issues_by_category.png rename to src/python/evaluation/plots/examples/unique_issues_by_category.png diff --git a/src/resources/plots/examples/unique_penalty_issues_by_category.png b/src/python/evaluation/plots/examples/unique_penalty_issues_by_category.png similarity index 100% rename from src/resources/plots/examples/unique_penalty_issues_by_category.png rename to src/python/evaluation/plots/examples/unique_penalty_issues_by_category.png From c75d8d8e159e35eb330d3a89b25e6d3a25d929fc Mon Sep 17 00:00:00 2001 From: Ilya Vlasov <55441714+GirZ0n@users.noreply.github.com> Date: Sun, 6 Jun 2021 22:27:27 +0300 Subject: [PATCH 64/73] Update README.md --- src/python/evaluation/plots/README.md | 32 +++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/src/python/evaluation/plots/README.md b/src/python/evaluation/plots/README.md index d0b61674..a99e0f75 100644 --- a/src/python/evaluation/plots/README.md +++ b/src/python/evaluation/plots/README.md @@ -2,13 +2,21 @@ This module allows you to visualize the data obtained with the [inspectors](inspectors) module ## [diffs_plotter.py](diffs_plotter.py) -This script allows you to visualize a dataset obtained with [diffs_between_df.py](../inspectors/diffs_between_df.py). +This script allows you to visualize a dataset obtained with [diffs_between_df.py](../inspectors/diffs_between_df.py). + +The script can build the following graphs: +* number of unique issues by category ([Example]()) +* number of issues by category ([Example]()) +* number of unique penalty issues by category ([Example]()) +* number of penalty issues by category ([Example]()) +* median influence on penalty by category ([Example]()) +* distribution of influence on penalty by category ([Example]()) ### Usage Run the [diffs_plotter.py](diffs_plotter.py) with the arguments from command line. **Required arguments**: -1. `diffs_file_path` — path to a file with serialized diffs that were founded by [diffs_plotter.py](diffs_plotter.py). +1. `diffs_file_path` — path to a file with serialized diffs that were founded by [diffs_between_df.py](../inspectors/diffs_between_df.py). 2. `save_dir` — directory where the plotted charts will be saved. 3. `config_path` — path to the yaml file containing information about the graphs to be plotted. @@ -18,3 +26,23 @@ Run the [diffs_plotter.py](diffs_plotter.py) with the arguments from command lin Argument | Description --- | --- **‑‑file‑extension** | allows you to select the extension of output files. Available extensions: `.png`, `.jpg`, `.jpeg`, `.webp`, `.svg`, `.pdf`, `.eps`, `.json`. Default is `.svg`. + +### Examples + +#### Number of unique issues by category + + +#### Number of issues by category + + +#### Number of unique penalty issues by category + + +#### Number of penalty issues by category + + +#### Median influence on penalty by category + + +#### Distribution of influence on penalty by category + From c27b97d2e36a8fff87c829cb8421659cb1f27093 Mon Sep 17 00:00:00 2001 From: Ilya Vlasov <55441714+GirZ0n@users.noreply.github.com> Date: Sun, 6 Jun 2021 22:30:37 +0300 Subject: [PATCH 65/73] Update README.md --- src/python/evaluation/plots/README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/python/evaluation/plots/README.md b/src/python/evaluation/plots/README.md index a99e0f75..a67def76 100644 --- a/src/python/evaluation/plots/README.md +++ b/src/python/evaluation/plots/README.md @@ -5,12 +5,12 @@ This module allows you to visualize the data obtained with the [inspectors](insp This script allows you to visualize a dataset obtained with [diffs_between_df.py](../inspectors/diffs_between_df.py). The script can build the following graphs: -* number of unique issues by category ([Example]()) -* number of issues by category ([Example]()) -* number of unique penalty issues by category ([Example]()) -* number of penalty issues by category ([Example]()) -* median influence on penalty by category ([Example]()) -* distribution of influence on penalty by category ([Example]()) +* number of unique issues by category ([Example](#number-of-unique-issues-by-category)) +* number of issues by category ([Example](#number-of-issues-by-category)) +* number of unique penalty issues by category ([Example](#number-of-unique-penalty-issues-by-category)) +* number of penalty issues by category ([Example](#number-of-penalty-issues-by-category)) +* median influence on penalty by category ([Example](#median-influence-on-penalty-by-category)) +* distribution of influence on penalty by category ([Example](#distribution-of-influence-on-penalty-by-category)) ### Usage Run the [diffs_plotter.py](diffs_plotter.py) with the arguments from command line. From d6fb2cd7b3909308c9a6cc5e902965091a9b2534 Mon Sep 17 00:00:00 2001 From: Ilya Vlasov <55441714+GirZ0n@users.noreply.github.com> Date: Sun, 6 Jun 2021 22:32:31 +0300 Subject: [PATCH 66/73] Update README.md --- src/python/evaluation/plots/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/python/evaluation/plots/README.md b/src/python/evaluation/plots/README.md index a67def76..2b842b17 100644 --- a/src/python/evaluation/plots/README.md +++ b/src/python/evaluation/plots/README.md @@ -1,5 +1,5 @@ # Hyperstyle evaluation: plots -This module allows you to visualize the data obtained with the [inspectors](inspectors) module +This module allows you to visualize the data obtained with the [inspectors](../inspectors) module ## [diffs_plotter.py](diffs_plotter.py) This script allows you to visualize a dataset obtained with [diffs_between_df.py](../inspectors/diffs_between_df.py). From 7269f3c9ba8ab6587a840b36c72cad24a802512d Mon Sep 17 00:00:00 2001 From: Ilya Vlasov <55441714+GirZ0n@users.noreply.github.com> Date: Sun, 6 Jun 2021 23:03:35 +0300 Subject: [PATCH 67/73] Update README.md --- src/python/evaluation/plots/README.md | 31 ++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/src/python/evaluation/plots/README.md b/src/python/evaluation/plots/README.md index 2b842b17..074b32a9 100644 --- a/src/python/evaluation/plots/README.md +++ b/src/python/evaluation/plots/README.md @@ -4,13 +4,13 @@ This module allows you to visualize the data obtained with the [inspectors](../i ## [diffs_plotter.py](diffs_plotter.py) This script allows you to visualize a dataset obtained with [diffs_between_df.py](../inspectors/diffs_between_df.py). -The script can build the following graphs: +The script can build the following charts: * number of unique issues by category ([Example](#number-of-unique-issues-by-category)) * number of issues by category ([Example](#number-of-issues-by-category)) * number of unique penalty issues by category ([Example](#number-of-unique-penalty-issues-by-category)) * number of penalty issues by category ([Example](#number-of-penalty-issues-by-category)) -* median influence on penalty by category ([Example](#median-influence-on-penalty-by-category)) -* distribution of influence on penalty by category ([Example](#distribution-of-influence-on-penalty-by-category)) +* median penalty influence by category ([Example](#median-influence-on-penalty-by-category)) +* distribution of penalty influence by category ([Example](#distribution-of-influence-on-penalty-by-category)) ### Usage Run the [diffs_plotter.py](diffs_plotter.py) with the arguments from command line. @@ -18,14 +18,35 @@ Run the [diffs_plotter.py](diffs_plotter.py) with the arguments from command lin **Required arguments**: 1. `diffs_file_path` — path to a file with serialized diffs that were founded by [diffs_between_df.py](../inspectors/diffs_between_df.py). 2. `save_dir` — directory where the plotted charts will be saved. -3. `config_path` — path to the yaml file containing information about the graphs to be plotted. +3. `config_path` — path to the yaml file containing information about the charts to be plotted. **Optional arguments**: Argument | Description --- | --- -**‑‑file‑extension** | allows you to select the extension of output files. Available extensions: `.png`, `.jpg`, `.jpeg`, `.webp`, `.svg`, `.pdf`, `.eps`, `.json`. Default is `.svg`. +**‑‑file‑extension** | Allows you to select the extension of output files. Available extensions: `.png`, `.jpg`, `.jpeg`, `.webp`, `.svg`, `.pdf`, `.eps`, `.json`. Default is `.svg`. + +### Config +The configuration file is a dictionary in yaml format, where each chart you want to build has its parameters. + +**Possible values of the charts**: +* `unique_issues_by_category` to plot the number of unique issues by category +* `issues_by_category` to plot the number of issues by category +* `unique_penalty_issues_by_category` to plot the number of unique penalty issues by category +* `penalty_issues_by_category` to plot the number of penalty issues by category +* `median_penalty_influence_by_category` to plot the median penalty influence by category +* `penalty_influence_distribution` to plot the distribution of penalty influence by category + +**Possible parameters**: +Parametr | Description +---|--- +**x_axis_name** | Name of the x-axis. +**y_axis_name** | Name of the y-axis. +**limit** | A value that allows you to filter the values before displaying them.

For charts `unique_issues_by_category`, `issues_by_category`, `unique_penalty_issues_by_category` and `penalty_issues_by_category` only those categories will be shown where the number of issues is greater than or equal to the limit.

For chart `penalty_issues_by_category` only those categories will be shown where the number of median value is greater than or equal to the limit.

For chart `penalty_influence_distribution` only those categories will be shown where the number of values is greater than or equal to the limit. +**margin** | Defines the outer margin on all four sides of the chart. The available values are specified in the Enum class `MARGIN` from [plots const file](/common/plotly_consts.py). +**sort_order** | Defines the sorting order of the chart. The available values are specified in the Enum class `SORT_ORDER` from [plots const file](/common/plotly_consts.py). +**color** | Defines the color of the chart. The available values are specified in the Enum class `COLOR` from [plots const file](/common/plotly_consts.py). ### Examples From 898396c68bbd0cf2a06120badb72d14c7b078d42 Mon Sep 17 00:00:00 2001 From: Ilya Vlasov <55441714+GirZ0n@users.noreply.github.com> Date: Sun, 6 Jun 2021 23:04:48 +0300 Subject: [PATCH 68/73] Update README.md --- src/python/evaluation/plots/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/python/evaluation/plots/README.md b/src/python/evaluation/plots/README.md index 074b32a9..0d0108d7 100644 --- a/src/python/evaluation/plots/README.md +++ b/src/python/evaluation/plots/README.md @@ -44,9 +44,9 @@ Parametr | Description **x_axis_name** | Name of the x-axis. **y_axis_name** | Name of the y-axis. **limit** | A value that allows you to filter the values before displaying them.

For charts `unique_issues_by_category`, `issues_by_category`, `unique_penalty_issues_by_category` and `penalty_issues_by_category` only those categories will be shown where the number of issues is greater than or equal to the limit.

For chart `penalty_issues_by_category` only those categories will be shown where the number of median value is greater than or equal to the limit.

For chart `penalty_influence_distribution` only those categories will be shown where the number of values is greater than or equal to the limit. -**margin** | Defines the outer margin on all four sides of the chart. The available values are specified in the Enum class `MARGIN` from [plots const file](/common/plotly_consts.py). -**sort_order** | Defines the sorting order of the chart. The available values are specified in the Enum class `SORT_ORDER` from [plots const file](/common/plotly_consts.py). -**color** | Defines the color of the chart. The available values are specified in the Enum class `COLOR` from [plots const file](/common/plotly_consts.py). +**margin** | Defines the outer margin on all four sides of the chart. The available values are specified in the Enum class `MARGIN` from [plots const file](./common/plotly_consts.py). +**sort_order** | Defines the sorting order of the chart. The available values are specified in the Enum class `SORT_ORDER` from [plots const file](./common/plotly_consts.py). +**color** | Defines the color of the chart. The available values are specified in the Enum class `COLOR` from [plots const file](./common/plotly_consts.py). ### Examples From 63de9fcdfebcdc06b34ad78e0c38aba9cdc176d0 Mon Sep 17 00:00:00 2001 From: Ilya Vlasov <55441714+GirZ0n@users.noreply.github.com> Date: Sun, 6 Jun 2021 23:10:46 +0300 Subject: [PATCH 69/73] Added default values --- src/python/evaluation/plots/README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/python/evaluation/plots/README.md b/src/python/evaluation/plots/README.md index 0d0108d7..8482b74f 100644 --- a/src/python/evaluation/plots/README.md +++ b/src/python/evaluation/plots/README.md @@ -41,12 +41,12 @@ The configuration file is a dictionary in yaml format, where each chart you want **Possible parameters**: Parametr | Description ---|--- -**x_axis_name** | Name of the x-axis. -**y_axis_name** | Name of the y-axis. -**limit** | A value that allows you to filter the values before displaying them.

For charts `unique_issues_by_category`, `issues_by_category`, `unique_penalty_issues_by_category` and `penalty_issues_by_category` only those categories will be shown where the number of issues is greater than or equal to the limit.

For chart `penalty_issues_by_category` only those categories will be shown where the number of median value is greater than or equal to the limit.

For chart `penalty_influence_distribution` only those categories will be shown where the number of values is greater than or equal to the limit. -**margin** | Defines the outer margin on all four sides of the chart. The available values are specified in the Enum class `MARGIN` from [plots const file](./common/plotly_consts.py). -**sort_order** | Defines the sorting order of the chart. The available values are specified in the Enum class `SORT_ORDER` from [plots const file](./common/plotly_consts.py). -**color** | Defines the color of the chart. The available values are specified in the Enum class `COLOR` from [plots const file](./common/plotly_consts.py). +**x_axis_name** | Name of the x-axis. The default value depends on the type of chart. +**y_axis_name** | Name of the y-axis. The default value depends on the type of chart. +**limit** | A value that allows you to filter the values before displaying them.

For charts `unique_issues_by_category`, `issues_by_category`, `unique_penalty_issues_by_category` and `penalty_issues_by_category` only those categories will be shown where the number of issues is greater than or equal to the limit.

For chart `penalty_issues_by_category` only those categories will be shown where the number of median value is greater than or equal to the limit.

For chart `penalty_influence_distribution` only those categories will be shown where the number of values is greater than or equal to the limit.

The default value depends on the type of chart. +**margin** | Defines the outer margin on all four sides of the chart. The available values are specified in the Enum class `MARGIN` from [plots const file](./common/plotly_consts.py). If not specified, the default value provided by Plotly is used. +**sort_order** | Defines the sorting order of the chart. The available values are specified in the Enum class `SORT_ORDER` from [plots const file](./common/plotly_consts.py). If not specified, the default value provided by Plotly is used. +**color** | Defines the color of the chart. The available values are specified in the Enum class `COLOR` from [plots const file](./common/plotly_consts.py). If not specified, the default value provided by Plotly is used. ### Examples From 15c0b8b752f3929766f0d2a3ac660c2d1d1571aa Mon Sep 17 00:00:00 2001 From: Ilya Vlasov <55441714+GirZ0n@users.noreply.github.com> Date: Sun, 6 Jun 2021 23:12:03 +0300 Subject: [PATCH 70/73] Small fix --- src/python/evaluation/plots/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/python/evaluation/plots/README.md b/src/python/evaluation/plots/README.md index 8482b74f..6e5886f5 100644 --- a/src/python/evaluation/plots/README.md +++ b/src/python/evaluation/plots/README.md @@ -43,7 +43,7 @@ Parametr | Description ---|--- **x_axis_name** | Name of the x-axis. The default value depends on the type of chart. **y_axis_name** | Name of the y-axis. The default value depends on the type of chart. -**limit** | A value that allows you to filter the values before displaying them.

For charts `unique_issues_by_category`, `issues_by_category`, `unique_penalty_issues_by_category` and `penalty_issues_by_category` only those categories will be shown where the number of issues is greater than or equal to the limit.

For chart `penalty_issues_by_category` only those categories will be shown where the number of median value is greater than or equal to the limit.

For chart `penalty_influence_distribution` only those categories will be shown where the number of values is greater than or equal to the limit.

The default value depends on the type of chart. +**limit** | A value that allows you to filter the data before displaying them.

For charts `unique_issues_by_category`, `issues_by_category`, `unique_penalty_issues_by_category` and `penalty_issues_by_category` only those categories will be shown where the number of issues is greater than or equal to the limit.

For chart `penalty_issues_by_category` only those categories will be shown where the number of median value is greater than or equal to the limit.

For chart `penalty_influence_distribution` only those categories will be shown where the number of values is greater than or equal to the limit.

The default value depends on the type of chart. **margin** | Defines the outer margin on all four sides of the chart. The available values are specified in the Enum class `MARGIN` from [plots const file](./common/plotly_consts.py). If not specified, the default value provided by Plotly is used. **sort_order** | Defines the sorting order of the chart. The available values are specified in the Enum class `SORT_ORDER` from [plots const file](./common/plotly_consts.py). If not specified, the default value provided by Plotly is used. **color** | Defines the color of the chart. The available values are specified in the Enum class `COLOR` from [plots const file](./common/plotly_consts.py). If not specified, the default value provided by Plotly is used. From 40e41e681eb30dff240e2c96a26a0d4b94f07103 Mon Sep 17 00:00:00 2001 From: Ilya Vlasov <55441714+GirZ0n@users.noreply.github.com> Date: Sun, 6 Jun 2021 23:19:45 +0300 Subject: [PATCH 71/73] Added example of config --- src/python/evaluation/plots/README.md | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/python/evaluation/plots/README.md b/src/python/evaluation/plots/README.md index 6e5886f5..2546f414 100644 --- a/src/python/evaluation/plots/README.md +++ b/src/python/evaluation/plots/README.md @@ -18,7 +18,7 @@ Run the [diffs_plotter.py](diffs_plotter.py) with the arguments from command lin **Required arguments**: 1. `diffs_file_path` — path to a file with serialized diffs that were founded by [diffs_between_df.py](../inspectors/diffs_between_df.py). 2. `save_dir` — directory where the plotted charts will be saved. -3. `config_path` — path to the yaml file containing information about the charts to be plotted. +3. `config_path` — path to the yaml file containing information about the charts to be plotted. A description of the config and its example is provided in [this section](#example-of-config). **Optional arguments**: @@ -48,6 +48,23 @@ Parametr | Description **sort_order** | Defines the sorting order of the chart. The available values are specified in the Enum class `SORT_ORDER` from [plots const file](./common/plotly_consts.py). If not specified, the default value provided by Plotly is used. **color** | Defines the color of the chart. The available values are specified in the Enum class `COLOR` from [plots const file](./common/plotly_consts.py). If not specified, the default value provided by Plotly is used. +#### Example of config +```yaml +unique_issues_by_category: + margin: "ZERO" + limit: 10 + sort_order: "total descending" + color: "RED" +unique_penalty_issues_by_category: + margin: "ZERO" + limit: 10 + sort_order: "total descending" +median_penalty_influence_by_category: +penalty_influence_distribution: +``` + +The result will be four graphs (`unique_issues_by_category`, `unique_penalty_issues_by_category`, `median_penalty_influence_by_category`, `penalty_influence_distribution`) with the corresponding parameters. + ### Examples #### Number of unique issues by category From 6c03041ffdedafbeef0da7c7b51b0ffe33a487e8 Mon Sep 17 00:00:00 2001 From: Ilya Vlasov <55441714+GirZ0n@users.noreply.github.com> Date: Sun, 6 Jun 2021 23:20:14 +0300 Subject: [PATCH 72/73] Small fix --- src/python/evaluation/plots/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/python/evaluation/plots/README.md b/src/python/evaluation/plots/README.md index 2546f414..8df94f68 100644 --- a/src/python/evaluation/plots/README.md +++ b/src/python/evaluation/plots/README.md @@ -18,7 +18,7 @@ Run the [diffs_plotter.py](diffs_plotter.py) with the arguments from command lin **Required arguments**: 1. `diffs_file_path` — path to a file with serialized diffs that were founded by [diffs_between_df.py](../inspectors/diffs_between_df.py). 2. `save_dir` — directory where the plotted charts will be saved. -3. `config_path` — path to the yaml file containing information about the charts to be plotted. A description of the config and its example is provided in [this section](#example-of-config). +3. `config_path` — path to the yaml file containing information about the charts to be plotted. A description of the config and its example is provided in [this section](#config). **Optional arguments**: From b7445106316079c56fe3dfb955d3291045dca824 Mon Sep 17 00:00:00 2001 From: Ilya Vlasov <55441714+GirZ0n@users.noreply.github.com> Date: Sun, 6 Jun 2021 23:21:56 +0300 Subject: [PATCH 73/73] Fixed config example --- src/python/evaluation/plots/README.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/python/evaluation/plots/README.md b/src/python/evaluation/plots/README.md index 8df94f68..6c8c3f11 100644 --- a/src/python/evaluation/plots/README.md +++ b/src/python/evaluation/plots/README.md @@ -56,9 +56,8 @@ unique_issues_by_category: sort_order: "total descending" color: "RED" unique_penalty_issues_by_category: - margin: "ZERO" - limit: 10 - sort_order: "total descending" + limit: 30 + sort_order: "category ascending" median_penalty_influence_by_category: penalty_influence_distribution: ```