diff --git a/chasten/constants.py b/chasten/constants.py index 51963182..600f42d0 100644 --- a/chasten/constants.py +++ b/chasten/constants.py @@ -1,6 +1,7 @@ """Define constants with dataclasses for use in chasten.""" from dataclasses import dataclass +from pathlib import Path # chasten constant @@ -8,6 +9,7 @@ class Chasten: """Define the Chasten dataclass for constant(s).""" + Analyze_Storage: Path Application_Name: str Application_Author: str Chasten_Database_View: str @@ -26,6 +28,7 @@ class Chasten: chasten = Chasten( + Analyze_Storage=Path("analysis.md"), Application_Name="chasten", Application_Author="ChastenedTeam", Chasten_Database_View="chasten_complete", diff --git a/chasten/database.py b/chasten/database.py index bc0a144f..299c76e7 100644 --- a/chasten/database.py +++ b/chasten/database.py @@ -6,7 +6,7 @@ from sqlite_utils import Database -from chasten import constants, enumerations, filesystem, output +from chasten import constants, enumerations, filesystem, output, util CHASTEN_SQL_SELECT_QUERY = """ SELECT @@ -66,7 +66,6 @@ def enable_full_text_search(chasten_database_name: str) -> None: database["sources"].enable_fts( [ "filename", - "filelines", "check_id", "check_name", "check_description", @@ -129,18 +128,6 @@ def display_datasette_details( output.console.print() -def executable_name(OpSystem: str = "Linux") -> str: - """Get the executable directory depending on OS""" - exe_directory = "/bin/" - executable_name = constants.datasette.Datasette_Executable - # Checks if the OS is windows and changed where to search if true - if OpSystem == "Windows": - exe_directory = "/Scripts/" - executable_name += ".exe" - virtual_env_location = sys.prefix - return virtual_env_location + exe_directory + executable_name - - def start_datasette_server( # noqa: PLR0912, PLR0913 database_path: Path, datasette_metadata: Path, @@ -160,7 +147,9 @@ def start_datasette_server( # noqa: PLR0912, PLR0913 # chasten will exist in a bin directory. For instance, the "datasette" # executable that is a dependency of chasten can be found by starting # the search from this location for the virtual environment. - full_executable_name = executable_name(OpSystem) + full_executable_name = util.executable_name( + constants.datasette.Datasette_Executable, OpSystem + ) (found_executable, executable_path) = filesystem.can_find_executable( full_executable_name ) @@ -224,7 +213,7 @@ def start_datasette_server( # noqa: PLR0912, PLR0913 # datasette-publish-fly plugin) and thus need to exit and not proceed if not found_publish_platform_executable: output.console.print( - ":person_shrugging: Was not able to find '{datasette_platform}'" + f":person_shrugging: Was not able to find '{datasette_platform}'" ) return None # was able to find the fly or vercel executable that will support the @@ -276,3 +265,25 @@ def start_datasette_server( # noqa: PLR0912, PLR0913 # there is debugging output in the console to indicate this option. proc = subprocess.Popen(cmd) proc.wait() + + +def display_results_frog_mouth(result_file, OpSystem) -> None: + """Run frogmouth as a subprocess of chasten""" + cmd = [ + "frogmouth", + result_file, + ] + executable = util.executable_name("frogmouth", OpSystem) + exec_found, executable_path = filesystem.can_find_executable(executable) + if exec_found: + # run frogmouth with specified path + output.console.print("\n🐸 Frogmouth Information\n") + output.console.print(f" {small_bullet_unicode} Venv: {sys.prefix}") + output.console.print(f" {small_bullet_unicode} Program: {executable_path}") + proc = subprocess.Popen(cmd) + proc.wait() + else: + output.console.print( + ":person_shrugging: Was not able to find frogmouth executable try installing it separately" + ) + return None diff --git a/chasten/filesystem.py b/chasten/filesystem.py index 381aa2e4..28a3e8bb 100644 --- a/chasten/filesystem.py +++ b/chasten/filesystem.py @@ -231,7 +231,7 @@ def write_dict_results( # using indentation to ensure that JSON file is readable results_path_with_file = results_path / complete_results_file_name # use the built-in method from pathlib Path to write the JSON contents - results_path_with_file.write_text(results_json) + results_path_with_file.write_text(results_json, "utf-8") # return the name of the file that contains the JSON dictionary contents return complete_results_file_name @@ -295,7 +295,7 @@ def get_json_results(json_paths: List[Path]) -> List[Dict[Any, Any]]: # iterate through each of the provided paths to a JSON file for json_path in json_paths: # turn the contents of the current JSON file into a dictionary - json_dict = json.loads(json_path.read_text()) + json_dict = json.loads(json_path.read_text("utf-8")) # add the current dictionary to the list of dictionaries json_dicts_list.append(json_dict) # return the list of JSON dictionaries diff --git a/chasten/main.py b/chasten/main.py index 3056ffba..782c0bec 100644 --- a/chasten/main.py +++ b/chasten/main.py @@ -1,5 +1,6 @@ """💫 Chasten checks the AST of a Python program.""" +import os import sys import time from pathlib import Path @@ -30,6 +31,7 @@ # create a small bullet for display in the output small_bullet_unicode = constants.markers.Small_Bullet_Unicode +ANALYSIS_FILE = constants.chasten.Analyze_Storage # --- @@ -178,7 +180,8 @@ def configure( # noqa: PLR0913 ) # write the configuration file for the chasten tool in the created directory filesystem.create_configuration_file( - created_directory_path, constants.filesystem.Main_Configuration_File + created_directory_path, + constants.filesystem.Main_Configuration_File, ) # write the check file for the chasten tool in the created directory filesystem.create_configuration_file( @@ -246,7 +249,19 @@ def analyze( # noqa: PLR0912, PLR0913, PLR0915 writable=True, resolve_path=True, ), - config: str = typer.Option( + store_result: Path = typer.Option( + None, + "--markdown-storage", + "-r", + help="A directory for storing results in a markdown file", + exists=True, + file_okay=False, + dir_okay=True, + readable=True, + writable=True, + resolve_path=True, + ), + config: Path = typer.Option( None, "--config", "-c", @@ -264,8 +279,10 @@ def analyze( # noqa: PLR0912, PLR0913, PLR0915 "-t", help="Specify the destination for debugging output.", ), + display: bool = typer.Option(False, help="Display results using frogmouth"), verbose: bool = typer.Option(False, help="Enable verbose mode output."), save: bool = typer.Option(False, help="Enable saving of output file(s)."), + force: bool = typer.Option(False, help="Force creation of new markdown file"), ) -> None: """💫 Analyze the AST of Python source code.""" start_time = time.time() @@ -344,6 +361,27 @@ def analyze( # noqa: PLR0912, PLR0913, PLR0915 "\n:person_shrugging: Cannot perform analysis due to invalid search directory.\n" ) sys.exit(constants.markers.Non_Zero_Exit) + if store_result: + # creates an empty string for storing results temporarily + analysis_result = "" + analysis_file_dir = store_result / ANALYSIS_FILE + # clears markdown file of results if it exists and new results are to be store + if filesystem.confirm_valid_file(analysis_file_dir): + if not force: + if display: + database.display_results_frog_mouth( + analysis_file_dir, util.get_OS() + ) + sys.exit(0) + else: + output.console.print( + "File already exists: use --force to recreate markdown directory." + ) + sys.exit(constants.markers.Non_Zero_Exit) + else: + analysis_file_dir.write_text("") + # creates file if doesn't exist already + analysis_file_dir.touch() # create the list of directories valid_directories = [input_path] # output the list of directories subject to checking @@ -358,7 +396,9 @@ def analyze( # noqa: PLR0912, PLR0913, PLR0915 # iterate through and perform each of the checks for current_check in check_list: # extract the pattern for the current check - current_xpath_pattern = str(current_check[constants.checks.Check_Pattern]) # type: ignore + current_xpath_pattern = str( + current_check[constants.checks.Check_Pattern] + ) # type: ignore # extract the minimum and maximum values for the checks, if they exist # note that this function will return None for a min or a max if # that attribute does not exist inside of the current_check; importantly, @@ -383,8 +423,7 @@ def analyze( # noqa: PLR0912, PLR0913, PLR0915 match_generator = pyastgrepsearch.search_python_files( paths=valid_directories, expression=current_xpath_pattern, xpath2=True ) - - # materialize a list from the generator of (potential) matches; + # materia>>> mastlize a list from the generator of (potential) matches; # note that this list will also contain an object that will # indicate that the analysis completed for each located file match_generator_list = list(match_generator) @@ -419,6 +458,19 @@ def analyze( # noqa: PLR0912, PLR0913, PLR0915 f" {check_status_symbol} id: '{check_id}', name: '{check_name}'" + f", pattern: '{current_xpath_pattern_escape}', min={min_count}, max={max_count}" ) + if store_result: + # makes the check marks or x's appear as words instead for markdown + check_pass = ( + "PASSED:" + if check_status_symbol == "[green]\u2713[/green]" + else "FAILED:" + ) + # stores check type in a string to stored in file later + analysis_result += ( + f"\n# {check_pass} **ID:** '{check_id}', **Name:** '{check_name}'" + + f", **Pattern:** '{current_xpath_pattern_escape}', min={min_count}, max={max_count}\n\n" + ) + # for each potential match, log and, if verbose model is enabled, # display details about each of the matches current_result_source = results.Source( @@ -455,6 +507,9 @@ def analyze( # noqa: PLR0912, PLR0913, PLR0915 output.console.print( f" {small_bullet_unicode} {file_name} - {len(matches_list)} matches" ) + if store_result: + # stores details of checks in string to be stored later + analysis_result += f" - {file_name} - {len(matches_list)} matches\n" # extract the lines of source code for this file; note that all of # these matches are organized for the same file and thus it is # acceptable to extract the lines of the file from the first match @@ -484,7 +539,10 @@ def analyze( # noqa: PLR0912, PLR0913, PLR0915 ), linematch_context=util.join_and_preserve( current_match.file_lines, - max(0, position_end - constants.markers.Code_Context), + max( + 0, + position_end - constants.markers.Code_Context, + ), position_end + constants.markers.Code_Context, ), ) @@ -492,9 +550,19 @@ def analyze( # noqa: PLR0912, PLR0913, PLR0915 # pyastgrepsearch.Match for verbose debugging output as needed current_check_save._matches.append(current_match) # add the match to the listing of matches for the current check - current_check_save.matches.append(current_match_for_current_check_save) # type: ignore + current_check_save.matches.append( + current_match_for_current_check_save + ) # type: ignore # add the current source to main object that contains a list of source chasten_results_save.sources.append(current_result_source) + # add the amount of total matches in each check to the end of each checks output + output.console.print(f" = {len(match_generator_list)} total matches\n") + # calculate the final count of matches found + total_result = util.total_amount_passed(chasten_results_save, len(check_list)) + # display checks passed, total amount of checks, and percentage of checks passed + output.console.print( + f":computer: {total_result[0]} / {total_result[1]} checks passed ({total_result[2]}%)\n" + ) # display all of the analysis results if verbose output is requested output.print_analysis_details(chasten_results_save, verbose=verbose) # save all of the results from this analysis @@ -503,7 +571,7 @@ def analyze( # noqa: PLR0912, PLR0913, PLR0915 ) # output the name of the saved file if saving successfully took place if saved_file_name: - output.console.print(f"\n:sparkles: Saved the file '{saved_file_name}'") + output.console.print(f":sparkles: Saved the file '{saved_file_name}'") # confirm whether or not all of the checks passed # and then display the appropriate diagnostic message all_checks_passed = all(check_status_list) @@ -512,10 +580,23 @@ def analyze( # noqa: PLR0912, PLR0913, PLR0915 if not all_checks_passed: output.console.print("\n:sweat: At least one check did not pass.") + if store_result: + # writes results of analyze into a markdown file + analysis_file_dir.write_text(analysis_result, encoding="utf-8") + output.console.print( + f"\n:sparkles: Results saved in: {os.path.abspath(analysis_file_dir)}\n" + ) sys.exit(constants.markers.Non_Zero_Exit) output.console.print( f"\n:joy: All checks passed. Elapsed Time: {elapsed_time} seconds" ) + if store_result: + # writes results of analyze into a markdown file + result_path = os.path.abspath(analysis_file_dir) + analysis_file_dir.write_text(analysis_result, encoding="utf-8") + output.console.print(f"\n:sparkles: Results saved in: {result_path}\n") + if display: + database.display_results_frog_mouth(result_path, util.get_OS()) @cli.command() @@ -582,7 +663,7 @@ def integrate( # noqa: PLR0913 if combined_json_file_name: output.console.print(f"\n:sparkles: Saved the file '{combined_json_file_name}'") # "flatten" (i.e., "un-nest") the now-saved combined JSON file using flatterer - # create the SQLite3 database and then configure the database for use in datasett + # create the SQLite3 database and then configure the database for use in datasette combined_flattened_directory = filesystem.write_flattened_csv_and_database( combined_json_file_name, output_directory, diff --git a/chasten/util.py b/chasten/util.py index 6182f3c5..b77cc125 100644 --- a/chasten/util.py +++ b/chasten/util.py @@ -2,6 +2,7 @@ import importlib.metadata import platform +import sys from chasten import constants from typing import List @@ -27,6 +28,17 @@ def get_OS() -> str: return OpSystem +def executable_name(executable_name: str, OpSystem: str = "Linux") -> str: + """Get the executable directory depending on OS""" + exe_directory = "/bin/" + # Checks if the OS is windows and changed where to search if true + if OpSystem == "Windows": + exe_directory = "/Scripts/" + executable_name += ".exe" + virtual_env_location = sys.prefix + return virtual_env_location + exe_directory + executable_name + + def get_symbol_boolean(answer: bool) -> str: """Produce a symbol-formatted version of a boolean value of True or False.""" if answer: @@ -86,3 +98,17 @@ def is_url(url: str) -> bool: url_reassembled += str(url_piece) # determine if parsed and reconstructed url matches original return str(parse_url(url)).lower() == url_reassembled.lower() + + +def total_amount_passed(analyze_result, count_total) -> tuple[int, int, float]: + """Calculate amount of checks passed in analyze""" + try: + # iterate through check sources to find checks passed + list_passed = [x.check.passed for x in analyze_result.sources] + # set variables to count true checks and total counts + count_true = list_passed.count(True) + # return tuple of checks passed, total checks, percentage of checks passed + return (count_true, count_total, (count_true / count_total) * 100) + # return exception when dividing by zero + except ZeroDivisionError: + return (0, 0, 0.0) diff --git a/poetry.lock b/poetry.lock index 81b9e357..f5ace7d3 100644 --- a/poetry.lock +++ b/poetry.lock @@ -695,6 +695,23 @@ ijson = "*" orjson = "*" pandas = "*" +[[package]] +name = "frogmouth" +version = "0.9.0" +description = "A Markdown document viewer for the terminal" +optional = false +python-versions = ">=3.8,<4.0" +files = [ + {file = "frogmouth-0.9.0-py3-none-any.whl", hash = "sha256:fd9eb4cd1c7aa42d4110d2b6c25022d558e5aceb6890412df345674fc4ada10f"}, + {file = "frogmouth-0.9.0.tar.gz", hash = "sha256:b0735b730babe2d37c45fc5947d5aa7065880d92c5d823a354066a036b1deb5d"}, +] + +[package.dependencies] +httpx = ">=0.24.1,<0.25.0" +textual = ">=0.32,<0.33" +typing-extensions = ">=4.5.0,<5.0.0" +xdg = ">=6.0.0,<7.0.0" + [[package]] name = "h11" version = "0.14.0" @@ -708,13 +725,13 @@ files = [ [[package]] name = "httpcore" -version = "0.18.0" +version = "0.17.3" description = "A minimal low-level HTTP client." optional = false -python-versions = ">=3.8" +python-versions = ">=3.7" files = [ - {file = "httpcore-0.18.0-py3-none-any.whl", hash = "sha256:adc5398ee0a476567bf87467063ee63584a8bce86078bf748e48754f60202ced"}, - {file = "httpcore-0.18.0.tar.gz", hash = "sha256:13b5e5cd1dca1a6636a6aaea212b19f4f85cd88c366a2b82304181b769aab3c9"}, + {file = "httpcore-0.17.3-py3-none-any.whl", hash = "sha256:c2789b767ddddfa2a5782e3199b2b7f6894540b17b16ec26b2c4d8e103510b87"}, + {file = "httpcore-0.17.3.tar.gz", hash = "sha256:a6f30213335e34c1ade7be6ec7c47f19f50c56db36abef1a9dfa3815b1cb3888"}, ] [package.dependencies] @@ -729,18 +746,18 @@ socks = ["socksio (==1.*)"] [[package]] name = "httpx" -version = "0.25.0" +version = "0.24.1" description = "The next generation HTTP client." optional = false -python-versions = ">=3.8" +python-versions = ">=3.7" files = [ - {file = "httpx-0.25.0-py3-none-any.whl", hash = "sha256:181ea7f8ba3a82578be86ef4171554dd45fec26a02556a744db029a0a27b7100"}, - {file = "httpx-0.25.0.tar.gz", hash = "sha256:47ecda285389cb32bb2691cc6e069e3ab0205956f681c5b2ad2325719751d875"}, + {file = "httpx-0.24.1-py3-none-any.whl", hash = "sha256:06781eb9ac53cde990577af654bd990a4949de37a28bdb4a230d434f3a30b9bd"}, + {file = "httpx-0.24.1.tar.gz", hash = "sha256:5853a43053df830c20f8110c5e69fe44d035d850b2dfe795e196f00fdb774bdd"}, ] [package.dependencies] certifi = "*" -httpcore = ">=0.18.0,<0.19.0" +httpcore = ">=0.15.0,<0.18.0" idna = "*" sniffio = "*" @@ -3062,6 +3079,17 @@ MarkupSafe = ">=2.1.1" [package.extras] watchdog = ["watchdog (>=2.3)"] +[[package]] +name = "xdg" +version = "6.0.0" +description = "Variables defined by the XDG Base Directory Specification" +optional = false +python-versions = ">=3.7,<4.0" +files = [ + {file = "xdg-6.0.0-py3-none-any.whl", hash = "sha256:df3510755b4395157fc04fc3b02467c777f3b3ca383257397f09ab0d4c16f936"}, + {file = "xdg-6.0.0.tar.gz", hash = "sha256:24278094f2d45e846d1eb28a2ebb92d7b67fc0cab5249ee3ce88c95f649a1c92"}, +] + [[package]] name = "zipp" version = "3.17.0" diff --git a/pyproject.toml b/pyproject.toml index 61b45c48..db139b81 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ rich = "^13.4.2" typer = {extras = ["all"], version = "^0.9.0"} pyastgrep = "^1.2.2" trogon = "^0.5.0" +frogmouth = "^0.9.0" pydantic = "^2.0.3" platformdirs = "^3.8.1" pyyaml = "^6.0" diff --git a/scripts/extract_coverage.py b/scripts/extract_coverage.py index 680c65ca..53d9be36 100644 --- a/scripts/extract_coverage.py +++ b/scripts/extract_coverage.py @@ -12,5 +12,5 @@ filename = "chasten/util.py" covered_lines = set(data.lines(filename)) # type: ignore -print(f"Covered lines in {filename}:") # noqa +print(f"Covered lines in {filename}:") # noqa print(covered_lines) # noqa diff --git a/tests/test_database.py b/tests/test_database.py deleted file mode 100644 index dd1ad2c3..00000000 --- a/tests/test_database.py +++ /dev/null @@ -1,9 +0,0 @@ -"""Pytest test suite for the database module.""" - -from chasten import database, filesystem, util - - -def test_executable_name() -> None: - assert filesystem.can_find_executable( - database.executable_name(OpSystem=util.get_OS()) - ) diff --git a/tests/test_main.py b/tests/test_main.py index b7175b72..caf7e8f4 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -395,3 +395,119 @@ def test_fuzz_cli_analyze_single_directory(cwd, directory): ], ) assert result.exit_code == 0 + + +def test_analyze_store_results_file_does_not_exist(cwd, tmpdir): + """Makes sure analyze doesn't crash when using markdown storage.""" + tmp_dir = Path(tmpdir) + project_name = "testing" + # create a reference to the internal + # .chasten directory that supports testing + configuration_directory = str(cwd) + "/.chasten" + result = runner.invoke( + main.cli, + [ + "analyze", + "--search-path", + cwd, + project_name, + "--config", + configuration_directory, + "--markdown-storage", + tmp_dir, + ], + ) + assert result.exit_code == 0 + assert "✨ Results saved in:" in result.output + + +def test_analyze_store_results_file_exists_no_force(cwd, tmpdir): + """Make sure Analyze acts accordingly when file exists and their is no force""" + tmp_dir = Path(tmpdir) + # creates a temporary directory to store markdown file + file = tmp_dir / "analysis.md" + # creates file if does not exist + file.touch() + # makes sure the file exists + assert file.exists() + project_name = "testing" + # create a reference to the internal + # .chasten directory that supports testing + configuration_directory = str(cwd) + "/.chasten" + # runs the CLI with the specified commands + result = runner.invoke( + main.cli, + [ + "analyze", + "--search-path", + cwd, + project_name, + "--config", + configuration_directory, + "--markdown-storage", + tmp_dir, + ], + ) + # assert that the code crashes and that the proper message is displayed + assert result.exit_code == 1 + assert ( + "File already exists: use --force to recreate markdown directory." + in result.output + ) + + +def test_analyze_store_results_file_exists_force(cwd, tmpdir): + tmp_dir = Path(tmpdir) + # creates a temporary directory to store markdown file + file = tmp_dir / "analysis.md" + # creates file if does not exist + file.touch() + # makes sure the file exists + assert file.exists() + project_name = "testing" + # create a reference to the internal + # .chasten directory that supports testing + configuration_directory = str(cwd) + "/.chasten" + # runs the CLI with the specified commands + result = runner.invoke( + main.cli, + [ + "analyze", + "--search-path", + cwd, + project_name, + "--config", + configuration_directory, + "--markdown-storage", + tmp_dir, + "--force", + ], + ) + # assert that the code crashes and that the proper message is displayed + assert result.exit_code == 0 + assert "✨ Results saved in:" in result.output + + +@given(directory=strategies.builds(Path)) +@settings(deadline=None, suppress_health_check=[HealthCheck.function_scoped_fixture]) +@pytest.mark.fuzz +def test_analyze_store_results_valid_path(directory, cwd): + project_name = "testing" + # create a reference to the internal + # .chasten directory that supports testing + configuration_directory = str(cwd) + "/.chasten" + result = runner.invoke( + main.cli, + [ + "analyze", + "--search-path", + cwd, + project_name, + "--config", + configuration_directory, + "--markdown-storage", + directory, + "--force", + ], + ) + assert result.exit_code == 0 diff --git a/tests/test_util.py b/tests/test_util.py index 23b3b918..a4fbf722 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -1,9 +1,11 @@ """Pytest test suite for the util module.""" +import shutil + import pytest from hypothesis import given, strategies, provisional -from chasten import util +from chasten import constants, util def test_human_readable_boolean() -> None: @@ -36,3 +38,15 @@ def test_is_url_correct(url: str) -> None: """Use Hypothesis to confirm that URLs are correctly recognized/unrecognized.""" result = util.is_url(url=url) assert result == True + + +OpSystem = util.get_OS() +datasette_exec = constants.datasette.Datasette_Executable + + +def test_executable_name() -> None: + """Test if executable name gets correct file name""" + # makes sure the datasette executable is where expected + assert shutil.which(util.executable_name(datasette_exec, OpSystem)) + # makes sure the frogmouth executable is where expected + assert shutil.which(util.executable_name("frogmouth", OpSystem))