diff --git a/PARITY_CHECKLIST.md b/PARITY_CHECKLIST.md index 3e5b6e0..9ea0763 100644 --- a/PARITY_CHECKLIST.md +++ b/PARITY_CHECKLIST.md @@ -706,6 +706,7 @@ Done: - [x] Basic file operations in tool implementations - [x] Basic git status snapshot - [x] Basic shell/subprocess handling +- [x] Bundled small portable utilities — `utils/array.ts`, `utils/set.ts`, `utils/objectGroupBy.ts`, `utils/xml.ts`, `utils/uuid.ts` ported in `src/small_utils.py` Missing major utility categories: diff --git a/src/small_utils.py b/src/small_utils.py new file mode 100644 index 0000000..ebd81a3 --- /dev/null +++ b/src/small_utils.py @@ -0,0 +1,166 @@ +"""Bundled portable utilities — Python ports of small npm `utils/` files. + +This module collects narrow, dependency-free helpers from +``utils/array.ts``, ``utils/set.ts``, ``utils/objectGroupBy.ts``, +``utils/xml.ts``, and ``utils/uuid.ts``. Keeping them in one file mirrors +how a user of the npm SDK would reach for these as a small toolbox. + +Design notes: +- Function names mirror upstream where idiomatic in Python; ``every`` is + renamed ``every_in`` to avoid shadowing the built-in name in callers. +- ``object_group_by`` returns a plain ``dict`` rather than a ``Mapping`` so + callers can mutate it the same way the JS object would behave. +- ``create_agent_id`` mirrors the upstream format exactly: + ``a{label-}{16 hex chars}``. +""" + +from __future__ import annotations + +import re +import secrets +from collections.abc import Callable, Iterable +from typing import TypeVar + +A = TypeVar('A') +T = TypeVar('T') +K = TypeVar('K') + + +# --------------------------------------------------------------------------- +# array.ts +# --------------------------------------------------------------------------- + +def intersperse(items: Iterable[A], separator: Callable[[int], A]) -> list[A]: + """Insert ``separator(i)`` between consecutive items. + + Mirrors ``utils/array.ts`` ``intersperse``: the separator callable + receives the 1-based index of the item it precedes. + """ + out: list[A] = [] + for i, item in enumerate(items): + if i: + out.append(separator(i)) + out.append(item) + return out + + +def count(items: Iterable[T], predicate: Callable[[T], object]) -> int: + """Count items where ``predicate(item)`` is truthy.""" + return sum(1 for x in items if predicate(x)) + + +def uniq(items: Iterable[T]) -> list[T]: + """Return unique items preserving first-seen order. + + Note: upstream JS uses ``[...new Set(xs)]`` which preserves insertion + order for primitive values; this matches that behavior. + """ + seen: set[T] = set() + out: list[T] = [] + for item in items: + if item not in seen: + seen.add(item) + out.append(item) + return out + + +# --------------------------------------------------------------------------- +# objectGroupBy.ts +# --------------------------------------------------------------------------- + +def object_group_by( + items: Iterable[T], + key_selector: Callable[[T, int], K], +) -> dict[K, list[T]]: + """Group items by ``key_selector(item, index)``. + + Mirrors ``Object.groupBy`` semantics from the TC39 proposal. + """ + out: dict[K, list[T]] = {} + for index, item in enumerate(items): + key = key_selector(item, index) + bucket = out.get(key) + if bucket is None: + bucket = [] + out[key] = bucket + bucket.append(item) + return out + + +# --------------------------------------------------------------------------- +# set.ts +# --------------------------------------------------------------------------- + +def difference(a: set[T], b: set[T]) -> set[T]: + """Items in ``a`` but not ``b``.""" + return {item for item in a if item not in b} + + +def intersects(a: set[T], b: set[T]) -> bool: + """Whether ``a`` and ``b`` share at least one element.""" + if not a or not b: + return False + return any(item in b for item in a) + + +def every_in(a: set[T], b: set[T]) -> bool: + """Whether every element of ``a`` is in ``b`` (renamed from ``every``).""" + return all(item in b for item in a) + + +def union(a: set[T], b: set[T]) -> set[T]: + """Set union.""" + return a | b + + +# --------------------------------------------------------------------------- +# xml.ts +# --------------------------------------------------------------------------- + +def escape_xml(value: str) -> str: + """Escape ``& < >`` for safe interpolation between XML/HTML tags.""" + return value.replace('&', '&').replace('<', '<').replace('>', '>') + + +def escape_xml_attr(value: str) -> str: + """Escape ``& < > " '`` for safe interpolation into an attribute value.""" + return escape_xml(value).replace('"', '"').replace("'", ''') + + +# --------------------------------------------------------------------------- +# uuid.ts +# --------------------------------------------------------------------------- + +_UUID_RE = re.compile( + r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', + re.IGNORECASE, +) + + +def validate_uuid(maybe_uuid: object) -> str | None: + """Return the input as a UUID string if it matches the canonical format.""" + if not isinstance(maybe_uuid, str): + return None + return maybe_uuid if _UUID_RE.match(maybe_uuid) else None + + +def create_agent_id(label: str | None = None) -> str: + """Generate an agent ID with the upstream ``a{label-}{hex16}`` format.""" + suffix = secrets.token_hex(8) + return f'a{label}-{suffix}' if label else f'a{suffix}' + + +__all__ = [ + 'intersperse', + 'count', + 'uniq', + 'object_group_by', + 'difference', + 'intersects', + 'every_in', + 'union', + 'escape_xml', + 'escape_xml_attr', + 'validate_uuid', + 'create_agent_id', +] diff --git a/tests/test_small_utils.py b/tests/test_small_utils.py new file mode 100644 index 0000000..8598ced --- /dev/null +++ b/tests/test_small_utils.py @@ -0,0 +1,138 @@ +"""Tests for the bundled small utilities ported in ``src/small_utils.py``.""" + +from __future__ import annotations + +import unittest + +from src.small_utils import ( + count, + create_agent_id, + difference, + escape_xml, + escape_xml_attr, + every_in, + intersects, + intersperse, + object_group_by, + union, + uniq, + validate_uuid, +) + + +class IntersperseTest(unittest.TestCase): + def test_empty(self) -> None: + self.assertEqual(intersperse([], lambda i: ','), []) + + def test_single_item_no_separator(self) -> None: + self.assertEqual(intersperse(['a'], lambda i: ','), ['a']) + + def test_separator_receives_index_starting_at_one(self) -> None: + seen: list[int] = [] + + def sep(i: int) -> str: + seen.append(i) + return f'-{i}-' + + out = intersperse(['a', 'b', 'c'], sep) + self.assertEqual(out, ['a', '-1-', 'b', '-2-', 'c']) + self.assertEqual(seen, [1, 2]) + + +class CountTest(unittest.TestCase): + def test_counts_truthy(self) -> None: + self.assertEqual(count([1, 2, 3, 4], lambda x: x % 2 == 0), 2) + + def test_predicate_returning_objects_treated_as_truthy(self) -> None: + self.assertEqual(count(['', 'x', 'y'], lambda s: s), 2) + + +class UniqTest(unittest.TestCase): + def test_preserves_first_seen_order(self) -> None: + self.assertEqual(uniq([3, 1, 2, 1, 3, 4]), [3, 1, 2, 4]) + + +class ObjectGroupByTest(unittest.TestCase): + def test_groups_by_key(self) -> None: + out = object_group_by(['apple', 'banana', 'avocado'], lambda s, _i: s[0]) + self.assertEqual(out, {'a': ['apple', 'avocado'], 'b': ['banana']}) + + def test_passes_index_to_selector(self) -> None: + out = object_group_by( + ['x', 'y', 'z'], lambda _s, i: 'even' if i % 2 == 0 else 'odd', + ) + self.assertEqual(out, {'even': ['x', 'z'], 'odd': ['y']}) + + +class SetOpsTest(unittest.TestCase): + def test_difference(self) -> None: + self.assertEqual(difference({1, 2, 3}, {2}), {1, 3}) + + def test_intersects_true(self) -> None: + self.assertTrue(intersects({1, 2}, {2, 3})) + + def test_intersects_false(self) -> None: + self.assertFalse(intersects({1, 2}, {3, 4})) + + def test_intersects_empty_short_circuits(self) -> None: + self.assertFalse(intersects(set(), {1})) + self.assertFalse(intersects({1}, set())) + + def test_every_in(self) -> None: + self.assertTrue(every_in({1, 2}, {1, 2, 3})) + self.assertFalse(every_in({1, 4}, {1, 2, 3})) + self.assertTrue(every_in(set(), {1, 2})) + + def test_union(self) -> None: + self.assertEqual(union({1, 2}, {2, 3}), {1, 2, 3}) + + +class XmlEscapeTest(unittest.TestCase): + def test_escape_xml(self) -> None: + self.assertEqual( + escape_xml('a & b < c > d'), 'a & b < c > d', + ) + + def test_escape_xml_amp_first_no_double_escape(self) -> None: + self.assertEqual(escape_xml('<&>'), '<&>') + + def test_escape_xml_attr_includes_quotes(self) -> None: + self.assertEqual( + escape_xml_attr('he said "hi" & \'bye\''), + 'he said "hi" & 'bye'', + ) + + +class ValidateUuidTest(unittest.TestCase): + def test_valid_lowercase(self) -> None: + u = '12345678-1234-1234-1234-123456789012' + self.assertEqual(validate_uuid(u), u) + + def test_valid_uppercase(self) -> None: + u = 'ABCDEF12-1234-5678-90AB-CDEF12345678' + self.assertEqual(validate_uuid(u), u) + + def test_invalid_format(self) -> None: + self.assertIsNone(validate_uuid('not-a-uuid')) + + def test_non_string_returns_none(self) -> None: + self.assertIsNone(validate_uuid(123)) + self.assertIsNone(validate_uuid(None)) + + +class CreateAgentIdTest(unittest.TestCase): + def test_no_label(self) -> None: + agent_id = create_agent_id() + self.assertRegex(agent_id, r'^a[0-9a-f]{16}$') + + def test_with_label(self) -> None: + agent_id = create_agent_id('compact') + self.assertRegex(agent_id, r'^acompact-[0-9a-f]{16}$') + + def test_unique_across_calls(self) -> None: + ids = {create_agent_id() for _ in range(50)} + self.assertEqual(len(ids), 50) + + +if __name__ == '__main__': + unittest.main()