Repository: amaranth-lang/amaranth (python)

Issue:
Make `Shape` immutable and hashable
It has never been intended that a `Shape` could be mutated.

Pull request description:
hdl._ast: make `Shape` immutable and hashable.
Fixes #1127.

Patch:
diff --git a/amaranth/hdl/_ast.py b/amaranth/hdl/_ast.py
index dfa802f..cee94b8 100644
--- a/amaranth/hdl/_ast.py
+++ b/amaranth/hdl/_ast.py
@@ -61,8 +61,16 @@ class Shape:
                             f"not {width}")
         if signed and width <= 0:
             raise TypeError(f"Width of a signed value must be a positive integer, not {width}")
-        self.width = width
-        self.signed = bool(signed)
+        self._width = width
+        self._signed = bool(signed)
+
+    @property
+    def width(self):
+        return self._width
+
+    @property
+    def signed(self):
+        return self._signed
 
     # The algorithm for inferring shape for standard Python enumerations is factored out so that
     # `Shape.cast()` and Amaranth's `EnumMeta.as_shape()` can both use it.
@@ -144,6 +152,9 @@ class Shape:
         else:
             return f"unsigned({self.width})"
 
+    def __hash__(self):
+        return hash((self._width, self._signed))
+
     def __eq__(self, other):
         return (isinstance(other, Shape) and
                 self.width == other.width and self.signed == other.signed)
diff --git a/docs/changes.rst b/docs/changes.rst
index b60e15a..d7e122b 100644
--- a/docs/changes.rst
+++ b/docs/changes.rst
@@ -63,6 +63,7 @@ Language changes
 * Changed: ``Signal(range(0))`` is now valid without a warning.
 * Changed: ``Shape.cast(range(1))`` is now ``unsigned(0)``. (`RFC 46`_)
 * Changed: the ``reset=`` argument of :class:`Signal`, :meth:`Signal.like`, :class:`amaranth.lib.wiring.Member`, :class:`amaranth.lib.cdc.FFSynchronizer`, and ``m.FSM()`` has been renamed to ``init=``. (`RFC 43`_)
+* Changed: :class:`Shape` has been made immutable and hashable.
 * Deprecated: :func:`amaranth.utils.log2_int`. (`RFC 17`_)
 * Removed: (deprecated in 0.4) :meth:`Const.normalize`. (`RFC 5`_)
 * Removed: (deprecated in 0.4) :class:`Repl`. (`RFC 10`_)


Test patch:
diff --git a/tests/test_hdl_ast.py b/tests/test_hdl_ast.py
index 6740c9e..54a7d02 100644
--- a/tests/test_hdl_ast.py
+++ b/tests/test_hdl_ast.py
@@ -152,6 +152,16 @@ class ShapeTestCase(FHDLTestCase):
                 r"^Object 'foo' cannot be converted to an Amaranth shape$"):
             Shape.cast("foo")
 
+    def test_hashable(self):
+        d = {
+            signed(2): "a",
+            unsigned(3): "b",
+            unsigned(2): "c",
+        }
+        self.assertEqual(d[signed(2)], "a")
+        self.assertEqual(d[unsigned(3)], "b")
+        self.assertEqual(d[unsigned(2)], "c")
+
 
 class MockShapeCastable(ShapeCastable):
     def __init__(self, dest):