Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ jobs:
- name: Test with tox
run: tox

lint-and-format:
lint-format-and-type-check:
runs-on: ubuntu-latest

steps:
Expand All @@ -50,3 +50,6 @@ jobs:

- name: Check formatting with ruff
run: tox -e format-check

- name: Type check with mypy
run: tox -e mypy
25 changes: 20 additions & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,14 @@ 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",
"Intended Audience :: End Users/Desktop",
"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",
Expand All @@ -46,7 +44,7 @@ where = ["src"]
# Ruff configuration
[tool.ruff]
line-length = 120
target-version = "py38"
target-version = "py310"

[tool.ruff.lint]
select = [
Expand All @@ -68,4 +66,21 @@ ignore = [
quote-style = "double"
indent-style = "space"
skip-magic-trailing-comma = false
line-ending = "auto"
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
8 changes: 4 additions & 4 deletions src/interactive_sort/__init__.py
Original file line number Diff line number Diff line change
@@ -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"]
110 changes: 56 additions & 54 deletions src/interactive_sort/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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:
Expand All @@ -42,35 +44,37 @@ 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
Expand All @@ -94,11 +98,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:
Expand All @@ -109,90 +113,87 @@ 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

if not self.args.stderr:
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:
Expand All @@ -205,9 +206,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'")
Expand All @@ -224,10 +225,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()


Expand Down
Loading
Loading