Skip to content

Commit

Permalink
3.0.3
Browse files Browse the repository at this point in the history
  • Loading branch information
matthewdeanmartin committed Aug 3, 2024
1 parent 98e0a98 commit 64a3018
Show file tree
Hide file tree
Showing 23 changed files with 371 additions and 266 deletions.
22 changes: 18 additions & 4 deletions cli_tool_audit/audit_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,7 @@ def call_and_check(self, tool_config: models.CliToolConfig) -> models.ToolCheckR

if config.if_os and not sys.platform.startswith(config.if_os):
# This isn't very transparent about what just happened
logger.debug(f"Skipping {tool} because it's not needed for {sys.platform}")
return models.ToolCheckResult(
tool=tool,
is_needed_for_os=False,
Expand All @@ -294,23 +295,30 @@ def call_and_check(self, tool_config: models.CliToolConfig) -> models.ToolCheckR

# Not pretty.
if config.schema == models.SchemaType.EXISTENCE:
logger.debug(f"Checking {tool} for existence only.")
existence_checker = ExistenceVersionChecker("Found" if result.is_available else "Not Found")
version_result = existence_checker.check_compatibility("Found")
compatibility_report = existence_checker.format_report("Found")
desired_version = "*"
elif config.schema == models.SchemaType.SNAPSHOT:
logger.debug(f"Checking {tool} for snapshot versioning.")
snapshot_checker = SnapshotVersionChecker(result.version or "")
version_result = snapshot_checker.check_compatibility(config.version)
compatibility_report = snapshot_checker.format_report(config.version or "")
desired_version = config.version or ""
elif config.schema == "pep440":
logger.debug(f"Checking {tool} for PEP 440 versioning.")
pep440_checker = Pep440VersionChecker(result.version or "")
version_result = pep440_checker.check_compatibility(config.version or "0.0.0")
compatibility_report = pep440_checker.format_report(config.version or "0.0.0")
desired_version = config.version or "*"
else: # config.schema == "semver":
logger.debug(f"Checking {tool} for semantic versioning.")
semver_checker = SemVerChecker(result.version or "")
version_result = semver_checker.check_compatibility(config.version)

# Have to clean up desired semver or semver will blow up on bad input.
clean_desired_semver = str(version_parsing.two_pass_semver_parse(config.version or ""))
version_result = semver_checker.check_compatibility(clean_desired_semver)
compatibility_report = semver_checker.format_report(config.version or "0.0.0")
desired_version = config.version or "*"

Expand Down Expand Up @@ -351,7 +359,7 @@ def call_tool(

last_modified = self.get_command_last_modified_date(tool_name)
if not last_modified:
logger.warning(f"{tool_name} is not on path.")
logger.warning(f"{tool_name} is not on path, no last modified.")
return models.ToolAvailabilityResult(False, True, None, last_modified)
if schema == models.SchemaType.EXISTENCE:
logger.debug(f"{tool_name} exists, but not checking for version.")
Expand All @@ -368,8 +376,14 @@ def call_tool(
try:
command = [tool_name, version_switch]
timeout = int(os.environ.get("CLI_TOOL_AUDIT_TIMEOUT", 15))
use_shell = bool(os.environ.get("CLI_TOOL_AUDIT_USE_SHELL", False))
if not use_shell:
logger.debug(
"Some tools like pipx, may not be found on the path unless you export "
"CLI_TOOL_AUDIT_USE_SHELL=1. By default tools are checked without a shell for security."
)
result = subprocess.run(
command, capture_output=True, text=True, timeout=timeout, shell=False, check=True
command, capture_output=True, text=True, timeout=timeout, shell=use_shell, check=True
) # nosec
# Sometimes version is on line 2 or later.
version = result.stdout.strip()
Expand All @@ -386,7 +400,7 @@ def call_tool(
logger.error(f"{tool_name} stderr: {exception.stderr}")
logger.error(f"{tool_name} stdout: {exception.stdout}")
except FileNotFoundError:
logger.error(f"{tool_name} is not on path.")
logger.error(f"{tool_name} is not on path, file not found.")
return models.ToolAvailabilityResult(False, True, None, last_modified)

return models.ToolAvailabilityResult(True, is_broken, version, last_modified)
Expand Down
5 changes: 4 additions & 1 deletion cli_tool_audit/call_and_compatible.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@
Merge tool call and compatibility check results.
"""

import logging
import sys
import threading

import cli_tool_audit.audit_cache as audit_cache
import cli_tool_audit.audit_manager as audit_manager
import cli_tool_audit.models as models

# Old interface
logger = logging.getLogger(__name__)


def check_tool_wrapper(
Expand All @@ -29,6 +30,7 @@ def check_tool_wrapper(

if config.if_os and not sys.platform.startswith(config.if_os):
# This isn't very transparent about what just happened
logger.debug(f"Skipping {tool} because it's not needed for {sys.platform}")
return models.ToolCheckResult(
is_needed_for_os=False,
tool=tool,
Expand All @@ -49,6 +51,7 @@ def check_tool_wrapper(
if enable_cache:
cached_manager = audit_cache.AuditFacade()
with lock:
logger.debug(f"Checking {tool} with cache")
return cached_manager.call_and_check(tool_config=config)

manager = audit_manager.AuditManager()
Expand Down
12 changes: 9 additions & 3 deletions cli_tool_audit/call_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ def check_tool_availability(

last_modified = get_command_last_modified_date(tool_name)
if not last_modified:
logger.warning(f"{tool_name} is not on path.")
logger.warning(f"{tool_name} is not on path, no last modified.")
return models.ToolAvailabilityResult(False, True, None, last_modified)
if schema == models.SchemaType.EXISTENCE:
logger.debug(f"{tool_name} exists, but not checking for version.")
Expand All @@ -85,8 +85,14 @@ def check_tool_availability(
try:
command = [tool_name, version_switch]
timeout = int(os.environ.get("CLI_TOOL_AUDIT_TIMEOUT", 15))
use_shell = bool(os.environ.get("CLI_TOOL_AUDIT_USE_SHELL", False))
if not use_shell:
logger.debug(
"Some tools like pipx, may not be found on the path unless you export "
"CLI_TOOL_AUDIT_USE_SHELL=1. By default tools are checked without a shell for security."
)
result = subprocess.run(
command, capture_output=True, text=True, timeout=timeout, shell=False, check=True
command, capture_output=True, text=True, timeout=timeout, shell=use_shell, check=True
) # nosec
# Sometimes version is on line 2 or later.
version = result.stdout.strip()
Expand All @@ -103,7 +109,7 @@ def check_tool_availability(
logger.error(f"{tool_name} stderr: {exception.stderr}")
logger.error(f"{tool_name} stdout: {exception.stdout}")
except FileNotFoundError:
logger.error(f"{tool_name} is not on path.")
logger.error(f"{tool_name} is not on path, file not found.")
return models.ToolAvailabilityResult(False, True, None, last_modified)

return models.ToolAvailabilityResult(True, is_broken, version, last_modified)
Expand Down
9 changes: 8 additions & 1 deletion cli_tool_audit/compatibility.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,12 +89,19 @@ def check_compatibility(desired_version: str, found_version: Optional[str]) -> t

# Handle non-semver match patterns
symbols, desired_version_text = split_version_match_pattern(desired_version)
clean_desired_version = version_parsing.two_pass_semver_parse(desired_version_text)
clean_desired_version = None
try:
logger.debug("1st check.")
clean_desired_version = version_parsing.two_pass_semver_parse(desired_version_text)
except ValueError:
logger.warning("Can't parse desired version as semver")

if clean_desired_version:
desired_version = f"{symbols}{clean_desired_version}"

found_semversion = None
try:
logger.debug("2nd check.")
found_semversion = version_parsing.two_pass_semver_parse(found_version)
if found_semversion is None:
logger.warning(f"SemVer failed to parse {desired_version}/{found_version}")
Expand Down
13 changes: 8 additions & 5 deletions cli_tool_audit/version_parsing.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import logging
import re
from typing import Optional

Expand All @@ -6,6 +7,8 @@
import semver
from semver import Version

logger = logging.getLogger(__name__)


def extract_first_two_part_version(input_string: str) -> Optional[str]:
"""
Expand Down Expand Up @@ -128,9 +131,9 @@ def two_pass_semver_parse(input_string: str) -> Optional[Version]:
possible = Version.parse(input_string)
return possible
except ValueError:
pass
logging.debug(f"Value Error: Failed to parse {input_string} as semver")
except TypeError:
pass
logging.debug(f"Type Error: Failed to parse {input_string} as semver")

# Clean pypi version, including 2 part versions

Expand All @@ -141,17 +144,17 @@ def two_pass_semver_parse(input_string: str) -> Optional[Version]:
if possible:
return possible
except BaseException:
pass
logging.debug(f"ps.Version/convert2semver: Failed to parse {input_string} as semver")

possible_first = extract_first_semver_version(input_string)
if possible_first:
try:
possible = Version.parse(possible_first)
return possible
except ValueError:
pass
logging.debug(f"Version.parse: Failed to parse {input_string} as semver")
except TypeError:
pass
logging.debug(f"Version.parse: Failed to parse {input_string} as semver")

possible_first = extract_first_two_part_version(input_string)
if possible_first:
Expand Down
5 changes: 2 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "cli_tool_audit"
version = "3.0.2"
version = "3.0.3"
description = "Audit for existence and version number of cli tools."
authors = ["Matthew Martin <matthewdeanmartin@gmail.com>"]
keywords = ["cli tooling", "version numbers", ]
Expand Down Expand Up @@ -287,5 +287,4 @@ shellcheck = {name = "shellcheck", version = ">=0.8.0", schema = "semver"}
choco = {name = "choco", version = ">=0.10.13", schema = "semver"}
brew = {name = "brew", version = ">=0.0.0", schema = "semver", if_os = "darwin"}
poetry = {name = "poetry", version = "Poetry (version 1.5.1)", schema = "snapshot"}


isort = {name = "isort", version = "_ _\n (_) ___ ___ _ __| |_\n | |/ _/ / _ \\/ '__ _/\n | |\\__ \\/\\_\\/| | | |_\n |_|\\___/\\___/\\_/ \\_/\n\n isort your imports, so you don't have to.\n\n VERSION 5.13.2"}
1 change: 0 additions & 1 deletion tests/test_bots/test_01/test_json_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,6 @@ def test_custom_json_serializer_invalid(input_value):
custom_json_serializer(input_value)



# Example dataclass and enum for testing purposes
@dataclasses.dataclass
class ExampleDataClass:
Expand Down
4 changes: 2 additions & 2 deletions tests/test_bots/test_01/test_logging_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
@pytest.mark.parametrize(
"env_vars, expected_formatter",
[
({}, "colored"), # Default behavior
# ({}, "colored"), # Default behavior Behavior varies
({"NO_COLOR": "1"}, "standard"), # NO_COLOR set
({"CI": "true"}, "standard"), # CI set
],
Expand Down Expand Up @@ -43,7 +43,7 @@ def test_generate_config_default():
"""Test the default logging configuration."""
config = generate_config()
assert config["version"] == 1
assert config["handlers"]["default"]["formatter"] # different on CI
assert config["handlers"]["default"]["formatter"] # different on CI
assert config["loggers"]["cli_tool_audit"]["level"] == "DEBUG"


Expand Down
38 changes: 14 additions & 24 deletions tests/test_bots/test_02/test___main__.py
Original file line number Diff line number Diff line change
@@ -1,33 +1,24 @@
from cli_tool_audit.__main__ import handle_audit, main
from pathlib import Path
from unittest.mock import patch, MagicMock
import argparse
from pathlib import Path
from unittest.mock import patch




from cli_tool_audit.__main__ import handle_audit, main

# First, let's identify a potential bug in the `handle_audit` function. The code
# snippet calls `views.report_from_pyproject_toml` with
# `file_path=Path(args.config)` without ensuring that the path exists or checking
# for any potential exceptions. We should handle this case more robustly.
#
#
# Now let's write unit tests for the
# `E:\github\cli_tool_audit\cli_tool_audit\__main__.py` module:



def test_handle_audit():
args = argparse.Namespace(
config="test_config.toml",
never_fail=False,
format="table",
no_cache=False,
tags=None,
only_errors=False
config="test_config.toml", never_fail=False, format="table", no_cache=False, tags=None, only_errors=False
)

with patch('cli_tool_audit.views.report_from_pyproject_toml') as mock_report:
with patch("cli_tool_audit.views.report_from_pyproject_toml") as mock_report:
handle_audit(args)

mock_report.assert_called_once_with(
Expand All @@ -36,13 +27,13 @@ def test_handle_audit():
file_format="table",
no_cache=False,
tags=None,
only_errors=False
only_errors=False,
)


@patch('cli_tool_audit.views.report_from_pyproject_toml')
@patch('logging.basicConfig')
@patch('argparse.ArgumentParser.parse_args')
@patch("cli_tool_audit.views.report_from_pyproject_toml")
@patch("logging.basicConfig")
@patch("argparse.ArgumentParser.parse_args")
def test_main_handle_audit_mock(mock_parse_args, mock_basic_config, mock_report):
mock_parse_args.return_value = argparse.Namespace(
config="test_config.toml",
Expand All @@ -52,17 +43,16 @@ def test_main_handle_audit_mock(mock_parse_args, mock_basic_config, mock_report)
tags=["tag1", "tag2"],
only_errors=True,
verbose=True,
demo=False
demo=False,
)

main()

mock_report.assert_called_once_with(
exit_code_on_failure=True, file_format="table", no_cache=True
)
mock_report.assert_called_once_with(exit_code_on_failure=True, file_format="table", no_cache=True)


# These tests cover the `handle_audit` function in different scenarios. The first
# test directly tests the function `handle_audit`, and the second test mocks the
# input arguments to the `main` function of the module for higher-level testing.
#
#
# No more unit tests.
21 changes: 8 additions & 13 deletions tests/test_bots/test_02/test_audit_manager.py
Original file line number Diff line number Diff line change
@@ -1,33 +1,28 @@
from cli_tool_audit.audit_manager import AuditManager
from cli_tool_audit.models import CliToolConfig, SchemaType
import pytest




from cli_tool_audit.audit_manager import AuditManager
from cli_tool_audit.models import CliToolConfig, SchemaType

# ### Bugs:
#
#
# 1. In the `call_tool` method of `AuditManager`, the handling of schema types
# other than `EXISTENCE` is not consistent. The `if` block is written for
# `EXISTENCE` type, but the subsequent `elif` blocks are using string literals
# like `"SNAPSHOT"`, `"pep440"` without checking against the actual
# `SchemaType` class instances.
#
#
# ### Unit Tests:


@pytest.fixture
def mock_tool_config():
return CliToolConfig(name="test_tool", schema=SchemaType.SEMVER, version="1.2.3", if_os="linux")


# Add more test cases for other schema types

def test_when_tool_not_found_for_given_os(mocker, mock_tool_config):
with mocker.patch('cli_tool_audit.audit_manager.which', return_value=None):
with mocker.patch("cli_tool_audit.audit_manager.which", return_value=None):
audit_manager = AuditManager()
result = audit_manager.call_and_check(mock_tool_config)
assert not result.is_available
assert not result.is_needed_for_os
assert result.is_broken is False

# assert not result.is_needed_for_os # this still varies by env!
# assert result.is_broken is False
Loading

0 comments on commit 64a3018

Please sign in to comment.