diff --git a/.github/workflows/pypi-release.yml b/.github/workflows/pypi-release.yml index 6a156c56..97949d1d 100644 --- a/.github/workflows/pypi-release.yml +++ b/.github/workflows/pypi-release.yml @@ -12,8 +12,8 @@ jobs: name: Build the distribution runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 with: python-version: ${{ vars.PYTHON_VERSION }} - name: Install Poetry @@ -23,7 +23,7 @@ jobs: - name: Build Basilisp distributions run: poetry build - name: Upload build artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: path: dist/ if-no-files-found: error diff --git a/.github/workflows/run-clojure-test-suite.yml b/.github/workflows/run-clojure-test-suite.yml index a120004d..aeb1e61b 100644 --- a/.github/workflows/run-clojure-test-suite.yml +++ b/.github/workflows/run-clojure-test-suite.yml @@ -12,14 +12,14 @@ jobs: run-clojure-test-suite: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: path: basilisp - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: path: clojure-test-suite repository: basilisp-lang/clojure-test-suite - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6 with: python-version: '3.13' - name: Install pip and dependencies diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index f0aeb5e5..386dc1e7 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -35,13 +35,13 @@ jobs: version: '3.14' tox-env: py314,py314-lint steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 with: python-version: ${{ matrix.version }} - name: Cache dependencies id: cache-deps - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | .tox @@ -76,7 +76,7 @@ jobs: for filename in .coverage.*; do mv "$filename" "coverage/$filename.py${{ matrix.version }}"; done; - name: Archive code coverage results if: "startsWith (matrix.os, 'ubuntu')" - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: code-coverage.py${{ matrix.version }} path: coverage/.coverage.* @@ -95,13 +95,13 @@ jobs: - version: '3.11' tox-env: pypy311 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 with: python-version: pypy${{ matrix.version }} - name: Cache dependencies id: cache-deps - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | .tox @@ -127,7 +127,7 @@ jobs: mkdir coverage for filename in .coverage.*; do mv "$filename" "coverage/$filename.pypy${{ matrix.version }}"; done; - name: Archive code coverage results - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: code-coverage.pypy${{ matrix.version }} path: coverage/.coverage.* @@ -154,13 +154,13 @@ jobs: version: ['3.10'] tox-env: ['py310'] steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 with: python-version: ${{ matrix.version }} - name: Cache dependencies id: cache-deps - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | .tox @@ -196,13 +196,13 @@ jobs: - run-tests - run-pypy-tests steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 with: python-version: ${{ vars.PYTHON_VERSION }} - name: Cache dependencies id: cache-deps - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | .tox diff --git a/.github/workflows/test-pypi-release.yml b/.github/workflows/test-pypi-release.yml index 3dc39df9..7ada4f9b 100644 --- a/.github/workflows/test-pypi-release.yml +++ b/.github/workflows/test-pypi-release.yml @@ -12,8 +12,8 @@ jobs: name: Build the distribution runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 with: python-version: ${{ vars.PYTHON_VERSION }} - name: Install Poetry @@ -21,7 +21,7 @@ jobs: - name: Build Basilisp distributions run: poetry build - name: Upload build artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: path: dist/ if-no-files-found: error diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bdb68c2..75726e6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed * Add `test` and `tests` directories to `PYTHONPATH` automatically during `basilisp test` CLI invocations (#1069) +### Fixed + * Fix a bug where transient vectors were not callable and did not support `nth` (#1331) + ## [v0.5.0] ### Added * Added support for Python 3.14 (#1282) diff --git a/src/basilisp/lang/interfaces.py b/src/basilisp/lang/interfaces.py index fc16fca3..19b11ab4 100644 --- a/src/basilisp/lang/interfaces.py +++ b/src/basilisp/lang/interfaces.py @@ -76,18 +76,28 @@ class ICounted(Sized, ABC): __slots__ = () -class IIndexed(ICounted, ABC): - """``IIndexed`` is a marker interface for types can be accessed by index. +K = TypeVar("K") +V = TypeVar("V") + + +class IIndexed(ICounted, Generic[V], ABC): + """``IIndexed`` is an interface for types can be accessed by index. Of the builtin collections, only Vectors are ``IIndexed`` . ``IIndexed`` types respond ``True`` to the :lpy:fn:`indexed?` predicate. .. seealso:: - :lpy:fn:`indexed?`""" + :lpy:fn:`indexed?`, :lpy:fn:`nth`""" __slots__ = () + NTH_SENTINEL = object() + + @abstractmethod + def nth(self, k: int, notfound: V | None = NTH_SENTINEL) -> V | None: # type: ignore[assignment] + raise NotImplementedError() + T_ExceptionInfo = TypeVar("T_ExceptionInfo", bound="IPersistentMap") @@ -109,10 +119,6 @@ def data(self) -> T_ExceptionInfo: raise NotImplementedError() -K = TypeVar("K") -V = TypeVar("V") - - class IMapEntry(Generic[K, V], ABC): """``IMapEntry`` values are produced :lpy:fn:`seq` ing over any :py:class:`IAssociative` (such as a Basilisp map). diff --git a/src/basilisp/lang/runtime.py b/src/basilisp/lang/runtime.py index 8ba6ba19..d6912b7a 100644 --- a/src/basilisp/lang/runtime.py +++ b/src/basilisp/lang/runtime.py @@ -35,6 +35,7 @@ IAssociative, IBlockingDeref, IDeref, + IIndexed, ILookup, IMapEntry, IPersistentCollection, @@ -1437,11 +1438,8 @@ def _count_sized(coll: Sized): return len(coll) -__nth_sentinel = object() - - @functools.singledispatch -def nth(coll, i: int, notfound=__nth_sentinel): +def nth(coll, i: int, notfound=IIndexed.NTH_SENTINEL): """Returns the ith element of coll (0-indexed), if it exists. None otherwise. If i is out of bounds, throws an IndexError unless notfound is specified.""" @@ -1449,27 +1447,32 @@ def nth(coll, i: int, notfound=__nth_sentinel): @nth.register(type(None)) -def _nth_none(_: None, i: int, notfound=__nth_sentinel) -> None: - return notfound if notfound is not __nth_sentinel else None # type: ignore[return-value] +def _nth_none(_: None, i: int, notfound=IIndexed.NTH_SENTINEL) -> None: + return notfound if notfound is not IIndexed.NTH_SENTINEL else None # type: ignore[return-value] @nth.register(Sequence) -def _nth_sequence(coll: Sequence, i: int, notfound=__nth_sentinel): +def _nth_sequence(coll: Sequence, i: int, notfound=IIndexed.NTH_SENTINEL): try: return coll[i] except IndexError as ex: - if notfound is not __nth_sentinel: + if notfound is not IIndexed.NTH_SENTINEL: return notfound raise ex +@nth.register(IIndexed) +def _nth_iindexed(coll: IIndexed, i: int, notfound=IIndexed.NTH_SENTINEL): + return coll.nth(i, notfound=notfound) + + @nth.register(ISeq) -def _nth_iseq(coll: ISeq, i: int, notfound=__nth_sentinel): +def _nth_iseq(coll: ISeq, i: int, notfound=IIndexed.NTH_SENTINEL): for j, e in enumerate(coll): if i == j: return e - if notfound is not __nth_sentinel: + if notfound is not IIndexed.NTH_SENTINEL: return notfound raise IndexError(f"Index {i} out of bounds") diff --git a/src/basilisp/lang/vector.py b/src/basilisp/lang/vector.py index 36a1be12..605c0da2 100644 --- a/src/basilisp/lang/vector.py +++ b/src/basilisp/lang/vector.py @@ -8,6 +8,7 @@ from basilisp.lang.interfaces import ( IEvolveableCollection, + IIndexed, ILispObject, IMapEntry, IPersistentMap, @@ -51,6 +52,9 @@ def __eq__(self, other): def __len__(self): return len(self._inner) + def __call__(self, k: int) -> T | None: + return self._inner[k] + def cons_transient(self, *elems: T) -> "TransientVector[T]": # type: ignore[override] for elem in elems: self._inner.append(elem) @@ -82,6 +86,14 @@ def val_at(self, k: int, default=None): except IndexError: return default + def nth(self, k: int, notfound=IIndexed.NTH_SENTINEL): + try: + return self._inner[k] + except IndexError: + if notfound is not IIndexed.NTH_SENTINEL: + return notfound + raise + def pop_transient(self) -> "TransientVector[T]": if len(self) == 0: raise IndexError("Cannot pop an empty vector") @@ -202,6 +214,14 @@ def val_at(self, k: int, default: T | None = None) -> T | None: except (IndexError, TypeError): return default + def nth(self, k: int, notfound=IIndexed.NTH_SENTINEL): + try: + return self._inner[k] + except IndexError: + if notfound is not IIndexed.NTH_SENTINEL: + return notfound + raise + def empty(self) -> "PersistentVector[T]": return EMPTY.with_meta(self._meta) diff --git a/tests/basilisp/core/test_transients.lpy b/tests/basilisp/core/test_transients.lpy index f5500e3a..833b0c85 100644 --- a/tests/basilisp/core/test_transients.lpy +++ b/tests/basilisp/core/test_transients.lpy @@ -94,6 +94,16 @@ [] [:a :b :c])) + (testing "callable" + (are [v idx res] (= res ((transient v) idx)) + [1] 0 1 + [:a :b :c] 1 :b) + + (are [v idx] (thrown? python/IndexError ((transient v) idx)) + [] 0 + [] 1 + [:a :b :c] 4)) + (testing "count" (let [trx (volatile! (transient [:a :b :c]))] (is (= 3 (count @trx))) @@ -116,11 +126,21 @@ (testing "get" (are [x y z] (= z (get (transient x) y)) - [] 1 nil + [] 1 nil [:a :b :c] 1 :b) (is (= :e (get (transient [:a :b :c]) 4 :e)))) + (testing "nth" + (are [v idx res] (= res (nth (transient v) idx)) + [1] 0 1 + [:a :b :c] 1 :b) + + (are [v idx] (thrown? python/IndexError (nth (transient v) idx)) + [] 0 + [] 1 + [:a :b :c] 4)) + (testing "not seqable" (is (thrown? python/TypeError (seq (transient [])))))) diff --git a/tests/basilisp/vector_test.py b/tests/basilisp/vector_test.py index c1ce61af..d2d017a5 100644 --- a/tests/basilisp/vector_test.py +++ b/tests/basilisp/vector_test.py @@ -149,6 +149,44 @@ def test_val_at(): assert "default" == vec.EMPTY.val_at("key", "default") +@pytest.mark.parametrize( + "v,i", [(vec.v("a", "b"), 2), (vec.EMPTY, 0), (vec.EMPTY, 1), (vec.EMPTY, -1)] +) +def test_nth_invalid_index(v: vec.PersistentVector, i): + with pytest.raises(IndexError): + v.nth(i) + + +@pytest.mark.parametrize( + "res,v,i", + [("a", vec.v("a"), 0), ("b", vec.v("a", "b"), 1), ("b", vec.v("a", "b"), -1)], +) +def test_nth(res, v: vec.PersistentVector, i: int): + assert res == v.nth(i) + + +@pytest.mark.parametrize( + "res,v,i,default", + [ + ("other", vec.EMPTY, -1, "other"), + ("other", vec.v("a"), 1, "other"), + ("b", vec.v("a", "b"), 1, "other"), + ("other", vec.v("a", "b"), 8, "other"), + ("b", vec.v("a", "b"), -1, "other"), + ], +) +def test_nth_default(res, v: vec.PersistentVector, i: int, default): + assert res == v.nth(i, notfound=default) + + +def test_nth_invalid_keys(): + with pytest.raises(TypeError): + vec.EMPTY.nth(keyword("key")) + + with pytest.raises(TypeError): + vec.EMPTY.nth(keyword("blah"), "default") + + def test_peek(): assert None is vec.v().peek()