diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6b7ebcc77c15..9b4488f78310 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -29,18 +29,18 @@ repos: - tomli - repo: https://github.com/tox-dev/pyproject-fmt - rev: v2.30.1 + rev: v2.30.3 hooks: - id: pyproject-fmt - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.16.10 + rev: v0.17.3 hooks: - id: ruff-check - id: ruff-format - repo: https://github.com/rvben/rumdl-pre-commit - rev: v0.2.78 + rev: v0.3.2 hooks: - id: rumdl-fmt - id: rumdl @@ -55,12 +55,12 @@ repos: pass_filenames: false - repo: https://github.com/astral-sh/uv-pre-commit - rev: 0.12.23 + rev: 0.13.6 hooks: - id: uv-lock - repo: https://github.com/abravalheri/validate-pyproject - rev: "0.26" + rev: "v0.27" hooks: - id: validate-pyproject diff --git a/DIRECTORY.md b/DIRECTORY.md index 0418ee0974f4..52b15110f236 100644 --- a/DIRECTORY.md +++ b/DIRECTORY.md @@ -867,6 +867,7 @@ * [Josephus Problem](maths/josephus_problem.py) * [Juggler Sequence](maths/juggler_sequence.py) * [Karatsuba](maths/karatsuba.py) + * [Keith Number](maths/keith_number.py) * [Kth Lexicographic Permutation](maths/kth_lexicographic_permutation.py) * [Laplace Transformation](maths/laplace_transformation.py) * [Largest Of Very Large Numbers](maths/largest_of_very_large_numbers.py) @@ -1492,6 +1493,7 @@ * [Non Preemptive Shortest Job First](scheduling/non_preemptive_shortest_job_first.py) * [Round Robin](scheduling/round_robin.py) * [Shortest Job First](scheduling/shortest_job_first.py) + * [Weighted Interval Scheduling](scheduling/weighted_interval_scheduling.py) ## [Searches](searches) * [Binary Search](searches/binary_search.py) @@ -1642,6 +1644,7 @@ * [Z Function](strings/z_function.py) ## [Tests](tests) + * [Test Find And Fix Pep798](tests/test_find_and_fix_pep798.py) * [Test Sorts](tests/test_sorts.py) ## [Web Programming](web_programming) diff --git a/data_compression/huffman.py b/data_compression/huffman.py index 15bd311e4fc6..425876f5e5e6 100644 --- a/data_compression/huffman.py +++ b/data_compression/huffman.py @@ -76,9 +76,7 @@ def huffman(file_path: str) -> None: """ letters_list = parse_file(file_path) root = build_tree(letters_list) - letters = { - k: v for letter in traverse_tree(root, "") for k, v in letter.bitstring.items() - } + letters = {**letter.bitstring for letter in traverse_tree(root, "")} print(f"Huffman Coding of {file_path}: ") with open(file_path) as f: while True: diff --git a/data_structures/arrays/sudoku_solver.py b/data_structures/arrays/sudoku_solver.py index 05e2d9f7e964..6cc05d18adb1 100644 --- a/data_structures/arrays/sudoku_solver.py +++ b/data_structures/arrays/sudoku_solver.py @@ -38,7 +38,7 @@ def cross(items_a, items_b): + [cross(rs, cs) for rs in ("ABC", "DEF", "GHI") for cs in ("123", "456", "789")] ) units = {s: [u for u in unitlist if s in u] for s in squares} -peers = {s: {x for u in units[s] for x in u} - {s} for s in squares} +peers = {s: {*u for u in units[s]} - {s} for s in squares} def test() -> None: diff --git a/data_structures/heap/heap.py b/data_structures/heap/heap.py index 7614e3d7f500..bf2c14c40dc9 100644 --- a/data_structures/heap/heap.py +++ b/data_structures/heap/heap.py @@ -2,18 +2,15 @@ from abc import abstractmethod from collections.abc import Iterable -from typing import Protocol, TypeVar +from typing import Protocol class Comparable(Protocol): @abstractmethod - def __gt__(self: T, other: T, /) -> bool: + def __gt__(self, other: object, /) -> bool: pass -T = TypeVar("T", bound=Comparable) - - class Heap[T: Comparable]: """A Max Heap Implementation diff --git a/data_structures/linked_list/print_reverse.py b/data_structures/linked_list/print_reverse.py index a023745dee69..98c8b4e11505 100644 --- a/data_structures/linked_list/print_reverse.py +++ b/data_structures/linked_list/print_reverse.py @@ -98,7 +98,14 @@ def make_linked_list(elements_list: Iterable[int]) -> LinkedList: abc >>> make_linked_list([7, 25]) 7 -> 25 + >>> make_linked_list(x for x in [7, 25]) + 7 -> 25 + >>> make_linked_list(x for x in range(0)) + Traceback (most recent call last): + ... + Exception: The Elements List is empty """ + elements_list = list(elements_list) if not elements_list: raise Exception("The Elements List is empty") diff --git a/dynamic_programming/matrix_chain_multiplication.py b/dynamic_programming/matrix_chain_multiplication.py index 4c0c771f9092..9d7877a36bfb 100644 --- a/dynamic_programming/matrix_chain_multiplication.py +++ b/dynamic_programming/matrix_chain_multiplication.py @@ -45,7 +45,7 @@ ``26000`` """ -from collections.abc import Iterator +from collections.abc import Generator from contextlib import contextmanager from functools import cache from sys import maxsize @@ -128,7 +128,7 @@ def a(i: int, j: int) -> int: @contextmanager -def elapsed_time(msg: str) -> Iterator: +def elapsed_time(msg: str) -> Generator[None]: # print(f"Starting: {msg}") from time import perf_counter_ns diff --git a/graphs/hopcroft_karp.py b/graphs/hopcroft_karp.py index a6131f4f94ad..fcbd22095517 100644 --- a/graphs/hopcroft_karp.py +++ b/graphs/hopcroft_karp.py @@ -84,11 +84,7 @@ def __init__(self, graph: dict[T, list[T]]) -> None: self.graph = graph self.left_vertices = list(graph.keys()) self.right_vertices = sorted( - { - right_vertex - for neighbors in graph.values() - for right_vertex in neighbors - }, + {*neighbors for neighbors in graph.values()}, key=repr, ) diff --git a/machine_learning/dimensionality_reduction.py b/machine_learning/dimensionality_reduction.py index 6080d0a4f11f..3912a9d9cf5e 100644 --- a/machine_learning/dimensionality_reduction.py +++ b/machine_learning/dimensionality_reduction.py @@ -143,7 +143,7 @@ def linear_discriminant_analysis( assert classes > dimensions # Check if features have been already loaded - if features.any: + if features.any(): _, eigenvectors = eigh( covariance_between_classes(features, labels, classes), covariance_within_classes(features, labels, classes), @@ -363,6 +363,11 @@ def test_linear_discriminant_analysis() -> None: ) assert error_info.type is AssertionError + with pytest.raises(AssertionError): + linear_discriminant_analysis( + np.array([]), np.array([]), classes=2, dimensions=1 + ) + def test_principal_component_analysis() -> None: features = np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]]) diff --git a/maths/keith_number.py b/maths/keith_number.py new file mode 100644 index 000000000000..31d8154201a3 --- /dev/null +++ b/maths/keith_number.py @@ -0,0 +1,184 @@ +""" +== Keith Number (Repfigit Number) == +A Keith number (also known as a repfigit number, short for "repetitive +Fibonacci-like digit") is a natural number n with d >= 2 decimal digits +such that when a sequence is formed starting with the d digits of n and each +subsequent term is the sum of the preceding d terms, the number n itself +appears in the sequence. + +For example, 197 is a 3-digit number (d = 3): + Initial terms: 1, 9, 7 + Next term: 1 + 9 + 7 = 17 + Next term: 9 + 7 + 17 = 33 + Next term: 7 + 17 + 33 = 57 + Next term: 17 + 33 + 57 = 107 + Next term: 33 + 57 + 107 = 197 (Matches 197! So 197 is a Keith number). + +The first few Keith numbers are: +14, 19, 28, 47, 61, 75, 197, 742, 1104, 1537, 2208, 2580, 3684, 4788, 7385... + +References: +- https://en.wikipedia.org/wiki/Keith_number +- https://oeis.org/A007629 +""" + +from collections import deque + + +def is_keith_number(number: int) -> bool: + """ + Check if a given integer is a Keith number. + + A Keith number is an integer greater than or equal to 10 that appears + in the Fibonacci-like sequence generated by its digits. Single-digit + numbers are excluded by mathematical convention. + + Complexity Analysis: + - Time Complexity: O(d * k) where d is the number of digits of number + and k is the number of terms until the sequence reaches or exceeds + the number. Since the terms grow exponentially, k = O(log(number)). + - Space Complexity: O(d) auxiliary space to store the previous d terms. + + Args: + number: The integer to test. + + Returns: + True if number is a Keith number, False otherwise. + + Raises: + TypeError: If number is not an integer. + + Examples: + Known 2-digit Keith numbers: + >>> is_keith_number(14) + True + >>> is_keith_number(19) + True + >>> is_keith_number(28) + True + >>> is_keith_number(47) + True + >>> is_keith_number(61) + True + >>> is_keith_number(75) + True + + Known 3-digit and 4-digit Keith numbers: + >>> is_keith_number(197) + True + >>> is_keith_number(742) + True + >>> is_keith_number(1104) + True + >>> is_keith_number(1537) + True + + Non-Keith numbers: + >>> is_keith_number(10) + False + >>> is_keith_number(12) + False + >>> is_keith_number(25) + False + >>> is_keith_number(100) + False + + Edge cases (single-digit and non-positive integers): + >>> is_keith_number(9) + False + >>> is_keith_number(1) + False + >>> is_keith_number(0) + False + >>> is_keith_number(-14) + False + + Type validation: + >>> is_keith_number(14.0) + Traceback (most recent call last): + ... + TypeError: number must be an integer + >>> is_keith_number("14") + Traceback (most recent call last): + ... + TypeError: number must be an integer + >>> is_keith_number(True) + Traceback (most recent call last): + ... + TypeError: number must be an integer + >>> is_keith_number(None) + Traceback (most recent call last): + ... + TypeError: number must be an integer + """ + if not isinstance(number, int) or isinstance(number, bool): + raise TypeError("number must be an integer") + + if number < 10: + return False + + digits = [int(digit) for digit in str(number)] + window = deque(digits) + current_sum = sum(digits) + + while current_sum < number: + oldest = window.popleft() + window.append(current_sum) + # Update rolling sum in O(1) + current_sum = 2 * current_sum - oldest + + return current_sum == number + + +def find_keith_numbers(limit: int) -> list[int]: + """ + Find and return all Keith numbers up to a specified limit. + + Complexity Analysis: + - Time Complexity: O(limit * log(limit)) + - Space Complexity: O(k) where k is the number of Keith numbers found. + + Args: + limit: The upper bound (inclusive) up to which to search for Keith numbers. + + Returns: + A list of Keith numbers less than or equal to limit. + + Raises: + TypeError: If limit is not an integer. + ValueError: If limit is less than 10. + + Examples: + >>> find_keith_numbers(100) + [14, 19, 28, 47, 61, 75] + >>> find_keith_numbers(200) + [14, 19, 28, 47, 61, 75, 197] + >>> find_keith_numbers(10) + [] + + Type and value errors: + >>> find_keith_numbers(9) + Traceback (most recent call last): + ... + ValueError: limit must be an integer greater than or equal to 10 + >>> find_keith_numbers(10.5) + Traceback (most recent call last): + ... + TypeError: limit must be an integer + >>> find_keith_numbers(False) + Traceback (most recent call last): + ... + TypeError: limit must be an integer + """ + if not isinstance(limit, int) or isinstance(limit, bool): + raise TypeError("limit must be an integer") + if limit < 10: + raise ValueError("limit must be an integer greater than or equal to 10") + + return [num for num in range(10, limit + 1) if is_keith_number(num)] + + +if __name__ == "__main__": + import doctest + + doctest.testmod() diff --git a/maths/zellers_congruence.py b/maths/zellers_congruence.py index b958ed3b8659..5b447f9c9dfe 100644 --- a/maths/zellers_congruence.py +++ b/maths/zellers_congruence.py @@ -1,5 +1,5 @@ import argparse -import datetime +from datetime import date def zeller(date_input: str) -> str: @@ -119,8 +119,8 @@ def zeller(date_input: str) -> str: "Year out of range. There has to be some sort of limit...right?" ) - # Get datetime obj for validation - dt_ck = datetime.date(int(y), int(m), int(d)) + # Get date obj for validation + dt_ck = date(int(y), int(m), int(d)) # Start math if m <= 2: diff --git a/matrix/median_matrix.py b/matrix/median_matrix.py index 116e609a587c..57446615e814 100644 --- a/matrix/median_matrix.py +++ b/matrix/median_matrix.py @@ -23,7 +23,7 @@ def median(matrix: list[list[int]]) -> int: 3 """ # Flatten the matrix into a sorted 1D list - linear = sorted(num for row in matrix for num in row) + linear = sorted(*row for row in matrix) # Calculate the middle index mid = (len(linear) - 1) // 2 diff --git a/project_euler/problem_079/sol1.py b/project_euler/problem_079/sol1.py index 74392e9bd094..ee6912af9b3f 100644 --- a/project_euler/problem_079/sol1.py +++ b/project_euler/problem_079/sol1.py @@ -34,7 +34,7 @@ def find_secret_passcode(logins: list[str]) -> int: # Split each login by character e.g. '319' -> ('3', '1', '9') split_logins = [tuple(login) for login in logins] - unique_chars = {char for login in split_logins for char in login} + unique_chars = {*login for login in split_logins} for permutation in itertools.permutations(unique_chars): satisfied = True diff --git a/pyproject.toml b/pyproject.toml index 52bd60b9d3aa..932f64f2e00a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -317,7 +317,6 @@ skip = """\ # free-threaded 3.15t test venv would otherwise leave several stubs unresolved. environment.python-version = "3.15" rules.call-non-callable = "ignore" -rules.deprecated = "ignore" rules.invalid-argument-type = "ignore" rules.invalid-return-type = "ignore" rules.no-matching-overload = "ignore" diff --git a/scheduling/weighted_interval_scheduling.py b/scheduling/weighted_interval_scheduling.py new file mode 100644 index 000000000000..72d45c856ac0 --- /dev/null +++ b/scheduling/weighted_interval_scheduling.py @@ -0,0 +1,220 @@ +""" +Weighted Interval Scheduling Problem (Dynamic Programming with Binary Search). + +Given a set of intervals (tasks/activities), each characterized by a start time, +an end time, and an associated weight (value/profit), the goal is to select a subset +of mutually compatible (non-overlapping) intervals that maximizes the total weight. + +Unlike the unweighted interval scheduling (activity selection) problem which can be +optimally solved using a greedy strategy (earliest finish time first), the weighted +variant requires dynamic programming because higher-value intervals might conflict +with earlier-finishing intervals. + +Algorithm: +1. Sort intervals by their finish times in non-decreasing order. +2. For each interval j, compute p(j), the index of the latest compatible interval + that finishes at or before the start time of interval j. This can be found in + O(log n) time using binary search (bisect_right). +3. Compute the maximum weight subset using the recurrence: + OPT(j) = max(OPT(j - 1), weight(j) + OPT(p(j))) +4. Backtrack through the DP array to reconstruct the optimal set of intervals. + +Complexity: +- Time Complexity: O(n log n) due to sorting and n binary searches. +- Space Complexity: O(n) for the DP table and output list. + +Reference: +- Kleinberg, J., & Tardos, É. (2006). Algorithm Design. Chapter 6.1: + Weighted Interval Scheduling. Addison-Wesley. +- https://en.wikipedia.org/wiki/Interval_scheduling#Weighted_interval_scheduling +""" + +from __future__ import annotations + +from bisect import bisect_right +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Interval: + """ + Represents a task or activity with start time, end time, and weight. + + Attributes: + start: Start time of the interval. + end: End time of the interval (must be strictly greater than start). + weight: Value, priority, or profit of the interval (must be non-negative). + """ + + start: float + end: float + weight: float + + def __post_init__(self) -> None: + """ + Validate interval parameters after initialization. + + >>> Interval(0, 5, 10) + Interval(start=0, end=5, weight=10) + >>> Interval(5, 2, 10) + Traceback (most recent call last): + ... + ValueError: Start time (5) must be strictly less than end time (2). + >>> Interval(1, 5, -3) + Traceback (most recent call last): + ... + ValueError: Weight (-3) must be non-negative. + """ + if self.start >= self.end: + msg = ( + f"Start time ({self.start}) must be strictly less than " + f"end time ({self.end})." + ) + raise ValueError(msg) + if self.weight < 0: + msg = f"Weight ({self.weight}) must be non-negative." + raise ValueError(msg) + + +def weighted_interval_scheduling( + intervals: list[Interval], +) -> tuple[float, list[Interval]]: + """ + Finds the subset of compatible intervals yielding maximum total weight. + + Args: + intervals: A list of Interval objects. + + Returns: + A tuple (max_weight, selected_intervals) where: + - max_weight is the maximum sum of weights achievable. + - selected_intervals is the list of non-overlapping Interval objects selected. + + Examples: + >>> intervals = [ + ... Interval(0, 3, 3), + ... Interval(1, 5, 4), + ... Interval(4, 6, 2), + ... Interval(6, 8, 5), + ... ] + >>> max_weight, selected = weighted_interval_scheduling(intervals) + >>> max_weight + 10.0 + >>> [(i.start, i.end, i.weight) for i in selected] + [(0, 3, 3), (4, 6, 2), (6, 8, 5)] + + >>> # Classic case where greedy choice fails: + >>> intervals = [ + ... Interval(1, 4, 10), + ... Interval(3, 6, 12), + ... Interval(5, 8, 10), + ... ] + >>> max_weight, selected = weighted_interval_scheduling(intervals) + >>> max_weight + 20.0 + >>> [(i.start, i.end, i.weight) for i in selected] + [(1, 4, 10), (5, 8, 10)] + + >>> # Empty input + >>> weighted_interval_scheduling([]) + (0.0, []) + + >>> # Single interval + >>> max_weight, selected = weighted_interval_scheduling([Interval(2, 5, 7.5)]) + >>> max_weight + 7.5 + >>> [(i.start, i.end, i.weight) for i in selected] + [(2, 5, 7.5)] + + >>> # Completely overlapping intervals: picks the highest weight + >>> overlapping = [ + ... Interval(1, 10, 5), + ... Interval(2, 9, 15), + ... Interval(3, 8, 8), + ... ] + >>> max_weight, selected = weighted_interval_scheduling(overlapping) + >>> max_weight + 15.0 + >>> [(i.start, i.end, i.weight) for i in selected] + [(2, 9, 15)] + + >>> # Invalid intervals raise ValueError + >>> Interval(5, 2, 10) + Traceback (most recent call last): + ... + ValueError: Start time (5) must be strictly less than end time (2). + + >>> Interval(1, 5, -3) + Traceback (most recent call last): + ... + ValueError: Weight (-3) must be non-negative. + """ + if not intervals: + return 0.0, [] + + # Sort intervals by finish times in non-decreasing order + sorted_intervals = sorted(intervals, key=lambda item: item.end) + n = len(sorted_intervals) + + # Extract finish times for binary search + end_times = [item.end for item in sorted_intervals] + + # predecessors[j] stores count of compatible intervals ending <= start of j + predecessors: list[int] = [0] * n + for j in range(n): + idx = bisect_right(end_times, sorted_intervals[j].start) + predecessors[j] = idx + + # dp[j] stores max weight using a subset of the first j sorted intervals + dp: list[float] = [0.0] * (n + 1) + for j in range(1, n + 1): + incl_weight = sorted_intervals[j - 1].weight + dp[predecessors[j - 1]] + excl_weight = dp[j - 1] + dp[j] = max(incl_weight, excl_weight) + + # Backtracking to reconstruct the optimal set of intervals + selected: list[Interval] = [] + curr = n + while curr > 0: + incl_weight = sorted_intervals[curr - 1].weight + dp[predecessors[curr - 1]] + if incl_weight > dp[curr - 1]: + selected.append(sorted_intervals[curr - 1]) + curr = predecessors[curr - 1] + else: + curr -= 1 + + selected.reverse() + return dp[n], selected + + +def test_weighted_interval_scheduling() -> None: + """ + Test suite for weighted interval scheduling function and Interval dataclass. + + >>> test_weighted_interval_scheduling() + """ + intervals = [ + Interval(0, 3, 3), + Interval(1, 5, 4), + Interval(4, 6, 2), + Interval(6, 8, 5), + ] + max_weight, selected = weighted_interval_scheduling(intervals) + assert max_weight == 10.0 + assert len(selected) == 3 + assert selected == [Interval(0, 3, 3), Interval(4, 6, 2), Interval(6, 8, 5)] + + # Disjoint intervals + disjoint = [Interval(1, 2, 5), Interval(3, 4, 6), Interval(5, 6, 7)] + max_weight, selected = weighted_interval_scheduling(disjoint) + assert max_weight == 18.0 + assert selected == disjoint + + # Empty list + assert weighted_interval_scheduling([]) == (0.0, []) + + +if __name__ == "__main__": + import doctest + + doctest.testmod() diff --git a/scripts/find_and_fix_pep798.py b/scripts/find_and_fix_pep798.py new file mode 100755 index 000000000000..016a095b653a --- /dev/null +++ b/scripts/find_and_fix_pep798.py @@ -0,0 +1,259 @@ +#!/usr/bin/env -S uv run --script +# +# /// script +# requires-python = ">=3.13" +# dependencies = [] +# /// + +""" +Find (and optionally fix) opportunities to use PEP 798 in Python source files. + +PEP 798 allows unpacking inside comprehensions and generator expressions: +https://peps.python.org/pep-0798/ + +The following flattening patterns are detected: + + [y for x in xs for y in x] -> [*x for x in xs] + {y for x in xs for y in x} -> {*x for x in xs} + (y for x in xs for y in x) -> (*x for x in xs) + {k: v for d in ds for k, v in d.items()} -> {**d for d in ds} + +Only the simplest, always-equivalent forms are rewritten: exactly two ``for`` +clauses, no ``async``, and no ``if`` filter on the inner clause. Nested +candidates are fixed innermost first; re-run the script to catch any leftovers. +The rewritten source uses Python 3.15+ syntax, so fixed files need 3.15+. + +Usage: + scripts/find_and_fix_pep798.py [--fix] [PATH ...] + +If no path is provided, the current working directory is searched. + +Without ``--fix`` the script only reports and exits with status 1 if any +opportunities were found. +""" + +import argparse +import ast +import sys +from collections.abc import Iterable, Iterator +from dataclasses import dataclass +from pathlib import Path + +Comprehension = ast.ListComp | ast.SetComp | ast.GeneratorExp | ast.DictComp + +# Nodes that need parentheses after ``*`` or ``**`` (they bind looser than ``|``). +_NEEDS_PARENS_AFTER_STAR = ( + ast.BoolOp, + ast.Compare, + ast.IfExp, + ast.Lambda, + ast.NamedExpr, + ast.UnaryOp, +) +# Nodes that need parentheses inside a ``for ... in`` / ``if`` comprehension clause. +_NEEDS_PARENS_IN_CLAUSE = (ast.IfExp, ast.Lambda, ast.NamedExpr, ast.Yield) + + +@dataclass(frozen=True) +class Finding: + """A PEP 798 opportunity: a source span and its replacement text.""" + + line: int + start: int + end: int + old: str + new: str + + +def _is_name(node: ast.expr, name: str) -> bool: + return isinstance(node, ast.Name) and node.id == name + + +def _is_flattening(node: Comprehension) -> bool: + """ + Return True if the comprehension is a simple two-level flattening. + + >>> def check(code: str) -> bool: + ... return _is_flattening(ast.parse(code, mode="eval").body) + >>> check("[y for x in xs for y in x]") + True + >>> check("[y for x in xs if x for y in x]") + True + >>> check("[y for x in xs for y in x if y]") + False + >>> check("[y + 1 for x in xs for y in x]") + False + >>> check("[y for x in xs]") + False + >>> check("{k: v for d in ds for k, v in d.items()}") + True + >>> check("{k: v for d in ds for k, v in d.items() if v}") + False + >>> check("{k: v for d in ds for v, k in d.items()}") + False + >>> check("{k: v for d in ds for k, v in d.items(1)}") + False + """ + if len(node.generators) != 2: + return False + outer, inner = node.generators + if outer.is_async or inner.is_async or inner.ifs: + return False + if isinstance(node, ast.DictComp): + match inner.target, node.key, node.value, inner.iter: + case ( + ast.Tuple(elts=[ast.Name(id=a), ast.Name(id=b)]), + ast.Name(id=key), + ast.Name(id=value), + ast.Call(func=ast.Attribute(attr="items"), args=[], keywords=[]), + ): + return a != b and (a, b) == (key, value) + return False + return isinstance(inner.target, ast.Name) and _is_name(node.elt, inner.target.id) + + +class _Offsets: + """Convert AST (line, UTF-8 byte column) positions to string offsets.""" + + def __init__(self, source: str) -> None: + self.lines = source.splitlines(keepends=True) + self.line_starts = [0] + for line in self.lines: + self.line_starts.append(self.line_starts[-1] + len(line)) + + def offset(self, line: int, byte_col: int) -> int: + text = self.lines[line - 1].encode()[:byte_col].decode() + return self.line_starts[line - 1] + len(text) + + +def _segment(source: str, offsets: _Offsets, node: ast.expr, parens: tuple) -> str: + start = offsets.offset(node.lineno, node.col_offset) + end = offsets.offset(node.end_lineno, node.end_col_offset) + text = source[start:end] + return f"({text})" if isinstance(node, parens) else text + + +def _replacement(source: str, offsets: _Offsets, node: Comprehension) -> str: + outer, inner = node.generators + star = "**" if isinstance(node, ast.DictComp) else "*" + iterable = inner.iter + if isinstance(node, ast.DictComp): + iterable = iterable.func.value + unpacked = _segment(source, offsets, iterable, _NEEDS_PARENS_AFTER_STAR) + target = _segment(source, offsets, outer.target, ()) + outer_iter = _segment(source, offsets, outer.iter, _NEEDS_PARENS_IN_CLAUSE) + clauses = [f"for {target} in {outer_iter}"] + clauses += [ + f"if {_segment(source, offsets, cond, _NEEDS_PARENS_IN_CLAUSE)}" + for cond in outer.ifs + ] + start = offsets.offset(node.lineno, node.col_offset) + end = offsets.offset(node.end_lineno, node.end_col_offset) + return f"{source[start]}{star}{unpacked} {' '.join(clauses)}{source[end - 1]}" + + +def _span(offsets: _Offsets, node: ast.AST) -> tuple[int, int]: + return ( + offsets.offset(node.lineno, node.col_offset), + offsets.offset(node.end_lineno, node.end_col_offset), + ) + + +def find_opportunities(source: str) -> list[Finding]: + """ + Return non-overlapping PEP 798 opportunities, innermost candidates first. + + >>> [f.new for f in find_opportunities("[y for x in xs for y in x]")] + ['[*x for x in xs]'] + >>> find_opportunities("[y for y in ys]") + [] + >>> len(find_opportunities("a = [y for x in xs for y in x]\\nb = {*x for x in xs}")) + 1 + """ + tree = ast.parse(source) + offsets = _Offsets(source) + candidates = [ + node + for node in ast.walk(tree) + if isinstance(node, Comprehension) and _is_flattening(node) + ] + # Innermost (smallest) first so that nested candidates keep the inner fix. + candidates.sort(key=lambda node: _span(offsets, node)[1] - _span(offsets, node)[0]) + taken: list[tuple[int, int]] = [] + findings: list[Finding] = [] + for node in candidates: + start, end = _span(offsets, node) + if any(start < t_end and t_start < end for t_start, t_end in taken): + continue + taken.append((start, end)) + findings.append( + Finding( + line=node.lineno, + start=start, + end=end, + old=source[start:end], + new=_replacement(source, offsets, node), + ) + ) + return sorted(findings, key=lambda finding: finding.start) + + +def fix_source(source: str) -> str: + """ + Rewrite every opportunity found by :func:`find_opportunities`. + + >>> print(fix_source("x = [y for ys in yss for y in ys]")) + x = [*ys for ys in yss] + >>> print(fix_source("x = {k: v for d in ds if d for k, v in d.items()}")) + x = {**d for d in ds if d} + """ + for finding in reversed(find_opportunities(source)): + source = f"{source[: finding.start]}{finding.new}{source[finding.end :]}" + return source + + +def iter_python_files(paths: Iterable[Path]) -> Iterator[Path]: + for path in paths: + if path.is_dir(): + yield from sorted( + candidate + for candidate in path.rglob("*.py") + if not any(part.startswith(".") for part in candidate.parent.parts) + ) + elif path.suffix == ".py" and not any( + part.startswith(".") for part in path.parent.parts + ): + yield path + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + parser.add_argument( + "paths", + nargs="*", + type=Path, + default=[Path.cwd()], + help="Python files or directories (defaults to the current directory)", + ) + parser.add_argument("--fix", action="store_true", help="rewrite files in place") + args = parser.parse_args(argv) + + total = 0 + for path in iter_python_files(args.paths): + source = path.read_text(encoding="utf-8") + try: + findings = find_opportunities(source) + except SyntaxError as error: + print(f"{path}: skipped ({error.msg})", file=sys.stderr) + continue + for finding in findings: + print(f"{path}:{finding.line}: {finding.old!r} -> {finding.new!r}") + total += len(findings) + if findings and args.fix: + path.write_text(fix_source(source), encoding="utf-8") + print(f"{total} PEP 798 opportunit{'y' if total == 1 else 'ies'} found") + return 0 if args.fix or not total else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/sorts/bucket_sort.py b/sorts/bucket_sort.py index 4dba0cc22d0d..d4908edc16d9 100644 --- a/sorts/bucket_sort.py +++ b/sorts/bucket_sort.py @@ -102,7 +102,7 @@ def bucket_sort( index = min(int((val - min_value) / bucket_size), bucket_count - 1) buckets[index].append(val) - return [val for bucket in buckets for val in sorted(bucket)] + return [*sorted(bucket) for bucket in buckets] if __name__ == "__main__": diff --git a/sorts/insertion_sort.py b/sorts/insertion_sort.py index ae5edca3f60f..9ff68e20400b 100644 --- a/sorts/insertion_sort.py +++ b/sorts/insertion_sort.py @@ -14,16 +14,13 @@ """ from collections.abc import MutableSequence -from typing import Any, Protocol, TypeVar +from typing import Any, Protocol class Comparable(Protocol): def __lt__(self, other: Any, /) -> bool: ... -T = TypeVar("T", bound=Comparable) - - def insertion_sort[T: Comparable](collection: MutableSequence[T]) -> MutableSequence[T]: """A pure Python implementation of the insertion sort algorithm diff --git a/sorts/recursive_insertion_sort.py b/sorts/recursive_insertion_sort.py index 15bb702790e1..27721283a698 100644 --- a/sorts/recursive_insertion_sort.py +++ b/sorts/recursive_insertion_sort.py @@ -5,16 +5,13 @@ from __future__ import annotations from collections.abc import MutableSequence -from typing import Protocol, TypeVar +from typing import Protocol class Comparable(Protocol): def __lt__(self, other: object, /) -> bool: ... -T = TypeVar("T", bound=Comparable) - - def rec_insertion_sort[T: Comparable](collection: MutableSequence[T], n: int) -> None: """ Given a collection of comparable elements and its length, sorts the diff --git a/strings/palindrome.py b/strings/palindrome.py index 77578cc2d80d..173254300444 100644 --- a/strings/palindrome.py +++ b/strings/palindrome.py @@ -24,6 +24,8 @@ def is_palindrome(s: str) -> bool: >>> all(is_palindrome(key) == value for key, value in test_data.items()) True + >>> is_palindrome("") + True """ start_i = 0 @@ -43,6 +45,8 @@ def is_palindrome_traversal(s: str) -> bool: >>> all(is_palindrome_traversal(key) == value for key, value in test_data.items()) True + >>> is_palindrome_traversal("") + True """ end = len(s) // 2 n = len(s) @@ -62,6 +66,8 @@ def is_palindrome_recursive(s: str) -> bool: >>> all(is_palindrome_recursive(key) == value for key, value in test_data.items()) True + >>> is_palindrome_recursive("") + True """ if len(s) <= 1: return True @@ -77,6 +83,8 @@ def is_palindrome_slice(s: str) -> bool: >>> all(is_palindrome_slice(key) == value for key, value in test_data.items()) True + >>> is_palindrome_slice("") + True """ return s == s[::-1] @@ -96,6 +104,10 @@ def is_palindrome_ignore_case_and_spaces(s: str) -> bool: True >>> is_palindrome_ignore_case_and_spaces("") True + >>> is_palindrome_ignore_case_and_spaces(" !?. ") + True + >>> is_palindrome_ignore_case_and_spaces("1a2") + False """ s = "".join(char.lower() for char in s if char.isalnum()) return s == s[::-1] diff --git a/tests/test_find_and_fix_pep798.py b/tests/test_find_and_fix_pep798.py new file mode 100644 index 000000000000..0817921b33f6 --- /dev/null +++ b/tests/test_find_and_fix_pep798.py @@ -0,0 +1,161 @@ +from pathlib import Path + +import pytest + +from scripts.find_and_fix_pep798 import ( + find_opportunities, + fix_source, + iter_python_files, + main, +) + + +@pytest.mark.parametrize( + ("before", "after"), + [ + ("[y for x in xs for y in x]", "[*x for x in xs]"), + ("{y for x in xs for y in x}", "{*x for x in xs}"), + ("(y for x in xs for y in x)", "(*x for x in xs)"), + ("sum(y for x in xs for y in x)", "sum(*x for x in xs)"), + ("{k: v for d in ds for k, v in d.items()}", "{**d for d in ds}"), + ("[y for x in xs if x for y in x]", "[*x for x in xs if x]"), + ("[y for x in xs if x if x.a for y in x]", "[*x for x in xs if x if x.a]"), + ("[y for x, z in xs for y in x.items]", "[*x.items for x, z in xs]"), + ("[y for x in xs for y in x or ()]", "[*(x or ()) for x in xs]"), + ("[y for x in (a if b else c) for y in x]", "[*x for x in (a if b else c)]"), + ("[y for x in xs for y in (x)]", "[*x for x in xs]"), + ("[y for x in xs for y in f(x)[0]]", "[*f(x)[0] for x in xs]"), + ("[y for x in xs for y in x][0]", "[*x for x in xs][0]"), + ("[é for ü in xs for é in ü]", "[*ü for ü in xs]"), + ("{k: v for d in ds for k, v in (a or b).items()}", "{**(a or b) for d in ds}"), + ], +) +def test_fixes(before: str, after: str) -> None: + assert fix_source(before) == after + + +@pytest.mark.parametrize( + "source", + [ + "[y for y in ys]", + "[y + 1 for x in xs for y in x]", + "[z for x in xs for y in x]", + "[y for x in xs for y in x if y]", + "[y for x in xs for y in x for z in y]", + "[x for x in xs for x, y in x]", + "{k: v for d in ds for k, v in d.items() if v}", + "{k: v for d in ds for v, k in d.items()}", + "{k: v for d in ds for k, v in d.items(1)}", + "{k: v for d in ds for k, v in d}", + "{k: v for d in ds for k, k in d.items()}", + "[x for x in xs]", + "list(itertools.chain.from_iterable(xs))", + ], +) +def test_no_opportunity(source: str) -> None: + assert find_opportunities(source) == [] + assert fix_source(source) == source + + +def test_async_comprehensions_are_ignored() -> None: + source = ( + "async def f():\n" + " return [y async for x in xs for y in x]\n" + "async def g():\n" + " return [y for x in xs async for y in x]\n" + ) + assert find_opportunities(source) == [] + + +def test_find_reports_line_and_text() -> None: + source = "a = 1\nb = [y for x in xs for y in x]\n" + (finding,) = find_opportunities(source) + assert finding.line == 2 + assert finding.old == "[y for x in xs for y in x]" + assert finding.new == "[*x for x in xs]" + assert source[finding.start : finding.end] == finding.old + + +def test_multiple_findings_in_order() -> None: + source = ( + "a = [y for x in xs for y in x]\nb = {k: v for d in ds for k, v in d.items()}\n" + ) + assert fix_source(source) == "a = [*x for x in xs]\nb = {**d for d in ds}\n" + + +def test_nested_candidates_fix_innermost_only() -> None: + source = "[w for v in [y for x in xs for y in x] for w in v]" + (finding,) = find_opportunities(source) + assert finding.old == "[y for x in xs for y in x]" + assert fix_source(source) == "[w for v in [*x for x in xs] for w in v]" + + +def test_multiline_comprehension() -> None: + source = "result = [\n y\n for x in xs\n for y in x\n]\n" + assert fix_source(source) == "result = [*x for x in xs]\n" + + +def test_iter_python_files(tmp_path: Path) -> None: + (tmp_path / "sub").mkdir() + (tmp_path / ".hidden").mkdir() + first = tmp_path / "a.py" + second = tmp_path / "sub" / "b.py" + other = tmp_path / "c.txt" + hidden = tmp_path / ".hidden" / "hidden.py" + for path in (first, second, other, hidden): + path.write_text("") + assert list(iter_python_files([tmp_path])) == [first, second] + assert list(iter_python_files([other])) == [] + assert list(iter_python_files([hidden])) == [] + + +def test_main_reports_without_fixing( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + path = tmp_path / "mod.py" + path.write_text("a = [y for x in xs for y in x]\n") + assert main([str(path)]) == 1 + output = capsys.readouterr().out + assert f"{path}:1:" in output + assert "1 PEP 798 opportunity found" in output + assert path.read_text() == "a = [y for x in xs for y in x]\n" + + +def test_main_fix_rewrites_file( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + path = tmp_path / "mod.py" + path.write_text("a = [y for x in xs for y in x]\nb = {y for x in xs for y in x}\n") + assert main(["--fix", str(path)]) == 0 + assert path.read_text() == "a = [*x for x in xs]\nb = {*x for x in xs}\n" + assert "2 PEP 798 opportunities found" in capsys.readouterr().out + + +def test_main_clean_file_returns_zero( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + path = tmp_path / "mod.py" + path.write_text("a = [y for y in ys]\n") + assert main([str(path)]) == 0 + assert "0 PEP 798 opportunities found" in capsys.readouterr().out + + +def test_main_defaults_to_current_directory( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + path = tmp_path / "mod.py" + path.write_text("a = [y for x in xs for y in x]\n") + monkeypatch.chdir(tmp_path) + assert main([]) == 1 + output = capsys.readouterr().out + assert f"{path}:1:" in output + assert "1 PEP 798 opportunity found" in output + + +def test_main_skips_syntax_errors( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + path = tmp_path / "bad.py" + path.write_text("def (:\n") + assert main([str(path)]) == 0 + assert "skipped" in capsys.readouterr().err diff --git a/web_programming/crypto_price_tracker.py b/web_programming/crypto_price_tracker.py index 514f352b5e6c..477b64458f3f 100644 --- a/web_programming/crypto_price_tracker.py +++ b/web_programming/crypto_price_tracker.py @@ -3,7 +3,7 @@ """ # /// script -# requires-python = ">=3.11" +# requires-python = ">=3.15" # dependencies = [ # "httpx2", # ] diff --git a/web_programming/instagram_crawler.py b/web_programming/instagram_crawler.py index 3dced42d1442..1e0257a7367b 100644 --- a/web_programming/instagram_crawler.py +++ b/web_programming/instagram_crawler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # /// script -# requires-python = ">=3.13" +# requires-python = ">=3.15" # dependencies = [ # "beautifulsoup4", # "fake-useragent", @@ -9,8 +9,6 @@ # ] # /// -from __future__ import annotations - import json import httpx2