diff --git a/src/JavaScript/__init__.py b/src/JavaScript/__init__.py index 85244e0..82dbb51 100644 --- a/src/JavaScript/__init__.py +++ b/src/JavaScript/__init__.py @@ -1,5 +1,7 @@ """JavaScript-style objects, methods and functions for Python.""" +from ._array import Array +from ._object import Object from ._values import ( Infinity, NaN, @@ -13,6 +15,7 @@ ) __all__ = [ + "Array", "Object", "Infinity", "NaN", "isFinite", "isNaN", "null", "strictEqual", "truthy", "typeof", "undefined", ] diff --git a/src/JavaScript/_array.py b/src/JavaScript/_array.py new file mode 100644 index 0000000..afc0d08 --- /dev/null +++ b/src/JavaScript/_array.py @@ -0,0 +1,335 @@ +"""Sparse arrays and selected ECMAScript Array algorithms.""" + +from __future__ import annotations + +from functools import cmp_to_key +from reprlib import recursive_repr + +from ._object import array_index, ordered_keys +from ._values import ( + callback, integer, is_number, relative_index, same_value_zero, strictEqual, + to_number, to_string, truthy, undefined, +) + + +class Array: + """Identity-based sparse indexed values; use Array.of for one numeric item.""" + + __slots__ = ("_items", "_length", "_properties") + + def __init__(self, *values): + self._items = {} + self._properties = {} + self._length = 0 + if len(values) == 1 and is_number(values[0]): + self.length = values[0] + else: + self.push(*values) + + @property + def length(self): + return self._length + + @length.setter + def length(self, value): + number = to_number(value) + if not 0 <= number <= 2**32 - 1 or number != int(number): + raise ValueError("Invalid array length") + length = int(number) + for index in list(self._items): + if index >= length: + del self._items[index] + self._length = length + + def __getitem__(self, key): + key = to_string(key) + if key == "length": + return self.length + index = array_index(key) + return self._items.get(index, undefined) if index is not None else self._properties.get(key, undefined) + + def __setitem__(self, key, value): + key = to_string(key) + if key == "length": + self.length = value + return + index = array_index(key) + if index is None: + self._properties[key] = value + else: + self._items[index] = value + self._length = max(self._length, index + 1) + + def __delitem__(self, key): + key = to_string(key) + if key == "length": + raise TypeError("Cannot delete array length") + index = array_index(key) + (self._items if index is not None else self._properties).pop(index if index is not None else key, None) + + def __contains__(self, key): + return self._js_has_own(to_string(key)) + + def _js_has_own(self, key): + index = array_index(key) + return key == "length" or (index in self._items if index is not None else key in self._properties) + + def _js_entries(self): + return [(str(i), self._items[i]) for i in sorted(self._items)] + [(key, self._properties[key]) for key in ordered_keys(self._properties)] + + def __iter__(self): + index = 0 + while index < self.length: + yield self[index] + index += 1 + + def __len__(self): + return self.length + + def __bool__(self): + return True + + @recursive_repr("Array(...)") + def __repr__(self): + return f"Array(length={self.length}, items={self._items!r})" + + @recursive_repr("") + def _js_primitive(self): + return self.join() + + @classmethod + def of(cls, *values): + result = cls() + result.push(*values) + return result + + @classmethod + def from_(cls, source, mapFn=undefined): + transform = callback(mapFn) if mapFn is not undefined else None + result = cls() + for index, value in enumerate(source): + result.push(transform(value, index) if transform else value) + return result + + @staticmethod + def isArray(value): + return isinstance(value, Array) + + def at(self, index=undefined): + index = integer(index) + if index < 0: + index += self.length + return self[index] if 0 <= index < self.length else undefined + + def push(self, *values): + if self.length + len(values) > 2**32 - 1: + raise ValueError("Invalid array length") + for value in values: + self[self.length] = value + return self.length + + def pop(self): + if not self.length: + return undefined + index = self.length - 1 + value = self[index] + self._items.pop(index, None) + self._length -= 1 + return value + + def shift(self): + if not self.length: + return undefined + value = self[0] + self._items = {i - 1: item for i, item in self._items.items() if i > 0} + self._length -= 1 + return value + + def unshift(self, *values): + if self.length + len(values) > 2**32 - 1: + raise ValueError("Invalid array length") + self._items = {i + len(values): item for i, item in self._items.items()} + self._items.update(enumerate(values)) + self._length += len(values) + return self.length + + def slice(self, start=0, end=undefined): + length = self.length + start = relative_index(start, length) + end = length if end is undefined else relative_index(end, length) + result = Array(max(end - start, 0)) + for i in range(start, end): + if i in self._items: + result[i - start] = self[i] + return result + + def splice(self, *args): + if not args: + return Array() + start = relative_index(args[0], self.length) + count = self.length - start if len(args) == 1 else max(0, min(integer(args[1]), self.length - start)) + values = args[2:] + new_length = self.length - count + len(values) + if new_length > 2**32 - 1: + raise ValueError("Invalid array length") + removed = self.slice(start, start + count) + items = {i: value for i, value in self._items.items() if i < start} + items.update({i - count + len(values): value for i, value in self._items.items() if i >= start + count}) + items.update({start + i: value for i, value in enumerate(values)}) + self._items, self._length = items, new_length + return removed + + def concat(self, *values): + result = self.slice() + for value in values: + if isinstance(value, Array): + start = result.length + result.length += value.length + for i, child in value._items.items(): + result[start + i] = child + else: + result.push(value) + return result + + def join(self, separator=undefined): + separator = "," if separator is undefined else to_string(separator) + return separator.join("" if value is undefined or value is None else to_string(value) for value in self) + + def toString(self): + return self.join() + + def includes(self, value=undefined, fromIndex=0): + start = relative_index(fromIndex, self.length) + return any(same_value_zero(self[i], value) for i in range(start, self.length)) + + def indexOf(self, value=undefined, fromIndex=0): + for i in range(relative_index(fromIndex, self.length), self.length): + if i in self._items and strictEqual(self[i], value): + return i + return -1 + + def lastIndexOf(self, value=undefined, *args): + start = self.length - 1 if not args else integer(args[0]) + if start < 0: + start += self.length + start = min(start, self.length - 1) + if start < 0: + return -1 + for i in range(start, -1, -1): + if i in self._items and strictEqual(self[i], value): + return i + return -1 + + def map(self, function): + transform = callback(function) + result = Array(self.length) + for i in range(self.length): + if i in self._items: + result[i] = transform(self[i], i, self) + return result + + def filter(self, function): + predicate = callback(function) + result = Array() + for i in range(self.length): + if i in self._items: + value = self[i] + if truthy(predicate(value, i, self)): + result.push(value) + return result + + def forEach(self, function): + visit = callback(function) + for i in range(self.length): + if i in self._items: + visit(self[i], i, self) + return undefined + + def some(self, function): + predicate = callback(function) + return any(i in self._items and truthy(predicate(self[i], i, self)) for i in range(self.length)) + + def every(self, function): + predicate = callback(function) + return all(i not in self._items or truthy(predicate(self[i], i, self)) for i in range(self.length)) + + def _find(self, function, reverse=False): + predicate = callback(function) + indices = range(self.length - 1, -1, -1) if reverse else range(self.length) + for i in indices: + value = self[i] + if truthy(predicate(value, i, self)): + return i, value + return -1, undefined + + def find(self, function): + return self._find(function)[1] + + def findIndex(self, function): + return self._find(function)[0] + + def findLast(self, function): + return self._find(function, True)[1] + + def findLastIndex(self, function): + return self._find(function, True)[0] + + def _reduce(self, function, args, reverse=False): + reducer = callback(function) + indices = iter(range(self.length - 1, -1, -1) if reverse else range(self.length)) + if args: + accumulator = args[0] + else: + for i in indices: + if i in self._items: + accumulator = self[i] + break + else: + raise TypeError("Reduce of empty array with no initial value") + for i in indices: + if i in self._items: + accumulator = reducer(accumulator, self[i], i, self) + return accumulator + + def reduce(self, function, *args): + return self._reduce(function, args) + + def reduceRight(self, function, *args): + return self._reduce(function, args, True) + + def reverse(self): + self._items = {self.length - i - 1: value for i, value in self._items.items()} + return self + + def fill(self, value=undefined, start=0, end=undefined): + end = self.length if end is undefined else relative_index(end, self.length) + for i in range(relative_index(start, self.length), end): + self[i] = value + return self + + def sort(self, compareFn=undefined): + compare = callback(compareFn) if compareFn is not undefined else None + values = [self._items[i] for i in sorted(self._items) if self._items[i] is not undefined] + undefined_count = sum(value is undefined for value in self._items.values()) + if compare: + def order(a, b): + number = to_number(compare(a, b)) + return -1 if number < 0 else 1 if number > 0 else 0 + values.sort(key=cmp_to_key(order)) + else: + values.sort(key=lambda value: to_string(value).encode("utf-16-be", "surrogatepass")) + self._items = dict(enumerate(values + [undefined] * undefined_count)) + return self + + def keys(self): + index = 0 + while index < self.length: + yield index + index += 1 + + def values(self): + return iter(self) + + def entries(self): + for index in self.keys(): + yield Array.of(index, self[index]) diff --git a/src/JavaScript/_object.py b/src/JavaScript/_object.py new file mode 100644 index 0000000..d5487a7 --- /dev/null +++ b/src/JavaScript/_object.py @@ -0,0 +1,182 @@ +"""Own enumerable data properties for the initial Object subset.""" + +from __future__ import annotations + +from collections.abc import Mapping +from reprlib import recursive_repr + +from ._values import same_value, to_string, undefined + + +def array_index(key: str) -> int | None: + if key.isascii() and key.isdecimal() and (key == "0" or not key.startswith("0")): + # Avoid Python's integer-string digit limit on arbitrary property names. + if len(key) <= 10: + number = int(key) + if number < 2**32 - 1: + return number + return None + + +def ordered_keys(properties) -> list[str]: + indices = [key for key in properties if array_index(key) is not None] + names = [key for key in properties if array_index(key) is None] + return sorted(indices, key=int) + names + + +def own_entries(value): + if value is None or value is undefined: + raise TypeError("Cannot convert undefined or null to object") + method = getattr(type(value), "_js_entries", None) + if method is not None: + return method(value) + if isinstance(value, Mapping): + properties = {to_string(key): value[key] for key in value} + return [(key, properties[key]) for key in ordered_keys(properties)] + if isinstance(value, str): + data = value.encode("utf-16-le", "surrogatepass") + return [(str(i // 2), data[i:i + 2].decode("utf-16-le", "surrogatepass")) for i in range(0, len(data), 2)] + if isinstance(value, (int, float, bool)): + return [] + raise TypeError("Expected Object, Array, mapping or primitive") + + +class Object: + """An identity-based object containing enumerable, writable data properties. + + Missing properties return undefined. Own keys shadow ordinary attributes; + use Object.keys(obj) rather than obj.keys() when calling static methods. + Special Python attributes retain their Python meaning. + """ + + __slots__ = ("_properties",) + + def __new__(cls, value=undefined): + if isinstance(value, cls): + return value + return super().__new__(cls) + + def __init__(self, value=undefined): + if value is self: + return + if value is not None and value is not undefined and not isinstance(value, Mapping): + raise TypeError("Object() supports mappings, Object, null or undefined") + object.__setattr__(self, "_properties", {}) + if value is not None and value is not undefined: + for key, child in own_entries(value): + self[key] = child + + def __getattribute__(self, name): + if not (name.startswith("__") and name.endswith("__")): + properties = object.__getattribute__(self, "_properties") + if name in properties: + return properties[name] + return object.__getattribute__(self, name) + + def __getattr__(self, name): + if name.startswith("__") and name.endswith("__"): + raise AttributeError(name) + return undefined + + def __setattr__(self, name, value): + if name.startswith("__") and name.endswith("__"): + raise AttributeError("Use brackets for special Python names") + self[name] = value + + def __delattr__(self, name): + if name.startswith("__") and name.endswith("__"): + raise AttributeError("Use brackets for special Python names") + del self[name] + + def __getitem__(self, key): + return object.__getattribute__(self, "_properties").get(to_string(key), undefined) + + def __setitem__(self, key, value): + object.__getattribute__(self, "_properties")[to_string(key)] = value + + def __delitem__(self, key): + object.__getattribute__(self, "_properties").pop(to_string(key), None) + + def __contains__(self, key): + return to_string(key) in object.__getattribute__(self, "_properties") + + def __iter__(self): + return iter(ordered_keys(object.__getattribute__(self, "_properties"))) + + def __len__(self): + return len(object.__getattribute__(self, "_properties")) + + def __bool__(self): + return True + + def __repr__(self): + return _object_repr(self) + + def _js_entries(self): + properties = object.__getattribute__(self, "_properties") + return [(key, properties[key]) for key in ordered_keys(properties)] + + def _js_primitive(self): + return "[object Object]" + + def toString(self): + return "[object Object]" + + @staticmethod + def keys(value): + from ._array import Array + return Array.of(*(key for key, _ in own_entries(value))) + + @staticmethod + def values(value): + from ._array import Array + return Array.of(*(child for _, child in own_entries(value))) + + @staticmethod + def entries(value): + from ._array import Array + return Array.of(*(Array.of(key, child) for key, child in own_entries(value))) + + @staticmethod + def assign(target, *sources): + if not isinstance(target, Object): + raise TypeError("assign target must be an Object") + for source in sources: + if source is not None and source is not undefined: + for key, value in own_entries(source): + target[key] = value + return target + + @staticmethod + def fromEntries(entries): + from ._array import Array + result = Object() + for entry in entries: + if isinstance(entry, (Object, Array)): + key, value = entry[0], entry[1] + elif isinstance(entry, Mapping): + properties = dict(own_entries(entry)) + key, value = properties.get("0", undefined), properties.get("1", undefined) + elif isinstance(entry, (list, tuple)): + key = entry[0] if entry else undefined + value = entry[1] if len(entry) > 1 else undefined + else: + raise TypeError("Iterator entry must be an object") from None + result[key] = value + return result + + @staticmethod + def hasOwn(value, key): + if value is None or value is undefined: + raise TypeError("Cannot convert undefined or null to object") + method = getattr(type(value), "_js_has_own", None) + if method is not None: + return method(value, to_string(key)) + return to_string(key) in {name for name, _ in own_entries(value)} + + is_ = staticmethod(same_value) + + +@recursive_repr("Object({...})") +def _object_repr(value): + return f"Object({object.__getattribute__(value, '_properties')!r})" diff --git a/tests/test_array.py b/tests/test_array.py new file mode 100644 index 0000000..7ff618d --- /dev/null +++ b/tests/test_array.py @@ -0,0 +1,159 @@ +import math +import unittest + +from JavaScript import Array, Object, undefined + + +class ArrayTests(unittest.TestCase): + def test_constructors_length_holes_and_presence(self): + value = Array(3) + self.assertEqual(value.length, 3) + self.assertEqual(list(value), [undefined] * 3) + self.assertEqual(list(Object.keys(value)), []) + value[1] = undefined + self.assertTrue(Object.hasOwn(value, 1)) + self.assertFalse(Object.hasOwn(value, 0)) + self.assertEqual(list(Array.of(3)), [3]) + self.assertEqual(list(Array(True)), [True]) + self.assertTrue(Array()) + self.assertNotEqual(Array(), Array()) + self.assertTrue(Array.isArray(value)) + self.assertFalse(Array.isArray([])) + + def test_length_validation_truncation_and_sparse_expansion(self): + value = Array.of(1, 2, 3) + value.length = 1 + value.length = "4" + self.assertEqual(list(value), [1, undefined, undefined, undefined]) + self.assertFalse(Object.hasOwn(value, 2)) + value[10] = 8 + self.assertEqual(value.length, 11) + del value[10] + self.assertEqual(value.length, 11) + for length in (-1, 1.5, math.nan, math.inf, 2**32): + with self.assertRaises(ValueError): + value.length = length + self.assertEqual(Array(2**32 - 1).length, 2**32 - 1) + + def test_negative_bracket_is_a_property_and_at_is_relative(self): + value = Array.of("a", "b") + self.assertIs(value[-1], undefined) + value[-1] = "named" + self.assertEqual(value[-1], "named") + self.assertEqual(value.at(-1), "b") + self.assertIs(value.at(-3), undefined) + self.assertIs(value.at(math.inf), undefined) + self.assertEqual(value.length, 2) + + def test_mutators_preserve_holes_and_return_javascript_results(self): + value = Array(2) + value[1] = 2 + self.assertEqual(value.push(3, 4), 4) + self.assertEqual(value.pop(), 4) + self.assertIs(value.shift(), undefined) + self.assertEqual(value.unshift(0, 1), 4) + self.assertEqual(list(value), [0, 1, 2, 3]) + self.assertIs(Array().pop(), undefined) + self.assertIs(Array().shift(), undefined) + + def test_slice_splice_concat_and_fill(self): + value = Array(4) + value[1], value[3] = "a", "b" + sliced = value.slice(0, -1) + self.assertEqual(sliced.length, 3) + self.assertEqual(list(Object.keys(sliced)), ["1"]) + removed = value.splice(1, 2, "x", "y", "z") + self.assertEqual(list(removed), ["a", undefined]) + self.assertFalse(Object.hasOwn(removed, 1)) + self.assertEqual(list(value), [undefined, "x", "y", "z", "b"]) + combined = Array(1).concat(Array.of(1), [2, 3]) + self.assertEqual(combined.length, 3) + self.assertFalse(Object.hasOwn(combined, 0)) + self.assertEqual(combined[2], [2, 3]) + self.assertIs(value.fill(7, -2), value) + self.assertEqual(list(value)[-2:], [7, 7]) + self.assertEqual(value.splice().length, 0) + + def test_search_uses_correct_equality_and_hole_rules(self): + value = Array(1).concat(Array.of(math.nan, True, 1, -0.0, undefined)) + self.assertTrue(value.includes(math.nan)) + self.assertEqual(value.indexOf(math.nan), -1) + self.assertEqual(value.indexOf(undefined), 5) + self.assertTrue(Array(1).includes(undefined)) + self.assertEqual(value.indexOf(1), 3) + self.assertEqual(value.lastIndexOf(undefined), 5) + self.assertEqual(value.lastIndexOf(undefined, undefined), -1) + self.assertEqual(value.indexOf(0, -2), 4) + target = Object() + self.assertEqual(Array.of(Object(), target).indexOf(target), 1) + + def test_callback_methods_skip_holes_except_find_and_find_last(self): + value = Array(3) + value[1] = 2 + mapped = value.map(lambda child: child * 3) + self.assertEqual(list(Object.keys(mapped)), ["1"]) + self.assertEqual(mapped[1], 6) + self.assertEqual(list(value.filter(lambda child: True)), [2]) + visited = [] + self.assertIs(value.forEach(lambda child, index: visited.append((child, index))), undefined) + self.assertEqual(visited, [(2, 1)]) + self.assertTrue(value.every(lambda child: child == 2)) + self.assertTrue(value.some(lambda child: child == 2)) + self.assertEqual(value.findIndex(lambda child: child is undefined), 0) + self.assertEqual(value.findLastIndex(lambda child: child is undefined), 2) + self.assertIs(value.findLast(lambda child: child is undefined), undefined) + self.assertTrue(Array.of(1).some(lambda child: [])) + with self.assertRaises(TypeError): + Array().map(None) + + def test_callbacks_snapshot_length_and_observe_mutation(self): + value = Array.of(1, 2, 3) + seen = [] + def visit(child, index, array): + seen.append(child) + if index == 0: + array[1] = 9 + del array[2] + array.push(4) + return child + mapped = value.map(visit) + self.assertEqual(seen, [1, 9]) + self.assertEqual(mapped.length, 3) + self.assertFalse(Object.hasOwn(mapped, 2)) + + def test_reductions_distinguish_missing_and_explicit_undefined(self): + value = Array(1).concat(Array.of(2, 3)) + self.assertEqual(value.reduce(lambda total, child: total + child), 5) + self.assertEqual(value.reduceRight(lambda total, child: total - child), 1) + self.assertEqual(value.reduce(lambda total, child: total + child, 10), 15) + self.assertIs(Array().reduce(lambda total, child: child, undefined), undefined) + with self.assertRaises(TypeError): + Array(3).reduce(lambda total, child: child) + + def test_sort_reverse_and_join(self): + value = Array(1).concat(Array.of(10, 2, undefined, 1)) + self.assertIs(value.sort(), value) + self.assertEqual(list(value), [1, 10, 2, undefined, undefined]) + self.assertTrue(Object.hasOwn(value, 3)) + self.assertFalse(Object.hasOwn(value, 4)) + self.assertEqual(list(Array.of(10, 2, 1).sort(lambda a, b: a - b)), [1, 2, 10]) + self.assertIs(value.reverse(), value) + self.assertFalse(Object.hasOwn(value, 0)) + self.assertEqual(Array.of(1, None, undefined, Array.of(2, 3)).join("|"), "1|||2,3") + + def test_from_and_iterators(self): + self.assertEqual(list(Array.from_(Array(2))), [undefined, undefined]) + self.assertEqual(list(Array.from_([2, 3], lambda value, index: value + index)), [2, 4]) + value = Array.of("a") + iterator = value.values() + self.assertEqual(next(iterator), "a") + value.push("b") + self.assertEqual(next(iterator), "b") + self.assertEqual(list(value.keys()), [0, 1]) + self.assertEqual([list(entry) for entry in value.entries()], [[0, "a"], [1, "b"]]) + + def test_cycle_repr_and_string_conversion(self): + value = Array() + value.push(value) + self.assertIn("...", repr(value)) + self.assertEqual(value.toString(), "") diff --git a/tests/test_object.py b/tests/test_object.py new file mode 100644 index 0000000..ff71753 --- /dev/null +++ b/tests/test_object.py @@ -0,0 +1,84 @@ +import math +import unittest + +from JavaScript import Array, Object, undefined + + +class ObjectTests(unittest.TestCase): + def test_properties_missing_values_and_identity(self): + child = Object({"name": "Ada"}) + value = Object({"child": child, "items": 3, "__class__": "data"}) + self.assertIs(value.child, child) + self.assertEqual(value.items, 3) + self.assertEqual(value["__class__"], "data") + self.assertIs(value.__class__, Object) + self.assertIs(value.missing, undefined) + self.assertIs(value["missing"], undefined) + self.assertNotIn("missing", value) + self.assertIs(Object(value), value) + self.assertNotEqual(value, Object({"child": child, "items": 3})) + self.assertTrue(Object()) + + def test_mutation_key_conversion_and_safe_names(self): + value = Object() + value.name = "Grace" + value[True] = 1 + value[None] = 2 + value[-0.0] = 3 + value["_properties"] = "user data" + self.assertEqual(value["true"], 1) + self.assertEqual(value["null"], 2) + self.assertEqual(value[0], 3) + self.assertEqual(value._properties, "user data") + del value.name + del value["absent"] + self.assertIs(value.name, undefined) + with self.assertRaises(AttributeError): + value.__class__ = str + + def test_numeric_key_order_and_reinsertion(self): + value = Object({"b": 1, "10": 2, "2": 3, "01": 4, "0": 5, "4294967295": 6}) + self.assertEqual(list(Object.keys(value)), ["0", "2", "10", "b", "01", "4294967295"]) + del value.b + value.b = 7 + self.assertEqual(list(Object.keys(value))[-1], "b") + value["9" * 5000] = "long key" + self.assertTrue(Object.hasOwn(value, "9" * 5000)) + + def test_enumeration_copy_and_from_entries(self): + child = Object() + value = Object.assign(Object(), None, {"a": child}, {"a": 2, "b": child}) + self.assertEqual(list(Object.values(value)), [2, child]) + self.assertIs(value.b, child) + restored = Object.fromEntries(Object.entries(value)) + self.assertIs(restored.b, child) + self.assertEqual(restored.a, 2) + self.assertEqual(Object.fromEntries([["a", 1], ["a", 2]]).a, 2) + self.assertIs(Object.fromEntries([["a"]]).a, undefined) + self.assertEqual(Object.fromEntries([Object({"0": "x", "1": 4})]).x, 4) + with self.assertRaises(TypeError): + Object.fromEntries(["xy"]) + + def test_array_and_string_own_properties(self): + value = Array(3) + value[1] = undefined + value["-1"] = 9 + self.assertEqual(list(Object.keys(value)), ["1", "-1"]) + self.assertTrue(Object.hasOwn(value, "length")) + self.assertFalse(Object.hasOwn(value, 0)) + self.assertTrue(Object.hasOwn(value, 1)) + self.assertEqual(list(Object.keys("😀")), ["0", "1"]) + self.assertEqual(list(Object.values("😀")), ["\ud83d", "\ude00"]) + + def test_null_errors_and_same_value(self): + for value in (None, undefined): + with self.assertRaises(TypeError): + Object.keys(value) + self.assertTrue(Object.is_(math.nan, math.nan)) + self.assertFalse(Object.is_(0, -0.0)) + self.assertFalse(Object.is_(Object(), Object())) + + def test_repr_handles_cycles(self): + value = Object() + value.self = value + self.assertIn("...", repr(value))