diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 18f358a..ec10e7e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,7 +29,7 @@ jobs: - name: Test with tox run: tox - lint-and-format: + lint-format-and-type-check: runs-on: ubuntu-latest steps: @@ -50,3 +50,6 @@ jobs: - name: Check formatting with ruff run: tox -e format-check + + - name: Type check with mypy + run: tox -e mypy diff --git a/pyproject.toml b/pyproject.toml index 6086ed6..b850a98 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ authors = [ description = "Interactive sorting utility for text lines with various sorting options" readme = "README.md" license = {text = "MIT"} -requires-python = ">=3.8" +requires-python = ">=3.10" classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", @@ -19,8 +19,6 @@ classifiers = [ "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", @@ -46,7 +44,7 @@ where = ["src"] # Ruff configuration [tool.ruff] line-length = 120 -target-version = "py38" +target-version = "py310" [tool.ruff.lint] select = [ @@ -68,4 +66,21 @@ ignore = [ quote-style = "double" indent-style = "space" skip-magic-trailing-comma = false -line-ending = "auto" \ No newline at end of file +line-ending = "auto" + +# mypy configuration +[tool.mypy] +python_version = "3.10" +strict = true +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = true +disallow_incomplete_defs = true +check_untyped_defs = true +disallow_untyped_decorators = true +no_implicit_optional = true +warn_redundant_casts = true +warn_unused_ignores = true +warn_no_return = true +warn_unreachable = true +strict_equality = true \ No newline at end of file diff --git a/src/interactive_sort/__init__.py b/src/interactive_sort/__init__.py index b1bbb87..a2dc5e9 100644 --- a/src/interactive_sort/__init__.py +++ b/src/interactive_sort/__init__.py @@ -1,9 +1,9 @@ """Interactive Sort - A tool for interactively sorting lines of text.""" -__version__ = "1.0.0" -__author__ = "MightyHelper" -__description__ = "Interactive sorting utility for text lines with various sorting options" +__version__: str = "1.0.0" +__author__: str = "MightyHelper" +__description__: str = "Interactive sorting utility for text lines with various sorting options" from .main import InteractiveSort, main -__all__ = ["InteractiveSort", "main"] +__all__: list[str] = ["InteractiveSort", "main"] diff --git a/src/interactive_sort/main.py b/src/interactive_sort/main.py index a805b28..08af66d 100755 --- a/src/interactive_sort/main.py +++ b/src/interactive_sort/main.py @@ -4,14 +4,16 @@ import os import re import sys +from collections.abc import Callable from functools import cmp_to_key +from typing import Any class InteractiveSort: - def __init__(self, args): - self.data = [] - self.args = args - self.month_map = { + def __init__(self, args: argparse.Namespace) -> None: + self.data: list[str] = [] + self.args: argparse.Namespace = args + self.month_map: dict[str, int] = { "JAN": 1, "FEB": 2, "MAR": 3, @@ -26,11 +28,11 @@ def __init__(self, args): "DEC": 12, } - def get_sort_key(self, item): + def get_sort_key(self, item: str) -> str: """Generates a key for sorting based on the provided arguments.""" - key = item + key: str = item if self.args.field_separator: - fields = key.split(self.args.field_separator) + fields: list[str] = key.split(self.args.field_separator) if fields: key = fields[0] else: @@ -42,35 +44,39 @@ def get_sort_key(self, item): key = "".join(filter(str.isprintable, key)) return key - def month_sort_key(self, item): + def month_sort_key(self, item: str) -> int: """Extracts and converts the month from the beginning of the string (or first field).""" - text_to_check = item + text_to_check: str = item if self.args.field_separator: - fields = item.split(self.args.field_separator) + fields: list[str] = item.split(self.args.field_separator) if fields: text_to_check = fields[0] else: return 0 - match = re.match(r"^(JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC)\s*", text_to_check, re.IGNORECASE) + match: re.Match[str] | None = re.match( + r"^(JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC)\s*", text_to_check, re.IGNORECASE + ) if match: return self.month_map.get(match.group(1).upper(), 0) # 0 for unknown return 0 # Treat lines without a month at the beginning as "unknown" - def human_numeric_sort_key(self, item): + def human_numeric_sort_key(self, item: str) -> float | str: """Converts a human-readable number string to a comparable tuple (or from the first field).""" - text_to_check = item + text_to_check: str = item if self.args.field_separator: - fields = item.split(self.args.field_separator) + fields: list[str] = item.split(self.args.field_separator) if fields: text_to_check = fields[0] else: return item # Fallback - parts = re.findall(r"(\d+\.?\d*)([KMGTPEZY]?)", text_to_check, re.IGNORECASE) + parts: list[tuple[str, str]] = re.findall(r"(\d+\.?\d*)([KMGTPEZY]?)", text_to_check, re.IGNORECASE) if parts: - value, unit = parts[0] - value = float(value) + value_str: str + unit: str + value_str, unit = parts[0] + value: float = float(value_str) unit = unit.upper() if unit == "K": value *= 1024 @@ -94,11 +100,11 @@ def human_numeric_sort_key(self, item): except ValueError: return text_to_check # Keep original string if not convertible to number - def numeric_sort_key(self, item): + def numeric_sort_key(self, item: str) -> float | str: """Tries to convert the item (or first field) to a float for numeric sorting.""" - text_to_check = item + text_to_check: str = item if self.args.field_separator: - fields = item.split(self.args.field_separator) + fields: list[str] = item.split(self.args.field_separator) if fields: text_to_check = fields[0] else: @@ -109,51 +115,48 @@ def numeric_sort_key(self, item): except ValueError: return text_to_check # Keep original string if not a valid number - def compare_items(self, item1, item2): + def compare_items(self, item1: str, item2: str) -> int: """Compares two items based on the active sorting options.""" if self.args.month_sort: - key1 = self.month_sort_key(item1) - key2 = self.month_sort_key(item2) + key1: int = self.month_sort_key(item1) + key2: int = self.month_sort_key(item2) if key1 != key2: return key1 - key2 if self.args.human_numeric_sort: - key1 = self.human_numeric_sort_key(item1) - key2 = self.human_numeric_sort_key(item2) - if isinstance(key1, (int, float)) and isinstance(key2, (int, float)): - if key1 != key2: - return key1 - key2 + key1_human: float | str = self.human_numeric_sort_key(item1) + key2_human: float | str = self.human_numeric_sort_key(item2) + if isinstance(key1_human, int | float) and isinstance(key2_human, int | float): + if key1_human != key2_human: + return int(key1_human - key2_human) if self.args.numeric_sort: - key1 = self.numeric_sort_key(item1) - key2 = self.numeric_sort_key(item2) - if isinstance(key1, (int, float)) and isinstance(key2, (int, float)): - if key1 != key2: - return key1 - key2 + key1_numeric: float | str = self.numeric_sort_key(item1) + key2_numeric: float | str = self.numeric_sort_key(item2) + if isinstance(key1_numeric, int | float) and isinstance(key2_numeric, int | float): + if key1_numeric != key2_numeric: + return int(key1_numeric - key2_numeric) # Default string comparison (after applying ignore-case and ignore-nonprinting) - key1 = self.get_sort_key(item1) - key2 = self.get_sort_key(item2) - if key1 < key2: + key1_str: str = self.get_sort_key(item1) + key2_str: str = self.get_sort_key(item2) + if key1_str < key2_str: return -1 - elif key1 > key2: + elif key1_str > key2_str: return 1 else: return 0 - def add_item(self, item): + def add_item(self, item: str) -> None: """Adds an item to the sorted data using a custom comparison.""" if not self.args.month_sort and not self.args.human_numeric_sort and not self.args.numeric_sort: - - def key_func(x): - return self.get_sort_key(x) - + key_func: Callable[[str], Any] = self.get_sort_key else: key_func = cmp_to_key(self.compare_items) bisect.insort_left(self.data, item, key=key_func) - def display_sorted(self): + def display_sorted(self) -> None: """Displays the current sorted data.""" output_stream = sys.stderr if self.args.stderr else sys.stdout @@ -161,38 +164,38 @@ def display_sorted(self): os.system("cls" if os.name == "nt" else "clear") print("--- Interactive Sorted Data ---", file=output_stream) - items_to_display = self.data + items_to_display: list[str] = self.data if self.args.unique: items_to_display = sorted( set(items_to_display), key=lambda x: self.data.index(x) ) # Maintain original order of first occurrence if self.args.reverse: - items_to_display = reversed(items_to_display) + items_to_display = list(reversed(items_to_display)) for item in items_to_display: print(item, file=output_stream) print("-----------------------------", file=output_stream) - def output_final_sorted(self): + def output_final_sorted(self) -> None: """Outputs the final sorted data to stdout (used when stderr flag is set).""" - items_to_display = self.data + items_to_display: list[str] = self.data if self.args.unique: items_to_display = sorted( set(items_to_display), key=lambda x: self.data.index(x) ) # Maintain original order of first occurrence if self.args.reverse: - items_to_display = reversed(items_to_display) + items_to_display = list(reversed(items_to_display)) for item in items_to_display: print(item, file=sys.stdout) - def run(self): + def run(self) -> None: """Runs the interactive sort application.""" output_stream = sys.stderr if self.args.stderr else sys.stdout print("Enter lines of text. Press Ctrl+D (or Ctrl+Z then Enter on Windows) to finish.", file=output_stream) try: while True: - line = input() + line: str = input() self.add_item(line) self.display_sorted() except EOFError: @@ -205,9 +208,9 @@ def run(self): print("\nExiting.", file=output_stream) -def main(): +def main() -> None: """Main entry point for the interactive-sort command.""" - parser = argparse.ArgumentParser(description="Interactively sort input lines.") + parser: argparse.ArgumentParser = argparse.ArgumentParser(description="Interactively sort input lines.") parser.add_argument("-f", "--ignore-case", action="store_true", help="fold lower case to upper case characters") parser.add_argument("-i", "--ignore-nonprinting", action="store_true", help="consider only printable characters") parser.add_argument("-M", "--month-sort", action="store_true", help="compare (unknown) < 'JAN' < ... < 'DEC'") @@ -224,10 +227,11 @@ def main(): default=None, help="use FIELD as the field separator when finding an ordering key", ) + parser.add_argument("--stderr", action="store_true", help="output to stderr for debugging") - args = parser.parse_args() + args: argparse.Namespace = parser.parse_args() - sorter = InteractiveSort(args) + sorter: InteractiveSort = InteractiveSort(args) sorter.run() diff --git a/tests/test_interactive_sort.py b/tests/test_interactive_sort.py index 6779953..436da64 100644 --- a/tests/test_interactive_sort.py +++ b/tests/test_interactive_sort.py @@ -8,9 +8,9 @@ class TestInteractiveSort: """Test cases for InteractiveSort class.""" - def setup_method(self): + def setup_method(self) -> None: """Set up test fixtures.""" - self.default_args = argparse.Namespace( + self.default_args: argparse.Namespace = argparse.Namespace( ignore_case=False, ignore_nonprinting=False, month_sort=False, @@ -19,94 +19,95 @@ def setup_method(self): reverse=False, unique=False, field_separator=None, + stderr=False, ) - def test_basic_sorting(self): + def test_basic_sorting(self) -> None: """Test basic alphabetical sorting.""" - sorter = InteractiveSort(self.default_args) + sorter: InteractiveSort = InteractiveSort(self.default_args) sorter.add_item("banana") sorter.add_item("apple") sorter.add_item("cherry") assert sorter.data == ["apple", "banana", "cherry"] - def test_case_insensitive_sorting(self): + def test_case_insensitive_sorting(self) -> None: """Test case-insensitive sorting.""" - args = argparse.Namespace(**{**vars(self.default_args), "ignore_case": True}) - sorter = InteractiveSort(args) + args: argparse.Namespace = argparse.Namespace(**{**vars(self.default_args), "ignore_case": True}) + sorter: InteractiveSort = InteractiveSort(args) sorter.add_item("Banana") sorter.add_item("apple") sorter.add_item("Cherry") assert sorter.data == ["apple", "Banana", "Cherry"] - def test_numeric_sorting(self): + def test_numeric_sorting(self) -> None: """Test numeric sorting.""" - args = argparse.Namespace(**{**vars(self.default_args), "numeric_sort": True}) - sorter = InteractiveSort(args) + args: argparse.Namespace = argparse.Namespace(**{**vars(self.default_args), "numeric_sort": True}) + sorter: InteractiveSort = InteractiveSort(args) sorter.add_item("10") sorter.add_item("2") sorter.add_item("100") assert sorter.data == ["2", "10", "100"] - def test_human_numeric_sorting(self): + def test_human_numeric_sorting(self) -> None: """Test human-readable numeric sorting.""" - args = argparse.Namespace(**{**vars(self.default_args), "human_numeric_sort": True}) - sorter = InteractiveSort(args) + args: argparse.Namespace = argparse.Namespace(**{**vars(self.default_args), "human_numeric_sort": True}) + sorter: InteractiveSort = InteractiveSort(args) sorter.add_item("1K") sorter.add_item("100") sorter.add_item("2M") assert sorter.data == ["100", "1K", "2M"] - def test_month_sorting(self): + def test_month_sorting(self) -> None: """Test month-based sorting.""" - args = argparse.Namespace(**{**vars(self.default_args), "month_sort": True}) - sorter = InteractiveSort(args) + args: argparse.Namespace = argparse.Namespace(**{**vars(self.default_args), "month_sort": True}) + sorter: InteractiveSort = InteractiveSort(args) sorter.add_item("MAR data") sorter.add_item("JAN data") sorter.add_item("FEB data") assert sorter.data == ["JAN data", "FEB data", "MAR data"] - def test_get_sort_key(self): + def test_get_sort_key(self) -> None: """Test sort key generation.""" - sorter = InteractiveSort(self.default_args) + sorter: InteractiveSort = InteractiveSort(self.default_args) assert sorter.get_sort_key("test") == "test" # Test case insensitive - args = argparse.Namespace(**{**vars(self.default_args), "ignore_case": True}) + args: argparse.Namespace = argparse.Namespace(**{**vars(self.default_args), "ignore_case": True}) sorter = InteractiveSort(args) assert sorter.get_sort_key("TEST") == "test" - def test_field_separator(self): + def test_field_separator(self) -> None: """Test field separator functionality.""" - args = argparse.Namespace(**{**vars(self.default_args), "field_separator": ","}) - sorter = InteractiveSort(args) + args: argparse.Namespace = argparse.Namespace(**{**vars(self.default_args), "field_separator": ","}) + sorter: InteractiveSort = InteractiveSort(args) assert sorter.get_sort_key("second,first") == "second" - def test_month_sort_key(self): + def test_month_sort_key(self) -> None: """Test month sort key extraction.""" - sorter = InteractiveSort(self.default_args) + sorter: InteractiveSort = InteractiveSort(self.default_args) assert sorter.month_sort_key("JAN data") == 1 assert sorter.month_sort_key("DEC data") == 12 assert sorter.month_sort_key("invalid") == 0 - def test_numeric_sort_key(self): + def test_numeric_sort_key(self) -> None: """Test numeric sort key extraction.""" - sorter = InteractiveSort(self.default_args) + sorter: InteractiveSort = InteractiveSort(self.default_args) assert sorter.numeric_sort_key("123") == 123.0 assert sorter.numeric_sort_key("12.5") == 12.5 assert sorter.numeric_sort_key("not_a_number") == "not_a_number" - def test_human_numeric_sort_key(self): + def test_human_numeric_sort_key(self) -> None: """Test human-readable numeric sort key extraction.""" - sorter = InteractiveSort(self.default_args) + sorter: InteractiveSort = InteractiveSort(self.default_args) assert sorter.human_numeric_sort_key("1K") == 1024 assert sorter.human_numeric_sort_key("2M") == 2 * 1024 * 1024 diff --git a/tox.ini b/tox.ini index 9695845..66fb1b8 100644 --- a/tox.ini +++ b/tox.ini @@ -1,11 +1,9 @@ [tox] -envlist = py38,py39,py310,py311,py312,lint,format-check +envlist = py310,py311,py312,lint,format-check,mypy isolated_build = true [gh-actions] python = - 3.8: py38 - 3.9: py39 3.10: py310 3.11: py311 3.12: py312 @@ -33,10 +31,16 @@ description = Check code formatting with ruff deps = ruff commands = ruff format --check src/ tests/ +[testenv:mypy] +description = Run mypy type checking +deps = mypy +commands = mypy src/ tests/ + [testenv:dev] description = Development environment deps = pytest pytest-cov ruff + mypy commands = python -m pip install -e . \ No newline at end of file