Implemented the next missing parity slice around small portable utilities.
This commit is contained in:
@@ -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:
|
||||
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user