Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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"]
112 changes: 58 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,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
Expand All @@ -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:
Expand All @@ -109,90 +115,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 +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'")
Expand All @@ -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()


Expand Down
Loading