diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 00000000..742726b6 --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,42 @@ +# This builds a preview of the docs which can be seen on pull requests. It +# also uses the .github/workflows/docs-preview.yml GitHub Actions workflow. + +# This is separate from the GitHub Actions build that builds the docs, which +# also deploys the docs +version: 2 + +# Aliases to reuse +_defaults: &defaults + docker: + # CircleCI maintains a library of pre-built images + # documented at https://circleci.com/docs/2.0/circleci-images/ + - image: cimg/python:3.10.2 + working_directory: ~/repo + +jobs: + Build Docs Preview: + <<: *defaults + steps: + - checkout + - attach_workspace: + at: ~/ + - run: + name: Install dependencies + no_output_timeout: 25m + command: | + cd docs + pip install -r requirements.txt + - run: + name: Build docs + no_output_timeout: 25m + command: | + cd docs + make html + - store_artifacts: + path: docs/_build/html + +workflows: + version: 2 + default: + jobs: + - Build Docs Preview diff --git a/.github/workflows/docs-preview.yml b/.github/workflows/docs-preview.yml new file mode 100644 index 00000000..70b1f2e4 --- /dev/null +++ b/.github/workflows/docs-preview.yml @@ -0,0 +1,21 @@ +name: Docs Preview +on: [status] +jobs: + circleci_artifacts_redirector_job: + if: "${{ github.event.context == 'ci/circleci: Build Docs Preview' }}" + runs-on: ubuntu-latest + name: Run CircleCI artifacts redirector + steps: + - name: GitHub Action step + id: step1 + uses: larsoner/circleci-artifacts-redirector-action@master + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + artifact-path: 0/docs/_build/html/index.html + circleci-jobs: Build Docs Preview + job-title: Click here to see a preview of the documentation. + api-token: ${{ secrets.CIRCLECI_TOKEN }} + - name: Check the URL + if: github.event.status != 'pending' + run: | + curl --fail ${{ steps.step1.outputs.url }} | grep $GITHUB_SHA diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 8d05cc2a..0ffef95e 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -22,7 +22,7 @@ jobs: conda config --add channels conda-forge conda update -q conda conda info -a - conda create -n test-environment python=${{ matrix.python-version }} pyflakes pytest pytest-doctestplus numpy hypothesis doctr sphinx myst-parser sphinx_rtd_theme pytest-cov pytest-flakes + conda create -n test-environment python=${{ matrix.python-version }} --file docs/requirements.txt conda init - name: Build Docs @@ -51,3 +51,14 @@ jobs: conda activate test-environment cd docs make html + + # Note, the gh-pages deployment requires setting up a SSH deploy key. + # See + # https://github.com/JamesIves/github-pages-deploy-action/tree/dev#using-an-ssh-deploy-key- + - name: Deploy + uses: JamesIves/github-pages-deploy-action@v4 + if: ${{ github.ref == 'refs/heads/main' }} + with: + folder: docs/_build/html + ssh-key: ${{ secrets.DEPLOY_KEY }} + clean-exclude: benchmarks/ diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 6e548941..0c683fc7 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -5,7 +5,12 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.7', '3.8', '3.9', '3.10'] + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12-dev'] + # https://numpy.org/neps/nep-0029-deprecation_policy.html + numpy-version: ['1.22', 'latest', 'dev'] + exclude: + - python-version: '3.12-dev' + numpy-version: '1.22' fail-fast: false steps: - uses: actions/checkout@v2 @@ -16,39 +21,19 @@ jobs: run: | set -x set -e - # $CONDA is an environment variable pointing to the root of the miniconda directory - echo $CONDA/bin >> $GITHUB_PATH - conda config --set always_yes yes --set changeps1 no - conda config --add channels conda-forge - conda update -q conda - conda info -a - conda create -n test-environment python=${{ matrix.python-version }} pyflakes pytest pytest-doctestplus numpy hypothesis doctr sphinx myst-parser sphinx_rtd_theme pytest-cov pytest-flakes packaging - conda init - - - name: Run Tests - run: | - # Copied from .bashrc. We can't just source .bashrc because it exits - # when the shell isn't interactive. - - # >>> conda initialize >>> - # !! Contents within this block are managed by 'conda init' !! - __conda_setup="$('/usr/share/miniconda/bin/conda' 'shell.bash' 'hook' 2> /dev/null)" - if [ $? -eq 0 ]; then - eval "$__conda_setup" + python -m pip install pyflakes pytest pytest-doctestplus hypothesis pytest-cov pytest-flakes packaging + if [[ ${{ matrix.numpy-version }} == 'latest' ]]; then + python -m pip install --pre --upgrade numpy + elif [[ ${{ matrix.numpy-version }} == 'dev' ]]; then + python -m pip install --pre --upgrade --extra-index https://pypi.anaconda.org/scientific-python-nightly-wheels/simple numpy else - if [ -f "/usr/share/miniconda/etc/profile.d/conda.sh" ]; then - . "/usr/share/miniconda/etc/profile.d/conda.sh" - else - export PATH="/usr/share/miniconda/bin:$PATH" - fi + python -m pip install --upgrade numpy==${{ matrix.numpy-version }}.* fi - unset __conda_setup - # <<< conda initialize <<< - + - name: Run Tests + run: | set -x set -e - conda activate test-environment python -We:invalid -We::SyntaxWarning -m compileall -f -q ndindex/ # The coverage requirement check is done by the coverage report line below PYTEST_FLAGS="$PYTEST_FLAGS -v --cov-fail-under=0"; diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 3d870151..00000000 --- a/.travis.yml +++ /dev/null @@ -1,61 +0,0 @@ -language: python - -dist: xenial - -matrix: - include: - - python: 3.7 - env: - - TESTS=true - - python: 3.8 - env: - - TESTS=true - - DOCS=true - # Doctr deploy key for Quansight-Labs/ndindex - - secure: "oLAqRes+Qylu3xU753wlruJH/V8904rDfQfutk1SFvpxPHDZlxj8RbwpvkVKRPBU0nucIs75z9WRm2XdeJB/bdwMcx0gkL5N99L0BbPxKgicsPI6IPTe7aI5KmStEyQw65xO3Vu9vlGxMtDP2HIEEpq7brDvVAg/lRBcPn2ahujW0BUPVnoch3CZKLsE7f05Yeyc5aBCsOVCUHpV2riptabjAqjgJJVYa1BdlyUals99oRB671kFqyiBSzoeSAriII/joLGwNDaUlYc0EmUoSZZmNKc0I5xAXGwIwhgmvhZ2dwqiy2G253apnHaFyf/wqLyvQPqf8Fr6MVW3hc/EujDqE3y7pwR+UANXvEfbDqCxchhyRfNHswyARwD+DqailOt5voL4q/GMh8NnqMT1aEedAByZ/d+iX3npxwGSHx0qoBJs0HivPuN8t9qJGufI/ux66oASwJQeOZLfed0vVH5A/P3tmEV0IichCfj4horvr1A+h7tZcgN313MI0Lap2+6WnodF1b2AvIR/02OMWBna+P8UCfG4RR7i5Bm8S/jWKV/GVZyN3ACWciwV/NjDr5dxayDYrrm+s3akfLYuZzQGF/gmaNjbDgIN+lUn7Zm4Ayp4yoKz7tBeS8Uhcg5tcSJmoJj77X6XriL9uO9JIkmNRq3pIYHGwlOv8q83fJI=" - - name: python 3.9 - env: - - PYTHON_VERSION=3.9 - - TESTS=true - -install: - - wget https://repo.continuum.io/miniconda/Miniconda3-latest-Linux-x86_64.sh -O miniconda.sh; - - bash miniconda.sh -b -p $HOME/miniconda - - export PATH="$HOME/miniconda/bin:$PATH" - - hash -r - - conda config --set always_yes yes --set changeps1 no - - conda config --add channels conda-forge - - conda update -q conda - - conda info -a - - conda create -n test-environment python=${PYTHON_VERSION:-$TRAVIS_PYTHON_VERSION} pyflakes pytest pytest-doctestplus hypothesis doctr sphinx myst-parser sphinx_rtd_theme pytest-cov pytest-flakes - - source activate test-environment - - pip install --pre numpy>=1.20 - -script: - - set -e - - python -We:invalid -We::SyntaxWarning -m compileall -f -q ndindex/ - # The coverage requirement check is done by the coverage report line below - - PYTEST_FLAGS="$PYTEST_FLAGS -v --cov-fail-under=0"; - - pytest $PYTEST_FLAGS - - ./run_doctests - # Make sure it installs - - python setup.py install - - if [[ "${DOCS}" == "true" ]]; then - cd docs; - make html; - cd ..; - if [[ "${TRAVIS_BRANCH}" == "master" ]]; then - doctr deploy .; - else - doctr deploy --no-require-master "_docs-$TRAVIS_BRANCH"; - fi - fi - # Coverage. This also sets the failing status if the - # coverage is not 100%. Travis sometimes cuts off the last command, which is - # why we print stuff at the end. - - if ! coverage report -m; then - echo "Coverage failed"; - false; - else - echo "Coverage passed"; - fi; diff --git a/MANIFEST.in b/MANIFEST.in index bccc50cb..caaeea04 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,6 @@ include versioneer.py include ndindex/_version.py include LICENSE +include pytest.ini +include conftest.py +include run_doctests.py diff --git a/conftest.py b/conftest.py index 5b2966a3..295cda47 100644 --- a/conftest.py +++ b/conftest.py @@ -13,6 +13,10 @@ if LooseVersion(numpy.__version__) < LooseVersion('1.20'): raise RuntimeError("NumPy 1.20 (development version) or greater is required to run the ndindex tests") +# Show the NumPy version in the pytest header +def pytest_report_header(config): + return f"project deps: numpy-{numpy.__version__}" + # Add a --hypothesis-max-examples flag to pytest. See # https://github.com/HypothesisWorks/hypothesis/issues/2434#issuecomment-630309150 diff --git a/docs/_pygments/styles.py b/docs/_pygments/styles.py new file mode 100644 index 00000000..1269bf5e --- /dev/null +++ b/docs/_pygments/styles.py @@ -0,0 +1,102 @@ +""" +Pygments styles used for syntax highlighting. + +These are based on the Sphinx style (see +https://github.com/sphinx-doc/sphinx/blob/master/sphinx/pygments_styles.py) +for light mode and the Friendly style for dark mode. + +The styles here have been adjusted so that they are WCAG AA compatible. The +tool at https://github.com/mpchadwick/pygments-high-contrast-stylesheets was +used to identify colors that should be adjusted. + +""" +from pygments.style import Style +from pygments.styles.friendly import FriendlyStyle +from pygments.styles.native import NativeStyle +from pygments.token import Comment, Generic, Literal, Name, Number, Text + +class SphinxHighContrastStyle(Style): + """ + Like Sphinx (which is like friendly, but a bit darker to enhance contrast + on the green background) but with higher contrast colors. + + """ + + @property + def _pre_style(self): + # This is used instead of the default 125% so that multiline Unicode + # pprint output looks good + return 'line-height: 120%;' + + background_color = '#eeffcc' + default_style = '' + + styles = FriendlyStyle.styles + styles.update({ + # These are part of the Sphinx modification to "friendly" + Generic.Output: '#333', + Number: '#208050', + + # These are adjusted from "friendly" (Comment is adjusted from + # "sphinx") to have better color contrast against the background. + Comment: 'italic #3c7a88', + Comment.Hashbang: 'italic #3c7a88', + Comment.Multiline: 'italic #3c7a88', + Comment.PreprocFile: 'italic #3c7a88', + Comment.Single: 'italic #3c7a88', + Comment.Special: '#3a7784 bg:#fff0f0', + Generic.Error: '#e60000', + Generic.Inserted: '#008200', + Generic.Prompt: 'bold #b75709', + Name.Class: 'bold #0e7ba6', + Name.Constant: '#2b79a1', + Name.Entity: 'bold #c54629', + Name.Namespace: 'bold #0e7ba6', + Name.Variable: '#ab40cd', + Text.Whitespace: '#707070', + Literal.String.Interpol: 'italic #3973b7', + Literal.String.Other: '#b75709', + Name.Variable.Class: '#ab40cd', + Name.Variable.Global: '#ab40cd', + Name.Variable.Instance: '#ab40cd', + Name.Variable.Magic: '#ab40cd', + }) + + + +class NativeHighContrastStyle(NativeStyle): + """ + Like native, but with higher contrast colors. + """ + @property + def _pre_style(self): + # This is used instead of the default 125% so that multiline Unicode + # pprint output looks good + return 'line-height: 120%;' + + styles = NativeStyle.styles + + # These are adjusted to have better color contrast against the background + styles.update({ + Comment.Preproc: 'bold #e15a5a', + Comment.Special: 'bold #f75050 bg:#520000', + Generic.Deleted: '#e75959', + Generic.Error: '#e75959', + Generic.Traceback: '#e75959', + Literal.Number: '#438dc4', + Name.Builtin: '#2594a1', + # We also remove the underline here from the original style + Name.Class: '#548bd3', + Name.Function: '#548bd3', + # We also remove the underline here from the original style + Name.Namespace: '#548bd3', + Text.Whitespace: '#878787', + Literal.Number.Bin: '#438dc4', + Literal.Number.Float: '#438dc4', + Literal.Number.Hex: '#438dc4', + Literal.Number.Integer: '#438dc4', + Literal.Number.Oct: '#438dc4', + Name.Builtin.Pseudo: '#2594a1', + Name.Function.Magic: '#548bd3', + Literal.Number.Integer.Long: '#438dc4', + }) diff --git a/docs/_static/custom.css b/docs/_static/custom.css index 3b3ff689..089432d5 100644 --- a/docs/_static/custom.css +++ b/docs/_static/custom.css @@ -1,110 +1,128 @@ -nav#rellinks { - float: left; - width: 100%; +/* Make the title text in the sidebar bold */ +.sidebar-brand-text { + font-weight: bold; +} +/* Remove the underline from the title text on hover */ +.sidebar-brand:hover { + text-decoration: none !important; } -.related.prev { - float: left; - text-align: left; - width: 50%; +:root { + /* ndindex brand colors, from logo/ndindex_final_2.pdf. */ + --color-brand-light-blue: #9ECBFF; + --color-brand-green: #15C293; + --color-brand-medium-blue: #115DF6; + --color-brand-dark-blue: #0D41AC; + --color-brand-dark-bg: #050038; + --color-brand-bg: white; + + --color-sidebar-current: white; + --color-sidebar-background-current: var(--color-brand-medium-blue); + --color-sidebar--hover: var(--color-brand-dark-blue); } -.related.next { - float: right; - text-align: right; - width: 50% +@media (prefers-color-scheme: dark) { + :root { + --color-sidebar-background-current: var(--color-brand-dark-blue); + --color-brand-bg: var(--color-brand-dark-bg); + --color-sidebar--hover: white; + } +} +[data-theme='dark'] { + --color-sidebar-background-current: var(--color-brand-dark-blue); + --color-brand-bg: var(--color-brand-dark-bg); + --color-sidebar--hover: white; } -nav#rellinks li+li:before { - content: ""; +/* The furo theme uses only-light and only-dark for light/dark-mode only + images, but they use display:block, so define + only-light-inline/only-dark-inline to use display:inline. */ + +.only-light-inline { + display: inline !important; +} +html body .only-dark-inline { + display: none !important; +} +@media not print { + html body[data-theme=dark] .only-light-inline { + display: none !important; + } + body[data-theme=dark] .only-dark-inline { + display: inline !important; + } + @media (prefers-color-scheme: dark) { + html body:not([data-theme=light]) .only-light-inline { + display: none !important; + } + body:not([data-theme=light]) .only-dark-inline { + display: inline !important; + } + } } -div.sphinxsidebar { - max-height: 95%; - overflow-y: auto; +/* Make top-level items in the sidebar bold */ +.sidebar-tree .toctree-l1>.reference, .sidebar-tree .toctree-l1>label .icon { + font-weight: bold !important; } -div.sphinxsidebar p.logo { - display: inline; +/* Indicate the current page using a background color rather than bold text */ +.sidebar-tree .current-page>.reference { + font-weight: normal; + background-color: var(--color-sidebar-background-current); + color: var(--color-sidebar-current); +} +.sidebar-tree .reference:hover { + color: var(--color-sidebar--hover); } -div.sphinxsidebar hr { - width: 100%; +/* The "hide search matches" text after doing a search. Defaults to the same + color as the search icon which is illegible on the colored background. */ +.highlight-link a { + color: white !important; } -/* Disable white background on some links, since our background is not white. */ -tt, code { - background-color: inherit; +.admonition.warning>.admonition-title { + color: white; } -div.admonition tt.xref, div.admonition code.xref, div.admonition a tt, tt.xref, code.xref, a tt { - background-color: inherit; - border-bottom: 0; +/* Disable underlines on links except on hover */ +a { + text-decoration: none; +} +a:hover { + text-decoration: underline; +} +/* Keep the underline in the announcement header */ +.announcement-content a { + text-decoration: underline; } -/* Pure CSS "Fork me on GitHub" ribbon. - * From - * https://github.com/ssokolow/quicktile/commit/1ae5388ac0f2a2bfa494045644b0ba19eb042329 - * See https://github.com/bitprophet/alabaster/issues/166 - */ +/* Remove the background from code in titles and the sidebar */ +code.literal { + background: inherit; +} -#forkongithub { - position: absolute; - display: block; - top: 0; - right: 0; - width: 200px; - overflow: hidden; - height: 200px; - z-index: 9999; -} -#forkongithub a { - background: #000; - color: #fff; - text-decoration: none; - font-family: arial, sans-serif; - text-align: center; - font-weight: bold; - padding: 5px 40px; - font-size: 10pt; - line-height: 15pt; - width: 200px; - position: absolute; - top: 40px; - right: -82px; - transform: rotate(45deg); - -webkit-transform: rotate(45deg); - -ms-transform: rotate(45deg); - -moz-transform: rotate(45deg); - -o-transform: rotate(45deg); - box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.2); -} -#forkongithub a::before, -#forkongithub a::after { - content: ""; - width: 100%; - display: block; - position: absolute; - top: 1px; - left: 0; - height: 1px; - background: #777; -} -#forkongithub a::after { - bottom: 1px; - top: auto; -} -@media screen and (max-width: 979px) { - #forkongithub a { - display: none; - } +/* Make "Warning" white */ +.admonition.warning>.admonition-title { + color: white; } -@media print { - #forkongithub a { - display: none; - } + +/* Makes the text look better on Mac retina displays (the Furo CSS disables*/ +/* subpixel antialiasing). */ +body { + -webkit-font-smoothing: auto; + -moz-osx-font-smoothing: auto; +} + +/* Disable upcasing of headers 4+ (they are still distinguishable by*/ +/* font-weight and size) */ +h4, h5, h6 { + text-transform: inherit; } -strong:hover > a.headerlink { - visibility: visible; +/* Disable the fancy scrolling behavior when jumping to headers (this is too + slow for long pages) */ +html { + scroll-behavior: auto; } diff --git a/docs/_static/ndindex_logo_dark_bg.svg b/docs/_static/ndindex_logo_dark_bg.svg new file mode 100644 index 00000000..46dcd6ab --- /dev/null +++ b/docs/_static/ndindex_logo_dark_bg.svg @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/_templates/globaltocindex.html b/docs/_templates/globaltocindex.html deleted file mode 100644 index c1657573..00000000 --- a/docs/_templates/globaltocindex.html +++ /dev/null @@ -1,25 +0,0 @@ -{# - basic/globaltoc.html - ~~~~~~~~~~~~~~~~~~~~ - - Sphinx sidebar template: global table of contents. - - :copyright: Copyright 2007-2017 by the Sphinx team, see AUTHORS. - :license: BSD, see LICENSE for details. - - Modified to work properly with the index page -#} -{% if theme_logo %} -

{{ project }}

- {% endif %} - -

-{% else %} -

{{ project }}

-{% endif %} - -{{ toctree(maxdepth=-1, collapse=True) }} diff --git a/docs/_templates/layout.html b/docs/_templates/layout.html deleted file mode 100644 index 2b047659..00000000 --- a/docs/_templates/layout.html +++ /dev/null @@ -1,11 +0,0 @@ -{% extends "!layout.html" %} - - {%- block extrahead %} - {{ super() }} - - {% endblock %} - - {%- block footer %} - {{super()}} - Fork me on GitHub - {%- endblock %} diff --git a/docs/api.rst b/docs/api.rst index 57013e83..97162042 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -6,13 +6,9 @@ The ndindex API consists of classes representing the different types of index objects (integers, slices, etc.), as well as some helper functions for dealing with indices. - ndindex ======= -ndindex -------- - .. autofunction:: ndindex.ndindex Index Types @@ -20,8 +16,6 @@ Index Types The following classes represent different types of indices. -Integer -------- .. autoclass:: ndindex.Integer :members: @@ -29,35 +23,19 @@ Integer .. _slice-api: -Slice ------ - .. autoclass:: ndindex.Slice :members: :special-members: -ellipsis --------- - .. autoclass:: ndindex.ellipsis :members: - -Newaxis -------- - .. autoclass:: ndindex.Newaxis :members: -Tuple ------ - .. autoclass:: ndindex.Tuple :members: -IntegerArray ------------- - .. autoclass:: ndindex.IntegerArray :members: :inherited-members: @@ -66,9 +44,6 @@ IntegerArray .. autoattribute:: dtype :annotation: -BooleanArray ------------- - .. autoclass:: ndindex.BooleanArray :members: :inherited-members: @@ -83,18 +58,19 @@ Index Helpers The functions here are helpers for working with indices that aren't methods of the index objects. -iter_indices ------------- - .. autofunction:: ndindex.iter_indices -BroadcastError --------------- +.. autofunction:: ndindex.broadcast_shapes -.. autoexception:: ndindex.BroadcastError +Exceptions +========== + +These are some custom exceptions that are raised by a few functions in +ndindex. Note that most functions in ndindex will raise `IndexError` +(if the index would be invalid), or `TypeError` or `ValueError` (if the input +types or values are incorrect). -AxisError ---------- +.. autoexception:: ndindex.BroadcastError .. autoexception:: ndindex.AxisError @@ -103,9 +79,6 @@ Chunking ndindex contains objects to represent chunking an array. -ChunkSize ---------- - .. autoclass:: ndindex.ChunkSize :members: @@ -115,21 +88,12 @@ Internal API These classes are only intended for internal use in ndindex. They shouldn't relied on as they may be removed or changed. -ImmutableObject ---------------- - .. autoclass:: ndindex.ndindex.ImmutableObject :members: -NDIndex -------- - .. autoclass:: ndindex.ndindex.NDIndex :members: -ArrayIndex ----------- - .. autoclass:: ndindex.array.ArrayIndex :members: :exclude-members: dtype @@ -137,27 +101,18 @@ ArrayIndex .. autoattribute:: dtype :annotation: Subclasses should redefine this -default -------- - .. autoclass:: ndindex.slice.default -asshape -------- - -.. autofunction:: ndindex.ndindex.asshape +.. autofunction:: ndindex.ndindex.operator_index -operator_index --------------- +.. autofunction:: ndindex.shapetools.asshape -.. autofunction:: ndindex.ndindex.operator_index +.. autofunction:: ndindex.shapetools.ncycles -ncycles -------- +.. autofunction:: ndindex.shapetools.associated_axis -.. autofunction:: ndindex.ndindex.ncycles +.. autofunction:: ndindex.shapetools.remove_indices -broadcast_shapes ----------------- +.. autofunction:: ndindex.shapetools.unremove_indices -.. autofunction:: ndindex.ndindex.broadcast_shapes +.. autofunction:: ndindex.shapetools.normalize_skip_axes diff --git a/docs/changelog.md b/docs/changelog.md index 6537ab43..11f3c2af 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,54 @@ # ndindex Changelog +## Version 1.7 (2023-04-20) + +## Major Changes + +- **Breaking:** the `skip_axes` argument {func}`~.iter_indices` function now + applies the skipped axes *before* broadcasting, not after. This behavior is + more generally useful and matches how functions with stacking work (e.g., + `np.cross` or `np.matmul`). The best way to get the old behavior is to + broadcast the arrays/shapes together first. The `skip_axes` in + `iter_indices` must be either all negative or all nonnegative to avoid + ambiguity. A future version may add support for specifying different skip + axes for each shape. + +- {func}`~.iter_indices` no longer requires the skipped axes specified by + `skip_axes` to be broadcast compatible. + +- New method {meth}`~ndindex.ndindex.NDIndex.isvalid` to check if an index is valid on + a given shape. + +- New function {func}`~.broadcast_shapes` which is the same as + `np.broadcast_shapes()` except it also allows specifying a set of + `skip_axes` which will be ignored when broadcasting. + +- New exceptions {class}`~.BroadcastError` and {class}`~.AxisError` which are + used by {func}`~.iter_indices` and {func}`~.broadcast_shapes`. + +## Minor Changes + +- The documentation theme has been changed to + [Furo](https://pradyunsg.me/furo/), which has a more clean color scheme + based on the ndindex logo, better navigation and layout, mobile support, and + dark mode support. + +- Fix some test failures with the latest version of NumPy. + +- Fix some tests that didn't work properly when run against the sdist. + +- The sdist now includes relevant testing files. + +- Automatically deploy docs from CI again. + +- Add a documentation preview CI job. + +- Test Python 3.11 in CI. + +- Minor improvements to some documentation. + +- Fix a typo in the [type confusion](type-confusion) docs. (@ruancomelli) + ## Version 1.6 (2022-01-24) ### Major Changes @@ -12,7 +61,7 @@ ndindex objects still match NumPy indexing semantics everywhere. Note that NumPy is still a hard requirement for all tests in the ndindex test suite. -- Added a new function {any}`iter_indices` which is a generalization of the +- Added a new function {func}`~.iter_indices` which is a generalization of the `np.ndindex()` function (which is otherwise unrelated) to allow multiple broadcast compatible shapes, and to allow skipping axes. @@ -61,9 +110,9 @@ ### Minor Changes - Added - [CODE_OF_CONDUCT.md](https://github.com/Quansight-Labs/ndindex/blob/master/CODE_OF_CONDUCT.md) + [CODE_OF_CONDUCT.md](https://github.com/Quansight-Labs/ndindex/blob/main/CODE_OF_CONDUCT.md) to the ndindex repository. ndindex follows the [Quansight Code of - Conduct](https://github.com/Quansight/.github/blob/master/CODE_OF_CONDUCT.md). + Conduct](https://github.com/Quansight/.github/blob/main/CODE_OF_CONDUCT.md). - Avoid precomputing all iterated values for slices with large steps in {any}`ChunkSize.as_subchunks()`. @@ -202,7 +251,7 @@ run the ndindex test suite due to the way ndindex tests itself against NumPy. the [type confusion](type-confusion-tuples) between `Tuple((1, 2))` and `Tuple(1, 2)` (only the latter form is correct). -- Document the [`.args`](args) attribute. +- Document the [`.args`](ndindex.ndindex.ImmutableObject.args) attribute. - New internal function {func}`~.operator_index`, which acts like `operator.index()` except it disallows boolean types. A consequence of this diff --git a/docs/conf.py b/docs/conf.py index f194e553..c520ebc1 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -32,6 +32,7 @@ 'sphinx.ext.autodoc', 'sphinx.ext.napoleon', 'sphinx.ext.intersphinx', + 'sphinx_copybutton', ] intersphinx_mapping = { @@ -70,57 +71,105 @@ # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # -html_theme = 'alabaster' - -# Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] +html_theme = 'furo' # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ['_static'] -html_theme_options = { - 'fixed_sidebar': True, - 'github_user': 'Quansight-Labs', - 'github_repo': 'ndindex', - 'github_banner': False, - 'logo': 'ndindex_logo_white_bg.svg', - 'logo_name': False, - # 'show_related': True, - # Needs a release with https://github.com/bitprophet/alabaster/pull/101 first - 'show_relbars': True, - - # Colors - - 'base_bg': '#EEEEEE', - 'narrow_sidebar_bg': '#DDDDDD', - # Sidebar text - 'gray_1': '#000000', - 'narrow_sidebar_link': '#333333', - # Doctest background - 'gray_2': '#F0F8FF', - - # Remove gray background from inline code - 'code_bg': '#EEEEEE', - - # Originally 940px - 'page_width': '1000px', - - # Fonts - 'font_family': "Palatino, 'goudy old style', 'minion pro', 'bell mt', Georgia, 'Hiragino Mincho Pro', serif", - 'font_size': '18px', - 'code_font_family': "'Menlo', 'DejaVu Sans Mono', 'Consolas', 'Bitstream Vera Sans Mono', monospace", - 'code_font_size': '0.85em', - } +# These are defined in _static/custom.css +light_blue = "var(--color-brand-light-blue)" +green = "var(--color-brand-green)" +medium_blue = "var(--color-brand-medium-blue)" +dark_blue = "var(--color-brand-dark-blue)" +dark_bg = "var(--color-brand-dark-bg)" +white = "white" +black = "black" +gray = "#EEEEEE" + +theme_colors_common = { + "color-sidebar-background-border": "var(--color-background-primary)", + "color-sidebar-brand-text": "var(--color-sidebar-link-text--top-level)", + + "color-admonition-title-background--seealso": "#CCCCCC", + "color-admonition-title--seealso": black, + "color-admonition-title-background--note": "#CCCCCC", + "color-admonition-title--note": black, + "color-admonition-title-background--warning": "var(--color-problematic)", + "color-admonition-title--warning": white, + "admonition-font-size": "var(--font-size--normal)", + "admonition-title-font-size": "var(--font-size--normal)", + + "color-link-underline--hover": "var(--color-link)", + + "color-api-keyword": "#000000bd", + "color-api-name": "var(--color-brand-content)", + "color-api-pre-name": "var(--color-brand-content)", + "api-font-size": "var(--font-size--normal)", + + "code-font-size": "var(--font-size--small)", -html_sidebars = { - '**': ['globaltocindex.html', 'searchbox.html'], + } +html_theme_options = { + 'light_logo': 'ndindex_logo_white_bg.svg', + 'dark_logo': 'ndindex_logo_dark_bg.svg', + "light_css_variables": { + **theme_colors_common, + "color-brand-primary": dark_blue, + "color-brand-content": dark_blue, + + "color-sidebar-background": gray, + "color-sidebar-item-background--hover": light_blue, + "color-sidebar-item-expander-background--hover": light_blue, + + }, + "dark_css_variables": { + **theme_colors_common, + "color-brand-primary": light_blue, + "color-brand-content": light_blue, + + "color-api-keyword": "#FFFFFFbd", + "color-api-overall": "#FFFFFF90", + "color-api-paren": "#FFFFFF90", + + "color-background-primary": black, + + "color-sidebar-background": dark_bg, + "color-sidebar-item-background--hover": medium_blue, + "color-sidebar-item-expander-background--hover": medium_blue, + + "color-admonition-title-background--seealso": "#555555", + "color-admonition-title-background--note": "#555555", + "color-problematic": "#B30000", + }, + # See https://pradyunsg.me/furo/customisation/footer/ + "footer_icons": [ + { + "name": "GitHub", + "url": "https://github.com/Quansight-Labs/ndindex", + "html": """ + + + + """, + "class": "", + }, + ], } +# custom.css contains changes that aren't possible with the above because they +# aren't specified in the Furo theme as CSS variables +html_css_files = ['custom.css'] + +sys.path.append(os.path.abspath("./_pygments")) +pygments_style = 'styles.SphinxHighContrastStyle' +pygments_dark_style = 'styles.NativeHighContrastStyle' html_favicon = "logo/favicon.ico" +myst_enable_extensions = ["dollarmath", "linkify"] + mathjax3_config = { 'TeX': { 'equationNumbers': { @@ -133,3 +182,14 @@ # Lets us use single backticks for code default_role = 'code' + +# Add a header for PR preview builds. See the Circle CI configuration. +if os.environ.get("CIRCLECI") == "true": + PR_NUMBER = os.environ.get('CIRCLE_PR_NUMBER') + SHA1 = os.environ.get('CIRCLE_SHA1') + html_theme_options['announcement'] = f"""This is a preview build from +ndindex pull request +#{PR_NUMBER}. It was built against {SHA1[:7]}. +If you aren't looking for a PR preview, go to the main ndindex documentation. """ diff --git a/docs/index.md b/docs/index.md index 828a8c43..16806dab 100644 --- a/docs/index.md +++ b/docs/index.md @@ -277,7 +277,7 @@ There are two primary types of tests that we employ to verify this: - Hypothesis tests. Hypothesis is a library that can intelligently check a combinatorial search space of inputs. This requires writing hypothesis strategies that can generate all the relevant types of indices (see - [ndindex/tests/helpers.py](https://github.com/Quansight-Labs/ndindex/blob/master/ndindex/tests/helpers.py)). + [ndindex/tests/helpers.py](https://github.com/Quansight-Labs/ndindex/blob/main/ndindex/tests/helpers.py)). For more information on hypothesis, see . All tests have hypothesis tests, even if they are also tested exhaustively. @@ -307,7 +307,7 @@ Benchmarks for ndindex are published ## License -[MIT License](https://github.com/Quansight-Labs/ndindex/blob/master/LICENSE) +[MIT License](https://github.com/Quansight-Labs/ndindex/blob/main/LICENSE) (acknowledgments)= ## Acknowledgments @@ -319,15 +319,17 @@ Quansight on numerous open source projects, including Numba, Dask and Project Jupyter.
-https://labs.quansight.org/ -https://www.deshaw.com +https://labs.quansight.org/images/QuansightLabs_logo_V2.png +https://labs.quansight.org/images/QuansightLabs_logo_V2_white.png +https://www.deshaw.com/ +https://www.deshaw.com/
## Table of Contents ```{toctree} +:titlesonly: + api.md slices.md type-confusion.md diff --git a/docs/logo/ndindex_logo_dark_bg.svg b/docs/logo/ndindex_logo_dark_bg.svg deleted file mode 100644 index 46dcd6ab..00000000 --- a/docs/logo/ndindex_logo_dark_bg.svg +++ /dev/null @@ -1,91 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/logo/ndindex_logo_dark_bg.svg b/docs/logo/ndindex_logo_dark_bg.svg new file mode 120000 index 00000000..a6f33953 --- /dev/null +++ b/docs/logo/ndindex_logo_dark_bg.svg @@ -0,0 +1 @@ +../_static/ndindex_logo_dark_bg.svg \ No newline at end of file diff --git a/docs/logo/ndindex_logo_white_bg.svg b/docs/logo/ndindex_logo_white_bg.svg index 3928df16..7a9255c0 120000 --- a/docs/logo/ndindex_logo_white_bg.svg +++ b/docs/logo/ndindex_logo_white_bg.svg @@ -1 +1 @@ -_static/ndindex_logo_white_bg.svg \ No newline at end of file +../_static/ndindex_logo_white_bg.svg \ No newline at end of file diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 00000000..125d74ef --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,5 @@ +furo +linkify-it-py +myst-parser +sphinx +sphinx-copybutton diff --git a/docs/slices.md b/docs/slices.md index b9a1573c..0c76d625 100644 --- a/docs/slices.md +++ b/docs/slices.md @@ -62,9 +62,9 @@ We will use these names throughout this guide. It is worth noting that the `x:y:z` syntax is not valid outside of square brackets. However, slice objects can be created manually using the `slice()` -builtin (`a[x:y:z]` is the same as `a[slice(x, y, z)]`). You can also use the -[`ndindex.Slice()`](slice-api) object if you want to perform more advanced -operations. +builtin (`a[x:y:z]` is the same as `a[slice(x, y, z)]`). If you want to +perform more advanced operations like arithmetic on slices, consider using +the [`ndindex.Slice()`](slice-api) object. (rules)= ## Rules @@ -135,14 +135,14 @@ $$ \begin{aligned} \begin{array}{r c c c c c c c} a = & [\mathtt{\textsf{'}a\textsf{'}}, & \mathtt{\textsf{'}b\textsf{'}}, & \mathtt{\textsf{'}c\textsf{'}}, & \mathtt{\textsf{'}d\textsf{'}}, & \mathtt{\textsf{'}e\textsf{'}}, & \mathtt{\textsf{'}f\textsf{'}}, & \mathtt{\textsf{'}g\textsf{'}}]\\ -\color{red}{\text{index}} - & \color{red}{0\phantom{,}} - & \color{red}{1\phantom{,}} - & \color{red}{2\phantom{,}} - & \color{blue}3{\phantom{,}} - & \color{red}{4\phantom{,}} - & \color{red}{5\phantom{,}} - & \color{red}{6\phantom{,}}\\ +\color{#EE0000}{\text{index}} + & \color{#EE0000}{0\phantom{,}} + & \color{#EE0000}{1\phantom{,}} + & \color{#EE0000}{2\phantom{,}} + & \color{#5E5EFF}3{\phantom{,}} + & \color{#EE0000}{4\phantom{,}} + & \color{#EE0000}{5\phantom{,}} + & \color{#EE0000}{6\phantom{,}}\\ \end{array} \end{aligned} $$ @@ -170,14 +170,14 @@ $$ \begin{aligned} \begin{array}{r c c c c c c c} a = & [\mathtt{\textsf{'}a\textsf{'}}, & \mathtt{\textsf{'}b\textsf{'}}, & \mathtt{\textsf{'}c\textsf{'}}, & \mathtt{\textsf{'}d\textsf{'}}, & \mathtt{\textsf{'}e\textsf{'}}, & \mathtt{\textsf{'}f\textsf{'}}, & \mathtt{\textsf{'}g\textsf{'}}]\\ -\color{red}{\text{index}} - & \color{red}{-7\phantom{,}} - & \color{red}{-6\phantom{,}} - & \color{red}{-5\phantom{,}} - & \color{red}{-4\phantom{,}} - & \color{blue}{-3\phantom{,}} - & \color{red}{-2\phantom{,}} - & \color{red}{-1\phantom{,}}\\ +\color{#EE0000}{\text{index}} + & \color{#EE0000}{-7\phantom{,}} + & \color{#EE0000}{-6\phantom{,}} + & \color{#EE0000}{-5\phantom{,}} + & \color{#EE0000}{-4\phantom{,}} + & \color{#5E5EFF}{-3\phantom{,}} + & \color{#EE0000}{-2\phantom{,}} + & \color{#EE0000}{-1\phantom{,}}\\ \end{array} \end{aligned} $$ @@ -206,7 +206,7 @@ allows one to specify parts of a list that would otherwise need to be specified in terms of the size of the list. If an integer index is greater than or equal to the size of the list, or less -than negative the size of the list (`i >= len(a)` or `i < len(a)`), then it +than negative the size of the list (`i >= len(a)` or `i < -len(a)`), then it is out of bounds and will raise an `IndexError`. ```py @@ -253,14 +253,15 @@ to demystify them through simple [rules](rules). (subarray)= ### Subarray -**A slice always produces a subarray (or sub-list, sub-tuple, sub-string, +> **A slice always produces a subarray (or sub-list, sub-tuple, sub-string, etc.). For NumPy arrays, this means that a slice will always *preserve* the -dimension that is sliced.** This is true even if the slice chooses only a -single element, or even if it chooses no elements. This is also true for -lists, tuples, and strings, in the sense that a slice on a list, tuple, or -string will always produce a list, tuple, or string. This behavior is -different from integer indices, which always remove the dimension that they -index. +dimension that is sliced.** + +This is true even if the slice chooses only a single element, or even if it +chooses no elements. This is also true for lists, tuples, and strings, in the +sense that a slice on a list, tuple, or string will always produce a list, +tuple, or string. This behavior is different from integer indices, which +always remove the dimension that they index. For example @@ -286,7 +287,7 @@ One consequence of this is that, unlike integer indices, **slices will never raise `IndexError`, even if the slice is empty**. Therefore you cannot rely on runtime errors to alert you to coding mistakes relating to slice bounds that are too large. A slice cannot be "out of bounds." See the section on -[clipping](#clipping) below. +[clipping](clipping) below. (0-based)= ### 0-based @@ -304,14 +305,14 @@ $$ \begin{aligned} \begin{array}{r c c c c c c c} a = & [\mathtt{\textsf{'}a\textsf{'}}, & \mathtt{\textsf{'}b\textsf{'}}, & \mathtt{\textsf{'}c\textsf{'}}, & \mathtt{\textsf{'}d\textsf{'}}, & \mathtt{\textsf{'}e\textsf{'}}, & \mathtt{\textsf{'}f\textsf{'}}, & \mathtt{\textsf{'}g\textsf{'}}]\\ -\color{red}{\text{index}} - & \color{red}{0\phantom{,}} - & \color{red}{1\phantom{,}} - & \color{red}{2\phantom{,}} - & \color{blue}{3\phantom{,}} - & \color{blue}{4\phantom{,}} - & \color{red}{5\phantom{,}} - & \color{red}{6\phantom{,}}\\ +\color{#EE0000}{\text{index}} + & \color{#EE0000}{0\phantom{,}} + & \color{#EE0000}{1\phantom{,}} + & \color{#EE0000}{2\phantom{,}} + & \color{#5E5EFF}{3\phantom{,}} + & \color{#5E5EFF}{4\phantom{,}} + & \color{#EE0000}{5\phantom{,}} + & \color{#EE0000}{6\phantom{,}}\\ \end{array} \end{aligned} $$ @@ -346,14 +347,14 @@ $$ \begin{aligned} \begin{array}{r c c c c c c c} a = & [\mathtt{\textsf{'}a\textsf{'}}, & \mathtt{\textsf{'}b\textsf{'}}, & \mathtt{\textsf{'}c\textsf{'}}, & \mathtt{\textsf{'}d\textsf{'}}, & \mathtt{\textsf{'}e\textsf{'}}, & \mathtt{\textsf{'}f\textsf{'}}, & \mathtt{\textsf{'}g\textsf{'}}]\\ -\color{red}{\text{index}} - & \color{red}{0\phantom{,}} - & \color{red}{1\phantom{,}} - & \color{red}{2\phantom{,}} - & \color{blue}{\enclose{circle}{3}} - & \color{blue}{\enclose{circle}{4}} - & \color{red}{\enclose{circle}{5}} - & \color{red}{6\phantom{,}}\\ +\color{#EE0000}{\text{index}} + & \color{#EE0000}{0\phantom{,}} + & \color{#EE0000}{1\phantom{,}} + & \color{#EE0000}{2\phantom{,}} + & \color{#5E5EFF}{\enclose{circle}{3}} + & \color{#5E5EFF}{\enclose{circle}{4}} + & \color{#EE0000}{\enclose{circle}{5}} + & \color{#EE0000}{6\phantom{,}}\\ \end{array} \end{aligned} $$ @@ -409,8 +410,8 @@ advantages: #### Wrong Ways of Thinking about Half-open Semantics -**The proper rule to remember for half-open semantics is "the `stop` is not -included".** +> **The proper rule to remember for half-open semantics is "the `stop` is not + included".** There are several alternative ways that one might think of slice semantics, but they are all wrong in subtle ways. To be sure, for each of these, one @@ -450,20 +451,20 @@ $[3, 5)$ but in reverse order.
a[5:3:-1] "==" ['e', 'd'] -
(WRONG)
+
(WRONG)
$$ \begin{aligned} \begin{array}{r c c c c c c c} a = & [\mathtt{\textsf{'}a\textsf{'}}, & \mathtt{\textsf{'}b\textsf{'}}, & \mathtt{\textsf{'}c\textsf{'}}, & \mathtt{\textsf{'}d\textsf{'}}, & \mathtt{\textsf{'}e\textsf{'}}, & \mathtt{\textsf{'}f\textsf{'}}, & \mathtt{\textsf{'}g\textsf{'}}]\\ -\color{red}{\text{index}} - & \color{red}{0\phantom{,}} - & \color{red}{1\phantom{,}} - & \color{red}{2\phantom{,}} - & \color{blue}{3\phantom{,}} - & \color{blue}{4\phantom{,}} - & \color{red}{5\phantom{,}} - & \color{red}{6\phantom{,}}\\ -\color{red}{\text{WRONG}}& +\color{#EE0000}{\text{index}} + & \color{#EE0000}{0\phantom{,}} + & \color{#EE0000}{1\phantom{,}} + & \color{#EE0000}{2\phantom{,}} + & \color{#5E5EFF}{3\phantom{,}} + & \color{#5E5EFF}{4\phantom{,}} + & \color{#EE0000}{5\phantom{,}} + & \color{#EE0000}{6\phantom{,}}\\ +\color{#EE0000}{\text{WRONG}}& & & & [\phantom{3,} @@ -491,7 +492,7 @@ Actually, what we really get is ``` This is because the slice `5:3:-1` starts at index `5` and steps backwards to -index `3`, but not including `3`. +index `3`, but not including `3` (see [](negative-steps) below).
a[5:3:-1] == ['f', 'e'] @@ -500,14 +501,14 @@ $$ \begin{aligned} \begin{array}{r r r r r r r r} a = & [\mathtt{\textsf{'}a\textsf{'}}, & \mathtt{\textsf{'}b\textsf{'}}, & \mathtt{\textsf{'}c\textsf{'}}, & \mathtt{\textsf{'}d\textsf{'}}, & \mathtt{\textsf{'}e\textsf{'}}, & \mathtt{\textsf{'}f\textsf{'}}, & \mathtt{\textsf{'}g\textsf{'}}]\\ -\color{red}{\text{index}} - & \color{red}{0\phantom{\textsf{'},}} - & \color{red}{1\phantom{\textsf{'},}} - & \color{red}{2\phantom{\textsf{'},}} - & \color{red}{\enclose{circle}{3}\phantom{,}} - & \leftarrow\color{blue}{\enclose{circle}{4}\phantom{,}} - & \leftarrow\color{blue}{\enclose{circle}{5}\phantom{,}} - & \color{red}{6\phantom{\textsf{'},}}\\ +\color{#EE0000}{\text{index}} + & \color{#EE0000}{0\phantom{\textsf{'},}} + & \color{#EE0000}{1\phantom{\textsf{'},}} + & \color{#EE0000}{2\phantom{\textsf{'},}} + & \color{#EE0000}{\enclose{circle}{3}\phantom{,}} + & \leftarrow\color{#5E5EFF}{\enclose{circle}{4}\phantom{,}} + & \leftarrow\color{#5E5EFF}{\enclose{circle}{5}\phantom{,}} + & \color{#EE0000}{6\phantom{\textsf{'},}}\\ \end{array} \end{aligned} $$ @@ -559,38 +560,38 @@ $$ \begin{array}{r r r r r r r r r r r r r r r r r r} a = & [&\phantom{|}&\mathtt{\textsf{'}a\textsf{'}}, &\phantom{|}& \mathtt{\textsf{'}b\textsf{'}}, &\phantom{|}& \mathtt{\textsf{'}c\textsf{'}}, &\phantom{|}& \mathtt{\textsf{'}d\textsf{'}}, &\phantom{|}& \mathtt{\textsf{'}e\textsf{'}}, &\phantom{|}& \mathtt{\textsf{'}f\textsf{'}}, &\phantom{|}& \mathtt{\textsf{'}g\textsf{'}}&\phantom{|}&]&\\ & - & \color{red}{|} + & \color{#EE0000}{|} & - & \color{red}{|} + & \color{#EE0000}{|} & - & \color{red}{|} + & \color{#EE0000}{|} & - & \color{blue}{|} + & \color{#5E5EFF}{|} & - & \color{blue}{|} + & \color{#5E5EFF}{|} & - & \color{blue}{|} + & \color{#5E5EFF}{|} & - & \color{red}{|} + & \color{#EE0000}{|} & - & \color{red}{|}\\ -\color{red}{\text{index}} + & \color{#EE0000}{|}\\ +\color{#EE0000}{\text{index}} & - & \color{red}{0} + & \color{#EE0000}{0} & - & \color{red}{1} + & \color{#EE0000}{1} & - & \color{red}{2} + & \color{#EE0000}{2} & - & \color{blue}{3} + & \color{#5E5EFF}{3} & - & \color{blue}{4} + & \color{#5E5EFF}{4} & - & \color{blue}{5} + & \color{#5E5EFF}{5} & - & \color{red}{6} + & \color{#EE0000}{6} & - & \color{red}{7}\\ + & \color{#EE0000}{7}\\ \end{array}\\ \end{aligned} $$ @@ -615,7 +616,7 @@ reasons why this way of thinking creates more confusion than it removes.
a[5:3:-1] "==" ['e', 'd'] -
(WRONG)
+
(WRONG)
$$ \require{enclose} \begin{aligned} @@ -623,40 +624,40 @@ reasons why this way of thinking creates more confusion than it removes. \begin{array}{r r r r r r r r r r r r r r r r r r} a = & [&\phantom{|}&\mathtt{\textsf{'}a\textsf{'}}, &\phantom{|}& \mathtt{\textsf{'}b\textsf{'}}, &\phantom{|}& \mathtt{\textsf{'}c\textsf{'}}, &\phantom{|}& \mathtt{\textsf{'}d\textsf{'}}, &\phantom{|}& \mathtt{\textsf{'}e\textsf{'}}, &\phantom{|}& \mathtt{\textsf{'}f\textsf{'}}, &\phantom{|}& \mathtt{\textsf{'}g\textsf{'}}&\phantom{|}&]&\\ & - & \color{red}{|} + & \color{#EE0000}{|} & - & \color{red}{|} + & \color{#EE0000}{|} & - & \color{red}{|} + & \color{#EE0000}{|} & - & \color{blue}{|} + & \color{#5E5EFF}{|} & - & \color{blue}{|} + & \color{#5E5EFF}{|} & - & \color{blue}{|} + & \color{#5E5EFF}{|} & - & \color{red}{|} + & \color{#EE0000}{|} & - & \color{red}{|}\\ - \color{red}{\text{index}} + & \color{#EE0000}{|}\\ + \color{#EE0000}{\text{index}} & - & \color{red}{0} + & \color{#EE0000}{0} & - & \color{red}{1} + & \color{#EE0000}{1} & - & \color{red}{2} + & \color{#EE0000}{2} & - & \color{blue}{3} + & \color{#5E5EFF}{3} & - & \color{blue}{4} + & \color{#5E5EFF}{4} & - & \color{blue}{5} + & \color{#5E5EFF}{5} & - & \color{red}{6} + & \color{#EE0000}{6} & - & \color{red}{7}\\ + & \color{#EE0000}{7}\\ \end{array}\\ - \small{\color{red}{\textbf{THIS IS WRONG!}}} + \small{\color{#EE0000}{\textbf{THIS IS WRONG!}}} \end{array} \end{aligned} $$ @@ -690,31 +691,31 @@ reasons why this way of thinking creates more confusion than it removes. \begin{aligned} \begin{array}{r r c c c c c c c c c c c c l} a = & [\mathtt{\textsf{'}a\textsf{'}}, && \mathtt{\textsf{'}b\textsf{'}}, && \mathtt{\textsf{'}c\textsf{'}}, && \mathtt{\textsf{'}d\textsf{'}}, && \mathtt{\textsf{'}e\textsf{'}}, && \mathtt{\textsf{'}f\textsf{'}}, && \mathtt{\textsf{'}g\textsf{'}}]\\ - \color{red}{\text{index}} - & \color{red}{0\phantom{,}} + \color{#EE0000}{\text{index}} + & \color{#EE0000}{0\phantom{,}} & - & \color{red}{1\phantom{,}} + & \color{#EE0000}{1\phantom{,}} & - & \color{red}{2\phantom{,}} + & \color{#EE0000}{2\phantom{,}} & - & \color{red}{\enclose{circle}{3}} + & \color{#EE0000}{\enclose{circle}{3}} & - & \color{blue}{\enclose{circle}{4}} + & \color{#5E5EFF}{\enclose{circle}{4}} & - & \color{blue}{\enclose{circle}{5}} + & \color{#5E5EFF}{\enclose{circle}{5}} & - & \color{red}{6\phantom{,}}\\ + & \color{#EE0000}{6\phantom{,}}\\ & & \phantom{\leftarrow} & & \phantom{\leftarrow} & & \phantom{\leftarrow} - & \color{red}{-1} + & \color{#EE0000}{-1} & \leftarrow - & \color{blue}{-1} + & \color{#5E5EFF}{-1} & \leftarrow - & \color{blue}{\text{start}} + & \color{#5E5EFF}{\text{start}} \end{array} \end{aligned} $$ @@ -733,38 +734,38 @@ reasons why this way of thinking creates more confusion than it removes. \begin{array}{r r r r r r r r r r r r r r r r r r} a = & [&\phantom{|}&\mathtt{\textsf{'}a\textsf{'}}, &\phantom{|}& \mathtt{\textsf{'}b\textsf{'}}, &\phantom{|}& \mathtt{\textsf{'}c\textsf{'}}, &\phantom{|}& \mathtt{\textsf{'}d\textsf{'}}, &\phantom{|}& \mathtt{\textsf{'}e\textsf{'}}, &\phantom{|}& \mathtt{\textsf{'}f\textsf{'}}, &\phantom{|}& \mathtt{\textsf{'}g\textsf{'}}&\phantom{|}&]&\\ & - & \color{red}{|} + & \color{#EE0000}{|} & - & \color{red}{|} + & \color{#EE0000}{|} & - & \color{red}{|} + & \color{#EE0000}{|} & - & \color{blue}{|} + & \color{#5E5EFF}{|} & - & \color{blue}{|} + & \color{#5E5EFF}{|} & - & \color{blue}{|} + & \color{#5E5EFF}{|} & - & \color{red}{|} + & \color{#EE0000}{|} & - & \color{red}{|}\\ - \color{red}{\text{index}} + & \color{#EE0000}{|}\\ + \color{#EE0000}{\text{index}} & - & \color{red}{-7} + & \color{#EE0000}{-7} & - & \color{red}{-6} + & \color{#EE0000}{-6} & - & \color{red}{-5} + & \color{#EE0000}{-5} & - & \color{blue}{-4} + & \color{#5E5EFF}{-4} & - & \color{blue}{-3} + & \color{#5E5EFF}{-3} & - & \color{blue}{-2} + & \color{#5E5EFF}{-2} & - & \color{red}{-1} + & \color{#EE0000}{-1} & - & \color{red}{0}\\ + & \color{#EE0000}{0}\\ \end{array}\\ \small{\text{(not a great way of thinking about negative indices)}} \end{array} @@ -785,7 +786,7 @@ reasons why this way of thinking creates more confusion than it removes.
a[-4:-2] "==" ['e', 'f'] -
(WRONG)
+
(WRONG)
$$ \require{enclose} \begin{aligned} @@ -793,40 +794,40 @@ reasons why this way of thinking creates more confusion than it removes. \begin{array}{r r r r r r r r r r r r r r r r r r} a = & [&\phantom{|}&\mathtt{\textsf{'}a\textsf{'}}, &\phantom{|}& \mathtt{\textsf{'}b\textsf{'}}, &\phantom{|}& \mathtt{\textsf{'}c\textsf{'}}, &\phantom{|}& \mathtt{\textsf{'}d\textsf{'}}, &\phantom{|}& \mathtt{\textsf{'}e\textsf{'}}, &\phantom{|}& \mathtt{\textsf{'}f\textsf{'}}, &\phantom{|}& \mathtt{\textsf{'}g\textsf{'}}&\phantom{|}&]&\\ & - & \color{red}{|} + & \color{#EE0000}{|} & - & \color{red}{|} + & \color{#EE0000}{|} & - & \color{red}{|} + & \color{#EE0000}{|} & - & \color{red}{|} + & \color{#EE0000}{|} & - & \color{blue}{|} + & \color{#5E5EFF}{|} & - & \color{blue}{|} + & \color{#5E5EFF}{|} & - & \color{blue}{|} + & \color{#5E5EFF}{|} & - & \color{red}{|}\\ - \color{red}{\text{index}} + & \color{#EE0000}{|}\\ + \color{#EE0000}{\text{index}} & - & \color{red}{-8} + & \color{#EE0000}{-8} & - & \color{red}{-7} + & \color{#EE0000}{-7} & - & \color{red}{-6} + & \color{#EE0000}{-6} & - & \color{red}{-5} + & \color{#EE0000}{-5} & - & \color{blue}{-4} + & \color{#5E5EFF}{-4} & - & \color{blue}{-3} + & \color{#5E5EFF}{-3} & - & \color{blue}{-2} + & \color{#5E5EFF}{-2} & - & \color{red}{-1}\\ + & \color{#EE0000}{-1}\\ \end{array}\\ - \small{\color{red}{\textbf{THIS IS WRONG!}}} + \small{\color{#EE0000}{\textbf{THIS IS WRONG!}}} \end{array} \end{aligned} $$ @@ -847,7 +848,7 @@ reasons why this way of thinking creates more confusion than it removes.
a[-2:-4:-1] == ['f', 'e'] -
NOW RIGHT!
+
NOW RIGHT!
$$ \require{enclose} \begin{aligned} @@ -855,38 +856,38 @@ reasons why this way of thinking creates more confusion than it removes. \begin{array}{r r r r r r r r r r r r r r r r r r} a = & [&\phantom{|}&\mathtt{\textsf{'}a\textsf{'}}, &\phantom{|}& \mathtt{\textsf{'}b\textsf{'}}, &\phantom{|}& \mathtt{\textsf{'}c\textsf{'}}, &\phantom{|}& \mathtt{\textsf{'}d\textsf{'}}, &\phantom{|}& \mathtt{\textsf{'}e\textsf{'}}, &\phantom{|}& \mathtt{\textsf{'}f\textsf{'}}, &\phantom{|}& \mathtt{\textsf{'}g\textsf{'}}&\phantom{|}&]&\\ & - & \color{red}{|} + & \color{#EE0000}{|} & - & \color{red}{|} + & \color{#EE0000}{|} & - & \color{red}{|} + & \color{#EE0000}{|} & - & \color{red}{|} + & \color{#EE0000}{|} & - & \color{blue}{|} + & \color{#5E5EFF}{|} & - & \color{blue}{|} + & \color{#5E5EFF}{|} & - & \color{blue}{|} + & \color{#5E5EFF}{|} & - & \color{red}{|}\\ - \color{red}{\text{index}} + & \color{#EE0000}{|}\\ + \color{#EE0000}{\text{index}} & - & \color{red}{-8} + & \color{#EE0000}{-8} & - & \color{red}{-7} + & \color{#EE0000}{-7} & - & \color{red}{-6} + & \color{#EE0000}{-6} & - & \color{red}{-5} + & \color{#EE0000}{-5} & - & \color{blue}{-4} + & \color{#5E5EFF}{-4} & - & \color{blue}{-3} + & \color{#5E5EFF}{-3} & - & \color{blue}{-2} + & \color{#5E5EFF}{-2} & - & \color{red}{-1}\\ + & \color{#EE0000}{-1}\\ \end{array}\\ \small{\text{(not a great way of thinking about negative indices)}} \end{array} @@ -896,7 +897,7 @@ reasons why this way of thinking creates more confusion than it removes.
a[-2:-4:-1] "==" ['e', 'd'] -
(WRONG)
+
(WRONG)
$$ \require{enclose} \begin{aligned} @@ -904,40 +905,40 @@ reasons why this way of thinking creates more confusion than it removes. \begin{array}{r r r r r r r r r r r r r r r r r r} a = & [&\phantom{|}&\mathtt{\textsf{'}a\textsf{'}}, &\phantom{|}& \mathtt{\textsf{'}b\textsf{'}}, &\phantom{|}& \mathtt{\textsf{'}c\textsf{'}}, &\phantom{|}& \mathtt{\textsf{'}d\textsf{'}}, &\phantom{|}& \mathtt{\textsf{'}e\textsf{'}}, &\phantom{|}& \mathtt{\textsf{'}f\textsf{'}}, &\phantom{|}& \mathtt{\textsf{'}g\textsf{'}}&\phantom{|}&]&\\ & - & \color{red}{|} + & \color{#EE0000}{|} & - & \color{red}{|} + & \color{#EE0000}{|} & - & \color{red}{|} + & \color{#EE0000}{|} & - & \color{blue}{|} + & \color{#5E5EFF}{|} & - & \color{blue}{|} + & \color{#5E5EFF}{|} & - & \color{blue}{|} + & \color{#5E5EFF}{|} & - & \color{red}{|} + & \color{#EE0000}{|} & - & \color{red}{|}\\ - \color{red}{\text{index}} + & \color{#EE0000}{|}\\ + \color{#EE0000}{\text{index}} & - & \color{red}{-7} + & \color{#EE0000}{-7} & - & \color{red}{-6} + & \color{#EE0000}{-6} & - & \color{red}{-5} + & \color{#EE0000}{-5} & - & \color{blue}{-4} + & \color{#5E5EFF}{-4} & - & \color{blue}{-3} + & \color{#5E5EFF}{-3} & - & \color{blue}{-2} + & \color{#5E5EFF}{-2} & - & \color{red}{-1} + & \color{#EE0000}{-1} & - & \color{red}{0}\\ + & \color{#EE0000}{0}\\ \end{array}\\ - \small{\color{red}{\textbf{THIS IS WRONG!}}} + \small{\color{#EE0000}{\textbf{THIS IS WRONG!}}} \end{array} \end{aligned} $$ @@ -1006,12 +1007,16 @@ here. It isn't worth it. ### Negative Indices Negative indices in slices work the same way they do with [integer -indices](integer-indices). **For `a[start:stop:step]`, negative `start` or -`stop` use −1-based indexing from the end of `a`.** However, negative `start` -or `stop` does *not* change the order of the slicing---only the `step` does -that. The other [rules](rules) of slicing do not change when the `start` or -`stop` is negative. [The `stop` is still not included](half-open), values less -than `-len(a)` still [clip](clipping), and so on. +indices](integer-indices). + +> **For `a[start:stop:step]`, negative `start` or `stop` use −1-based indexing + from the end of `a`.** + +However, negative `start` or `stop` does *not* change the order of the +slicing---only the [`step`](steps) does that. The other [rules](rules) of +slicing do not change when the `start` or `stop` is negative. [The `stop` is +still not included](half-open), values less than `-len(a)` still +[clip](clipping), and so on. Note that positive and negative indices can be mixed. The following slices of `a` all produce `['d', 'e']`: @@ -1023,22 +1028,22 @@ $$ \begin{aligned} \begin{array}{r c c c c c c c} a = & [\mathtt{\textsf{'}a\textsf{'}}, & \mathtt{\textsf{'}b\textsf{'}}, & \mathtt{\textsf{'}c\textsf{'}}, & \mathtt{\textsf{'}d\textsf{'}}, & \mathtt{\textsf{'}e\textsf{'}}, & \mathtt{\textsf{'}f\textsf{'}}, & \mathtt{\textsf{'}g\textsf{'}}]\\ -\color{red}{\text{nonnegative index}} - & \color{red}{0\phantom{,}} - & \color{red}{1\phantom{,}} - & \color{red}{\enclose{circle}{2}\phantom{,}} - & \color{blue}{3\phantom{,}} - & \color{blue}{4\phantom{,}} - & \color{red}{\enclose{circle}{5}\phantom{,}} - & \color{red}{6\phantom{,}}\\ -\color{red}{\text{negative index}} - & \color{red}{-7\phantom{,}} - & \color{red}{-6\phantom{,}} - & \color{red}{\enclose{circle}{-5}\phantom{,}} - & \color{blue}{-4\phantom{,}} - & \color{blue}{-3\phantom{,}} - & \color{red}{\enclose{circle}{-2}\phantom{,}} - & \color{red}{-1\phantom{,}}\\ +\color{#EE0000}{\text{nonnegative index}} + & \color{#EE0000}{0\phantom{,}} + & \color{#EE0000}{1\phantom{,}} + & \color{#EE0000}{\enclose{circle}{2}\phantom{,}} + & \color{#5E5EFF}{3\phantom{,}} + & \color{#5E5EFF}{4\phantom{,}} + & \color{#EE0000}{\enclose{circle}{5}\phantom{,}} + & \color{#EE0000}{6\phantom{,}}\\ +\color{#EE0000}{\text{negative index}} + & \color{#EE0000}{-7\phantom{,}} + & \color{#EE0000}{-6\phantom{,}} + & \color{#EE0000}{\enclose{circle}{-5}\phantom{,}} + & \color{#5E5EFF}{-4\phantom{,}} + & \color{#5E5EFF}{-3\phantom{,}} + & \color{#EE0000}{\enclose{circle}{-2}\phantom{,}} + & \color{#EE0000}{-1\phantom{,}}\\ \end{array} \end{aligned} $$ @@ -1141,7 +1146,8 @@ example, instead of using `mid - n//2`, we could use `max(mid - n//2, 0)`. Slices can never give an out-of-bounds `IndexError`. This is different from [integer indices](integer-indices) which require the index to be in bounds. -**If `start` or `stop` index before the beginning or after the end of the + +> **If `start` or `stop` index before the beginning or after the end of the `a`, they will clip to the bounds of `a`**: ```py @@ -1198,9 +1204,9 @@ Thus far, we have only considered slices with the default step size of 1. When the step is greater than 1, the slice picks every `step` element contained in the bounds of `start` and `stop`. -**The proper way to think about `step` is that the slice starts at `start` and -successively adds `step` until it reaches an index that is at or past the -`stop`, and then stops without including that index.** +> **The proper way to think about `step` is that the slice starts at `start` + and successively adds `step` until it reaches an index that is at or past + the `stop`, and then stops without including that index.** The important thing to remember about the `step` is that it being non-1 does not change the fundamental [rules](rules) of slices that we have learned so @@ -1218,21 +1224,21 @@ $$ \begin{aligned} \begin{array}{r c c c c c c l} a = & [\mathtt{\textsf{'}a\textsf{'}}, & \mathtt{\textsf{'}b\textsf{'}}, & \mathtt{\textsf{'}c\textsf{'}}, & \mathtt{\textsf{'}d\textsf{'}}, & \mathtt{\textsf{'}e\textsf{'}}, & \mathtt{\textsf{'}f\textsf{'}}, & \mathtt{\textsf{'}g\textsf{'}}]\\ -\color{red}{\text{index}} - & \color{blue}{\enclose{circle}{0}} - & \color{red}{1\phantom{,}} - & \color{red}{2\phantom{,}} - & \color{blue}{\enclose{circle}{3}} - & \color{red}{4\phantom{,}} - & \color{red}{5\phantom{,}} - & \color{red}{\enclose{circle}{6}}\\ - & \color{blue}{\text{start}} +\color{#EE0000}{\text{index}} + & \color{#5E5EFF}{\enclose{circle}{0}} + & \color{#EE0000}{1\phantom{,}} + & \color{#EE0000}{2\phantom{,}} + & \color{#5E5EFF}{\enclose{circle}{3}} + & \color{#EE0000}{4\phantom{,}} + & \color{#EE0000}{5\phantom{,}} + & \color{#EE0000}{\enclose{circle}{6}}\\ + & \color{#5E5EFF}{\text{start}} & & \rightarrow - & \color{blue}{+3} + & \color{#5E5EFF}{+3} & & \rightarrow - & \color{red}{+3\ (\geq \text{stop})} + & \color{#EE0000}{+3\ (\geq \text{stop})} \end{array} \end{aligned} $$ @@ -1265,23 +1271,23 @@ $$ \begin{aligned} \begin{array}{r c c c c c c c l} a = & [\mathtt{\textsf{'}a\textsf{'}}, & \mathtt{\textsf{'}b\textsf{'}}, & \mathtt{\textsf{'}c\textsf{'}}, & \mathtt{\textsf{'}d\textsf{'}}, & \mathtt{\textsf{'}e\textsf{'}}, & \mathtt{\textsf{'}f\textsf{'}}, & \mathtt{\textsf{'}g\textsf{'}}]\\ -\color{red}{\text{index}} - & \color{red}{0\phantom{,}} - & \color{blue}{\enclose{circle}{1}} - & \color{red}{2\phantom{,}} - & \color{red}{3\phantom{,}} - & \color{blue}{\enclose{circle}{4}} - & \color{red}{5\phantom{,}} - & \color{red}{\underline{6}\phantom{,}} - & \color{red}{\enclose{circle}{\phantom{7}}}\\ +\color{#EE0000}{\text{index}} + & \color{#EE0000}{0\phantom{,}} + & \color{#5E5EFF}{\enclose{circle}{1}} + & \color{#EE0000}{2\phantom{,}} + & \color{#EE0000}{3\phantom{,}} + & \color{#5E5EFF}{\enclose{circle}{4}} + & \color{#EE0000}{5\phantom{,}} + & \color{#EE0000}{\underline{6}\phantom{,}} + & \color{#EE0000}{\enclose{circle}{\phantom{7}}}\\ & - & \color{blue}{\text{start}} + & \color{#5E5EFF}{\text{start}} & & \rightarrow - & \color{blue}{+3} + & \color{#5E5EFF}{+3} & & \rightarrow - & \color{red}{+3\ (\geq \text{stop})} + & \color{#EE0000}{+3\ (\geq \text{stop})} \end{array} \end{aligned} $$ @@ -1334,9 +1340,9 @@ slices will necessarily have many piecewise conditions. Recall what we said [above](steps): -**The proper way to think about `step` is that the slice starts at `start` and -successively adds `step` until it reaches an index that is at or past the -`stop`, and then stops without including that index.** +> **The proper way to think about `step` is that the slice starts at `start` + and successively adds `step` until it reaches an index that is at or past + the `stop`, and then stops without including that index.** The key thing to remember with negative `step` is that this rule still applies. That is, the index starts at `start` then adds the `step` (which @@ -1415,22 +1421,22 @@ $$ \begin{aligned} \begin{array}{r r c c c c c c l} a = & [\mathtt{\textsf{'}a\textsf{'}}, & \mathtt{\textsf{'}b\textsf{'}}, & \mathtt{\textsf{'}c\textsf{'}}, & \mathtt{\textsf{'}d\textsf{'}}, & \mathtt{\textsf{'}e\textsf{'}}, & \mathtt{\textsf{'}f\textsf{'}}, & \mathtt{\textsf{'}g\textsf{'}}]\\ -\color{red}{\text{index}} - & \color{red}{\enclose{circle}{0}\phantom{,}} - & \color{red}{1\phantom{,}} - & \color{red}{2\phantom{,}} - & \color{blue}{\enclose{circle}{3}} - & \color{red}{4\phantom{,}} - & \color{red}{5\phantom{,}} - & \color{blue}{\enclose{circle}{6}}\\ - & \color{red}{-3}\phantom{\mathtt{\textsf{'},}} +\color{#EE0000}{\text{index}} + & \color{#EE0000}{\enclose{circle}{0}\phantom{,}} + & \color{#EE0000}{1\phantom{,}} + & \color{#EE0000}{2\phantom{,}} + & \color{#5E5EFF}{\enclose{circle}{3}} + & \color{#EE0000}{4\phantom{,}} + & \color{#EE0000}{5\phantom{,}} + & \color{#5E5EFF}{\enclose{circle}{6}}\\ + & \color{#EE0000}{-3}\phantom{\mathtt{\textsf{'},}} & \leftarrow & - & \color{blue}{-3}\phantom{\mathtt{\textsf{'},}} + & \color{#5E5EFF}{-3}\phantom{\mathtt{\textsf{'},}} & \leftarrow & - & \color{blue}{\text{start}}\\ - & \color{red}{(\leq \text{stop})} + & \color{#5E5EFF}{\text{start}}\\ + & \color{#EE0000}{(\leq \text{stop})} \end{array} \end{aligned} $$ @@ -1468,9 +1474,11 @@ trying to remember some rule based on where a colon is. But the colons in a slice are not indicators, they are separators. As to the semantic meaning of omitted entries, the easiest one is the `step`. -**If the `step` is omitted, it always defaults to `1`.** If the `step` is -omitted the second colon before the `step` can also be omitted. That is to -say, the following are completely equivalent: + +> **If the `step` is omitted, it always defaults to `1`.** + +If the `step` is omitted the second colon before the `step` can also be +omitted. That is to say, the following are completely equivalent: ```py a[i:j:1] @@ -1480,8 +1488,11 @@ a[i:j] -**For the `start` and `stop`, the rule is that being omitted extends the slice -all the way to the beginning or end of `a` in the direction being sliced.** If +> **For the `start` and `stop`, the rule is that being omitted extends the + slice all the way to the beginning or end of `a` in the direction being + sliced.** + +If the `step` is positive, this means `start` extends to the beginning of `a` and `stop` extends to the end. If `step` is negative, it is reversed: `start` extends to the end of `a` and `stop` extends to the beginning. @@ -1493,28 +1504,28 @@ $$ \begin{aligned} \begin{array}{r r c c c c c c c c c c c c l} a = & [\mathtt{\textsf{'}a\textsf{'}}, && \mathtt{\textsf{'}b\textsf{'}}, && \mathtt{\textsf{'}c\textsf{'}}, && \mathtt{\textsf{'}d\textsf{'}}, && \mathtt{\textsf{'}e\textsf{'}}, && \mathtt{\textsf{'}f\textsf{'}}, && \mathtt{\textsf{'}g\textsf{'}}]\\ -\color{red}{\text{index}} - & \color{blue}{\enclose{circle}{0}} +\color{#EE0000}{\text{index}} + & \color{#5E5EFF}{\enclose{circle}{0}} & - & \color{blue}{\enclose{circle}{1}} + & \color{#5E5EFF}{\enclose{circle}{1}} & - & \color{blue}{\enclose{circle}{2}} + & \color{#5E5EFF}{\enclose{circle}{2}} & - & \color{red}{\enclose{circle}{3}} + & \color{#EE0000}{\enclose{circle}{3}} & - & \color{red}{4\phantom{,}} + & \color{#EE0000}{4\phantom{,}} & - & \color{red}{5\phantom{,}} + & \color{#EE0000}{5\phantom{,}} & - & \color{red}{6\phantom{,}}\\ - \color{blue}{\text{start}} - & \color{blue}{\text{(beginning)}} + & \color{#EE0000}{6\phantom{,}}\\ + \color{#5E5EFF}{\text{start}} + & \color{#5E5EFF}{\text{(beginning)}} & \rightarrow - & \color{blue}{+1} + & \color{#5E5EFF}{+1} & \rightarrow - & \color{blue}{+1} + & \color{#5E5EFF}{+1} & \rightarrow - & \color{red}{\text{stop}} + & \color{#EE0000}{\text{stop}} & & \phantom{\rightarrow} & @@ -1533,34 +1544,34 @@ $$ \begin{aligned} \begin{array}{r r c c c c c c c c c c c c l} a = & [\mathtt{\textsf{'}a\textsf{'}}, && \mathtt{\textsf{'}b\textsf{'}}, && \mathtt{\textsf{'}c\textsf{'}}, && \mathtt{\textsf{'}d\textsf{'}}, && \mathtt{\textsf{'}e\textsf{'}}, && \mathtt{\textsf{'}f\textsf{'}}, && \mathtt{\textsf{'}g\textsf{'}}]\\ -\color{red}{\text{index}} - & \color{red}{0\phantom{,}} +\color{#EE0000}{\text{index}} + & \color{#EE0000}{0\phantom{,}} & - & \color{red}{1\phantom{,}} + & \color{#EE0000}{1\phantom{,}} & - & \color{red}{2\phantom{,}} + & \color{#EE0000}{2\phantom{,}} & - & \color{blue}{\enclose{circle}{3}} + & \color{#5E5EFF}{\enclose{circle}{3}} & - & \color{blue}{\enclose{circle}{4}} + & \color{#5E5EFF}{\enclose{circle}{4}} & - & \color{blue}{\enclose{circle}{5}} + & \color{#5E5EFF}{\enclose{circle}{5}} & - & \color{blue}{\enclose{circle}{6}\phantom{,}}\\ + & \color{#5E5EFF}{\enclose{circle}{6}\phantom{,}}\\ & & \phantom{\rightarrow} & & \phantom{\rightarrow} & & \phantom{\rightarrow} - & \color{blue}{\text{start}} + & \color{#5E5EFF}{\text{start}} & \rightarrow - & \color{blue}{+1} + & \color{#5E5EFF}{+1} & \rightarrow - & \color{blue}{+1} + & \color{#5E5EFF}{+1} & \rightarrow - & \color{blue}{\text{stop}} - & \color{blue}{\text{(end)}} + & \color{#5E5EFF}{\text{stop}} + & \color{#5E5EFF}{\text{(end)}} \end{array} \end{aligned} $$ @@ -1573,34 +1584,34 @@ $$ \begin{aligned} \begin{array}{r r c c c c c c c c c c c c l} a = & [\mathtt{\textsf{'}a\textsf{'}}, && \mathtt{\textsf{'}b\textsf{'}}, && \mathtt{\textsf{'}c\textsf{'}}, && \mathtt{\textsf{'}d\textsf{'}}, && \mathtt{\textsf{'}e\textsf{'}}, && \mathtt{\textsf{'}f\textsf{'}}, && \mathtt{\textsf{'}g\textsf{'}}]\\ -\color{red}{\text{index}} - & \color{red}{0\phantom{,}} +\color{#EE0000}{\text{index}} + & \color{#EE0000}{0\phantom{,}} & - & \color{red}{1\phantom{,}} + & \color{#EE0000}{1\phantom{,}} & - & \color{red}{2\phantom{,}} + & \color{#EE0000}{2\phantom{,}} & - & \color{red}{\enclose{circle}{3}} + & \color{#EE0000}{\enclose{circle}{3}} & - & \color{blue}{\enclose{circle}{4}} + & \color{#5E5EFF}{\enclose{circle}{4}} & - & \color{blue}{\enclose{circle}{5}} + & \color{#5E5EFF}{\enclose{circle}{5}} & - & \color{blue}{\enclose{circle}{6}\phantom{,}}\\ + & \color{#5E5EFF}{\enclose{circle}{6}\phantom{,}}\\ & & \phantom{\leftarrow} & & \phantom{\leftarrow} & & \phantom{\leftarrow} - & \color{red}{\text{stop}} + & \color{#EE0000}{\text{stop}} & \leftarrow - & \color{blue}{-1} + & \color{#5E5EFF}{-1} & \leftarrow - & \color{blue}{-1} + & \color{#5E5EFF}{-1} & \leftarrow - & \color{blue}{\text{start}} - & \color{blue}{\text{(end)}} + & \color{#5E5EFF}{\text{start}} + & \color{#5E5EFF}{\text{(end)}} \end{array} \end{aligned} $$ @@ -1613,28 +1624,28 @@ $$ \begin{aligned} \begin{array}{r r c c c c c c c c c c c c l} a = & [\mathtt{\textsf{'}a\textsf{'}}, && \mathtt{\textsf{'}b\textsf{'}}, && \mathtt{\textsf{'}c\textsf{'}}, && \mathtt{\textsf{'}d\textsf{'}}, && \mathtt{\textsf{'}e\textsf{'}}, && \mathtt{\textsf{'}f\textsf{'}}, && \mathtt{\textsf{'}g\textsf{'}}]\\ -\color{red}{\text{index}} - & \color{blue}{\enclose{circle}{0}} +\color{#EE0000}{\text{index}} + & \color{#5E5EFF}{\enclose{circle}{0}} & - & \color{blue}{\enclose{circle}{1}} + & \color{#5E5EFF}{\enclose{circle}{1}} & - & \color{blue}{\enclose{circle}{2}} + & \color{#5E5EFF}{\enclose{circle}{2}} & - & \color{blue}{\enclose{circle}{3}} + & \color{#5E5EFF}{\enclose{circle}{3}} & - & \color{red}{4\phantom{,}} + & \color{#EE0000}{4\phantom{,}} & - & \color{red}{5\phantom{,}} + & \color{#EE0000}{5\phantom{,}} & - & \color{red}{6\phantom{,}}\\ - \color{blue}{\text{stop}} - & \color{blue}{\text{(beginning)}} + & \color{#EE0000}{6\phantom{,}}\\ + \color{#5E5EFF}{\text{stop}} + & \color{#5E5EFF}{\text{(beginning)}} & \leftarrow - & \color{blue}{-1} + & \color{#5E5EFF}{-1} & \leftarrow - & \color{blue}{-1} + & \color{#5E5EFF}{-1} & \leftarrow - & \color{blue}{\text{start}} + & \color{#5E5EFF}{\text{start}} & & \phantom{\leftarrow} & @@ -1682,7 +1693,7 @@ hard to write slice arithmetic. The arithmetic is already hard enough due to the modular nature of `step`, but the discontinuous aspect of `start` and `stop` increases this tenfold. If you are unconvinced of this, take a look at the [source -code](https://github.com/Quansight-labs/ndindex/blob/master/ndindex/slice.py) for +code](https://github.com/Quansight-labs/ndindex/blob/main/ndindex/slice.py) for `ndindex.Slice()`. You will see lots of nested `if` blocks.[^source-footnote] This is because slices have *fundamentally* different definitions if the `start` or `stop` are `None`, negative, or nonnegative. Furthermore, `None` is diff --git a/github_deploy_key_quansight_labs_ndindex.enc b/github_deploy_key_quansight_labs_ndindex.enc deleted file mode 100644 index 78d11c5e..00000000 --- a/github_deploy_key_quansight_labs_ndindex.enc +++ /dev/null @@ -1 +0,0 @@ -gAAAAABgeI3n7QXDzdcsx7Px8UM3QsoRMz8gUpAHctdQWzrdxKqBPgAkC_iF9RWdFESBjpxWHRhnldS3iwSqPhvHttDjcg4lZVJOfTROwe0SepRlCCf2IrsJng4aXxDms4UAXc8XtwqaZCXAuEMl017D_C7ce9VoIuIfJqer5cXKU662I-56dtfYWGvpcYgjmTrehSnrlBh0b_ZDOEhqgWauqQo0o23eaglszV9434o2TkKj2w-sESc9Fc92goA5WO8G6Y4EgMAlJNpByHIJ6ryFVN4-NL-ZO1YQ0PN6NVzlN1oNt5djoRXSvd9Yraz5L73myzHNZJVWAlKHMbOHuXcLdSY8crn4uJ7QTeDtoaesDU3qywkTEDC-CV82o5GJ9WNLrrKjPlOjdgUWVxUdpGGLh3nhWad_z2fTIkMerRxirUyHO0jL32tJttmSYLi49kmGJ-2GPLtgynm1hxQ66rGMrha7FseSHVRoUStnwEBECBtzVClkY6PQK_5aywGXywOSIwB9sgD0golfCawZkP4uAfyjBSkEdwRb4_f3chJv7Wh5WFguCFgVp78NilyEEyUSf20_lsWwgEfQRdXO_GJFyJqVTUQpekc2Iv_D2LQA01W7FbrXhg6gfARG_1uiCzacU8YUyrGfqCCAbeR9YgCoDbca6KM63vNyCMW328nGz_NvA8Vz0iKOCf10EsUjbr8lVufVav1125mvNvNCQzpAWpcjJDUw242Ztv0kiKVd0QiUmolDnXnY_2nNsY2VCXpSD6AottoaZkXaVBQXjQm8yW1cuSWoOSdw4HvyK8ibdV4Y8IAVgA8U7l4a_fMfrzawjd3KIkgCOYS_SwXT8o-Vc6D3zoWvUWDTtlPDl5V6PwggAdHvYThmDyScDacyIQ0HeZC93jrmtr71JrZXzo41zzGojQrgUx8Q7xqKIN2tlOEpeQJd9IaSGQvsJ9wslxOqQzrUCiWpm10bdwZB73cQDyNzX1gkVPUPUBco7A6cBIaa6iUwhdkUoE549DicLYNbpXvCPQhfpEzw9AP6APhGGH03z4sy2PbwDsi-Js3EVu78ahE34H_GuMQ94KjMPLD9_x3qQpZQKE8dmKJeakCOlmXUhtunfWzkUnroP1RPbuk7B_WDTsOCKPaMv32tSNQ06J4mGIGRQchPKQvptUHRZMQP6NANqarUUbrvzzKKFowRL3ZDfxjdUaeq0cKyCAq5fOy4IZgchGXvVKDcZ8ij2m0Lxeg5PiKJPm-nyLROJ2O3HWivLT_Id-rfddivP8yaCaB72DUI1MsUT6dlmN6jrDPKN92H3bYCx1FbnoaXBQ3j6BCt6dp2mydBZjh2vdDs2oeaL4Nlk3QQXbCF6dLPwoT058Ws2V1VPVvgVDFxPJe5ZUILf85BN_zVHkL89DGqbBRwsftrwILHaBrJBBSHB8wNjG3meAY0DEPwKki4ya4N1Av3Z_rCdbtAaMmUbuQNayZZBExTfp5qw2eJ5QRo0KGkPKjmiGpnno8u-Poy2Ybn3wFDkIdd7Vk_7xohALRXAG2SSKpQDOsvAC3aheXMzqxpxpKN4_j6YBhF-TGmZfAVHYa11TVsDv5nQ9hKMfnqcuEtPt7fI78kWLebwRWNiAm1kaFoG_Mv8e6DOo2se7aqTtwT7vDwmzir4R25CvyrlqboVER_oapeBi18j0sCMBCJMTn54F05PFb2KnnrRlluxH_sZj4prvIRNGz706rCMewLe2hjtxx7PkxMguXozbSWWpmgnPk5XZIcfT46jIuDMVQH5WNbpJEgkQcBWJxD8CXwdmGF38JTVIAu4x_jkZ8fHBKUHmn1qYQwX1gAkKQcqm0SluRSYkHnPLavf26hqdbjFSL-G2oColkiT2kELB1ZYBzwxeGKKCaBqFMEY0Rmjd7Ob7Vcd7DUCnK-b_h60F6bKnvLIupIhP5rbN0EmWf3SCeUh0t1RbRD_LGfNY3yLS_pz1qLkmS3qAijg6v5upOKpvciCx43U9mew3jn6zvAO_SfKKh4OmfrkKDG3geaV86qzGK3Rg93IEqt_M8jKAIF0boFnLWtrQjTCil3W6-9kk3SWLJtUJoMZ_kggMDTkkqg_oIE1IgYbCBoXvHXoaHvV4q5n7Uso9Ds5ag4b4uv8x9N710OtkNFEzjtvHALGEyYT9xfQ5x47bniYSz4WbJ0O8EwukH66NeXJHQ1KUc2S8A9iV0wJSoeZfVa8IkNt04hxxRNrXfBvp66R_ndPCFRulS56AgF4iYczgE5O2QvdhXZu8fdVErD91Vq0ck8moKCIVkrri1kWpSVx2vfjK0EJtixTHprpKpVBRKJXsKnVpRqaFnDAK9SILVUAHWRM5Jmw1zcyR36ozV2YoqQubDkQ0UGUBXKbbXHzIUndqCw0EMmQGPcY_iZ0WTAt-rlX5nYMy-GlHhab3Vwb9NBbUMhJ6hjSwwQqpSb_rspOKQU8k2C5sr-nKDk5UEsqqG_QNZcOkioN0Wu3zJ95RcirfBOWVuOymW5dW2VUBP4qvA8m5gnPQiEk7_hAoOPEIncSRnqVSg47JboRLIt6LcedOzMeOiF9WY7mKqxwK_XRgxDCC3Jxv4RtDkECY78qePSb1WiS1kKcozxPtK96FZFu3CGzb4lz5iouG0yXXQY0H6Vnu7hVD-U5fL5qv1g_4v4aEluZKI5p2IS5_Nz1Rc9AFTQ_9EFnEhL0bPgXUAoxDhBgkRgkg7zL-4PkxnLTpMwIsP6l8Rb7tcqD5vj-FZd2v3ty4iPdiFPj9tlMw0d79npiaMSCb4F7F1D37OeCaIGvAvrHHl-G7HgcnDgcYlTp9EGdJXYd8iyc59Mip7-3DdQazRukK7g7gNN0ZiGyXfElUR3VPIQKCyZ45y3rLEwt-4oyHLyLSZwI-xUsPMXJ1yRopR9VgZCMXUGkgCJ9DvkFdO7PaKW-8qo3aJA0dACSNnfiuiMrwaRNm8Jnyzun34uOAVwTLuYB3XEd0hrIu6_brwdvU6JKpYFu9pLVwuzBNLmE5ftiiD8yZjn5WFqvlBtX4SUJG9zY-aJeKHwkt8XzllTwGdiN0Zhm5rlhvnWP18m7NMGNJzYnkaWOSuUyZYryYzJaTTcq1a-E-ikEtWxf_PMxwi5QLjhTjSx5SBXElb46OdHfS3gjAmap3qKF6qUK8b0DCRlBExIfNumBRKqO8K_ivNOCwGDk8620dJ33jSaeX4uU38EVXBxPO2znkXtOX0K31u4tq4H7cYMRSqsaiojOrBoD4tOJvmdHV0gmhV1oWcedA7MIpkToRVUM-mjY5-0GFFYWzTVq8H_ZP2gSWT0tEE_2sr_IrTZcQaMq5SGd6aLrLdedv-WLlA1WQ-_aYx5Zeme8jEM9z6hlI5Y__5EqO6jHpKVm4uLRJZCSMobD0zL8EHevkMtJxjgdEmnpHAumKIpRhSYOnPBp_fSZM7mIIclSvRfD6pb2fs9dMVtKZUlPSyoNlDszYbDFR_RlXD47tSd8MOnYLBHo6sG-hBd-vz-yUIDyGGcKGXuDNJ8kk0jdOwmx8BKvQiAqfEw8AjY5TL06XETSheNPKAj7rTdAbrBb70e_9wAx056wZUnwez-N4IIT8xB_8OT3NYu00d3AN4SK2a7NJX1tSYef4ofkYSXOPcb9eF8vyU-3IE1b2ySTMQeedvLiyngPp0E0_RWv_P4p0LHdYhP_d68DbHHYpcmBpTBlc3n0mGS4uCW9-cf3PKQJqkkJ6ubCi_cBX5kX5Qppq2ZlYI_QXn23g4uHQjZc-YGSVXeijFNH1rn06TnHz8tucz9OdTQN1eCtspUjC863K3VN9ChdnfNWSom6knYqN0PfagkgoyxpStDxcadyWZp0mmf2PKc6V5ofj_i6NNXgqoBeWiz5be9xnpm0JOS4ANDhxEIWEIhXmJsTo0EeWxXerABbDH6wSIBPyMPqoyZRNWO9mFAioBnQQYaZa4iHBdusSr1E3XDZli6uUoBkFAU_bidUF0o-ed36r7efx1EzUOq1JMbnItVVHz6ztMiO3A3XoD1up8F6f_BGOK3NQ_spa-khBHxMQFAPgOX9oY5jHziDhMu47sjNB9MYLkzFrhJFsXPES9rtLACrHlAy21IHV5Zqn1f1LAq2abbUTQK6Z0YZFrwWigfeWoYKwB7IcnhlttZ76LgaDwpwfsO0ks1x8_UfzDyCsd-z6B4Gsmo4GtDcw2Xdr_fs8Km61yfAxbohDHerEaMByz382AT36OGkesqEndoW1V2bIbaHuveBmLOssjZ5-cflKZ38dc_Dj_JlCIwXjUJxrNdtxjWZMmW9CQviWXJkI8eW8ngpTztrEl14eH4XtSI5J66bgVbxAzr69AEj81SE6mbb8S3du4uHTWJmtP_RkgC1o4BiYAcP0Uk3aH9F9GYlRKort7YIq7qvy8CSQ-A_whPhFdbg9G4q5cFEI9zifTr3akE6g== \ No newline at end of file diff --git a/ndindex/__init__.py b/ndindex/__init__.py index 8a5e60cf..debe4015 100644 --- a/ndindex/__init__.py +++ b/ndindex/__init__.py @@ -1,8 +1,12 @@ __all__ = [] -from .ndindex import ndindex, iter_indices, AxisError, BroadcastError +from .ndindex import ndindex -__all__ += ['ndindex', 'iter_indices', 'AxisError', 'BroadcastError'] +__all__ += ['ndindex'] + +from .shapetools import broadcast_shapes, iter_indices, AxisError, BroadcastError + +__all__ += ['broadcast_shapes', 'iter_indices', 'AxisError', 'BroadcastError'] from .slice import Slice diff --git a/ndindex/array.py b/ndindex/array.py index 618e5ff4..4eafd990 100644 --- a/ndindex/array.py +++ b/ndindex/array.py @@ -1,6 +1,7 @@ import warnings -from .ndindex import NDIndex, asshape +from .ndindex import NDIndex +from .shapetools import asshape class ArrayIndex(NDIndex): """ @@ -22,6 +23,10 @@ def _typecheck(self, idx, shape=None, _copy=True): from numpy import ndarray, asarray, integer, bool_, empty, intp except ImportError: # pragma: no cover raise ImportError("NumPy must be installed to create array indices") + try: + from numpy import VisibleDeprecationWarning + except ImportError: # pragma: no cover + from numpy.exceptions import VisibleDeprecationWarning if self.dtype is None: raise TypeError("Do not instantiate the superclass ArrayIndex directly") @@ -37,7 +42,10 @@ def _typecheck(self, idx, shape=None, _copy=True): if isinstance(idx, (list, ndarray, bool, integer, int, bool_)): # Ignore deprecation warnings for things like [1, []]. These will be # filtered out anyway since they produce object arrays. - with warnings.catch_warnings(record=True): + with warnings.catch_warnings(): + warnings.filterwarnings('ignore', + category=VisibleDeprecationWarning, + message='Creating an ndarray from ragged nested sequences') a = asarray(idx) if a is idx and _copy: a = a.copy() @@ -159,3 +167,14 @@ def __str__(self): def __hash__(self): return hash(self.array.tobytes()) + + def isvalid(self, shape, _axis=0): + shape = asshape(shape) + try: + # The logic is in _raise_indexerror because the error message uses + # the additional information that is computed when checking if the + # array is valid. + self._raise_indexerror(shape, _axis) + except IndexError: + return False + return True diff --git a/ndindex/booleanarray.py b/ndindex/booleanarray.py index 262b2b5b..a4620a9c 100644 --- a/ndindex/booleanarray.py +++ b/ndindex/booleanarray.py @@ -1,5 +1,5 @@ from .array import ArrayIndex -from .ndindex import asshape +from .shapetools import asshape class BooleanArray(ArrayIndex): """ @@ -102,7 +102,15 @@ def count_nonzero(self): from numpy import count_nonzero return count_nonzero(self.array) - def reduce(self, shape=None, axis=0): + def _raise_indexerror(self, shape, axis=0): + if len(shape) < self.ndim + axis: + raise IndexError(f"too many indices for array: array is {len(shape)}-dimensional, but {self.ndim + axis} were indexed") + + for i in range(axis, axis+self.ndim): + if self.shape[i-axis] != 0 and shape[i] != self.shape[i-axis]: + raise IndexError(f'boolean index did not match indexed array along axis {i}; size of axis is {shape[i]} but size of corresponding boolean axis is {self.shape[i-axis]}') + + def reduce(self, shape=None, *, axis=0, negative_int=False): """ Reduce a `BooleanArray` index on an array of shape `shape`. @@ -116,7 +124,7 @@ def reduce(self, shape=None, axis=0): >>> idx.reduce((3,)) Traceback (most recent call last): ... - IndexError: boolean index did not match indexed array along dimension 0; dimension is 3 but corresponding boolean dimension is 2 + IndexError: boolean index did not match indexed array along axis 0; size of axis is 3 but size of corresponding boolean axis is 2 >>> idx.reduce((2,)) BooleanArray([True, False]) @@ -137,22 +145,14 @@ def reduce(self, shape=None, axis=0): shape = asshape(shape) - if len(shape) < self.ndim + axis: - raise IndexError(f"too many indices for array: array is {len(shape)}-dimensional, but {self.ndim + axis} were indexed") - - for i in range(axis, axis+self.ndim): - if self.shape[i-axis] != 0 and shape[i] != self.shape[i-axis]: - - raise IndexError(f"boolean index did not match indexed array along dimension {i}; dimension is {shape[i]} but corresponding boolean dimension is {self.shape[i-axis]}") - + self._raise_indexerror(shape, axis) return self def newshape(self, shape): # The docstring for this method is on the NDIndex base class shape = asshape(shape) - # reduce will raise IndexError if it should be raised - self.reduce(shape) + self._raise_indexerror(shape) return (self.count_nonzero,) + shape[self.ndim:] def isempty(self, shape=None): diff --git a/ndindex/chunking.py b/ndindex/chunking.py index 019fc47c..77b619cf 100644 --- a/ndindex/chunking.py +++ b/ndindex/chunking.py @@ -1,12 +1,13 @@ from collections.abc import Sequence from itertools import product -from .ndindex import ImmutableObject, operator_index, asshape, ndindex +from .ndindex import ImmutableObject, operator_index, ndindex from .tuple import Tuple from .slice import Slice from .integer import Integer from .integerarray import IntegerArray from .newaxis import Newaxis +from .shapetools import asshape from .subindex_helpers import ceiling from ._crt import prod diff --git a/ndindex/ellipsis.py b/ndindex/ellipsis.py index 6caa1cbc..81cf2291 100644 --- a/ndindex/ellipsis.py +++ b/ndindex/ellipsis.py @@ -1,5 +1,6 @@ -from .ndindex import NDIndex, asshape +from .ndindex import NDIndex from .tuple import Tuple +from .shapetools import asshape class ellipsis(NDIndex): """ @@ -45,7 +46,7 @@ class ellipsis(NDIndex): def _typecheck(self): return () - def reduce(self, shape=None): + def reduce(self, shape=None, *, negative_int=False): """ Reduce an ellipsis index @@ -77,6 +78,10 @@ def reduce(self, shape=None): def raw(self): return ... + def isvalid(self, shape): + shape = asshape(shape) + return True + def newshape(self, shape): # The docstring for this method is on the NDIndex base class shape = asshape(shape) diff --git a/ndindex/integer.py b/ndindex/integer.py index 84ec7a87..0ba7d89f 100644 --- a/ndindex/integer.py +++ b/ndindex/integer.py @@ -1,4 +1,5 @@ -from .ndindex import NDIndex, asshape, operator_index +from .ndindex import NDIndex, operator_index +from .shapetools import AxisError, asshape class Integer(NDIndex): """ @@ -48,13 +49,29 @@ def __len__(self): """ return 1 - def reduce(self, shape=None, axis=0): + def isvalid(self, shape, _axis=0): + # The docstring for this method is on the NDIndex base class + shape = asshape(shape) + if not shape: + return False + size = shape[_axis] + return -size <= self.raw < size + + def _raise_indexerror(self, shape, axis=0): + if not self.isvalid(shape, axis): + size = shape[axis] + raise IndexError(f"index {self.raw} is out of bounds for axis {axis} with size {size}") + + def reduce(self, shape=None, *, axis=0, negative_int=False, axiserror=False): """ Reduce an Integer index on an array of shape `shape`. The result will either be `IndexError` if the index is invalid for the given shape, or an Integer index where the value is nonnegative. + If `negative_int` is `True` and a `shape` is provided, then the result + will be an Integer index where the value is negative. + >>> from ndindex import Integer >>> idx = Integer(-5) >>> idx.reduce((3,)) @@ -79,13 +96,22 @@ def reduce(self, shape=None, axis=0): if shape is None: return self + if axiserror: + if not isinstance(shape, int): # pragma: no cover + raise TypeError("axiserror=True requires shape to be an integer") + if not self.isvalid(shape): + raise AxisError(self.raw, shape) + shape = asshape(shape, axis=axis) - size = shape[axis] - if self.raw >= size or -size > self.raw < 0: - raise IndexError(f"index {self.raw} is out of bounds for axis {axis} with size {size}") - if self.raw < 0: + self._raise_indexerror(shape, axis) + + if self.raw < 0 and not negative_int: + size = shape[axis] return self.__class__(size + self.raw) + elif self.raw >= 0 and negative_int: + size = shape[axis] + return self.__class__(self.raw - size) return self @@ -93,8 +119,7 @@ def newshape(self, shape): # The docstring for this method is on the NDIndex base class shape = asshape(shape) - # reduce will raise IndexError if it should be raised - self.reduce(shape) + self._raise_indexerror(shape) return shape[1:] def as_subindex(self, index): diff --git a/ndindex/integerarray.py b/ndindex/integerarray.py index 13938ed3..67a6094e 100644 --- a/ndindex/integerarray.py +++ b/ndindex/integerarray.py @@ -1,5 +1,5 @@ from .array import ArrayIndex -from .ndindex import asshape +from .shapetools import asshape from .subindex_helpers import subindex_slice class IntegerArray(ArrayIndex): @@ -51,7 +51,13 @@ def dtype(self): from numpy import intp return intp - def reduce(self, shape=None, axis=0): + def _raise_indexerror(self, shape, axis=0): + size = shape[axis] + out_of_bounds = (self.array >= size) | ((-size > self.array) & (self.array < 0)) + if out_of_bounds.any(): + raise IndexError(f"index {self.array[out_of_bounds].flat[0]} is out of bounds for axis {axis} with size {size}") + + def reduce(self, shape=None, *, axis=0, negative_int=False): """ Reduce an `IntegerArray` index on an array of shape `shape`. @@ -60,6 +66,10 @@ def reduce(self, shape=None, axis=0): nonnegative, or, if `self` is a scalar array index (`self.shape == ()`), an `Integer` whose value is nonnegative. + If `negative_int` is `True` and a `shape` is provided, the result will + be an `IntegerArray` with negative entries instead of positive + entries. + >>> from ndindex import IntegerArray >>> idx = IntegerArray([-5, 2]) >>> idx.reduce((3,)) @@ -68,6 +78,8 @@ def reduce(self, shape=None, axis=0): IndexError: index -5 is out of bounds for axis 0 with size 3 >>> idx.reduce((9,)) IntegerArray([4, 2]) + >>> idx.reduce((9,), negative_int=True) + IntegerArray([-5, -7]) See Also ======== @@ -82,28 +94,28 @@ def reduce(self, shape=None, axis=0): """ if self.shape == (): - return Integer(self.array).reduce(shape, axis=axis) + return Integer(self.array).reduce(shape, axis=axis, negative_int=negative_int) if shape is None: return self shape = asshape(shape, axis=axis) + self._raise_indexerror(shape, axis) + size = shape[axis] new_array = self.array.copy() - out_of_bounds = (new_array >= size) | ((-size > new_array) & (new_array < 0)) - if out_of_bounds.any(): - raise IndexError(f"index {new_array[out_of_bounds].flat[0]} is out of bounds for axis {axis} with size {size}") - - new_array[new_array < 0] += size + if negative_int: + new_array[new_array >= 0] -= size + else: + new_array[new_array < 0] += size return IntegerArray(new_array) def newshape(self, shape): # The docstring for this method is on the NDIndex base class shape = asshape(shape) - # reduce will raise IndexError if it should be raised - self.reduce(shape) + self._raise_indexerror(shape) return self.shape + shape[1:] def isempty(self, shape=None): diff --git a/ndindex/ndindex.py b/ndindex/ndindex.py index f9120d19..c9e04e03 100644 --- a/ndindex/ndindex.py +++ b/ndindex/ndindex.py @@ -1,9 +1,6 @@ import sys import inspect -import itertools -import numbers import operator -import functools newaxis = None @@ -258,7 +255,7 @@ def __hash__(self): # as hash(self.args) return hash(self.raw) - def reduce(self, shape=None): + def reduce(self, shape=None, *, negative_int=False): """ Simplify an index given that it will be applied to an array of a given shape. @@ -297,6 +294,46 @@ def reduce(self, shape=None): # XXX: Should the default be raise NotImplementedError or return self? raise NotImplementedError + def isvalid(self, shape): + """ + Check whether a given index is valid on an array of a given shape. + + Returns `True` if an array of shape `shape` can be indexed by `self` + and `False` if it would raise `IndexError`. + + >>> from ndindex import ndindex + >>> ndindex(3).isvalid((4,)) + True + >>> ndindex(3).isvalid((2,)) + False + + Note that some indices can never be valid and will raise a + `IndexError` or `TypeError` if you attempt to construct them. + + >>> ndindex((..., 0, ...)) + Traceback (most recent call last): + ... + IndexError: an index can only have a single ellipsis ('...') + >>> ndindex(slice(True)) + Traceback (most recent call last): + ... + TypeError: 'bool' object cannot be interpreted as an integer + + See Also + ======== + .NDIndex.newshape + + """ + # Every class except for Tuple has a more direct efficient + # implementation. The logic for checking if a Tuple index is valid is + # basically the same as the logic in reduce/expand, so there's no + # point in duplicating it. + try: + self.reduce(shape) + return True + except IndexError: + return False + def expand(self, shape): r""" Expand a Tuple index on an array of shape `shape` @@ -377,8 +414,8 @@ def newshape(self, shape): `shape` should be a tuple of ints, or an int, which is equivalent to a 1-D shape. - Raises `IndexError` if `self` would be out of shape for an array of - shape `shape`. + Raises `IndexError` if `self` would be invalid for an array of shape + `shape`. >>> from ndindex import Slice, Integer, Tuple >>> shape = (6, 7, 8) @@ -393,6 +430,10 @@ def newshape(self, shape): >>> Tuple(0, ..., Slice(1, 3)).newshape(shape) (7, 2) + See Also + ======== + .NDIndex.isvalid + """ raise NotImplementedError @@ -557,307 +598,6 @@ def broadcast_arrays(self): """ return self - -# TODO: Use this in other places in the code that check broadcast compatibility. -class BroadcastError(ValueError): - """ - Exception raised by :func:`iter_indices()` when the input shapes are not - broadcast compatible. - - This is used instead of the NumPy exception of the same name so that - `iter_indices` does not need to depend on NumPy. - """ - -class AxisError(ValueError, IndexError): - """ - Exception raised by :func:`iter_indices()` when the `skip_axes` argument - is out of bounds. - - This is used instead of the NumPy exception of the same name so that - `iter_indices` does not need to depend on NumPy. - - """ - __slots__ = ("axis", "ndim") - - def __init__(self, axis, ndim): - self.axis = axis - self.ndim = ndim - - def __str__(self): - return f"axis {self.axis} is out of bounds for array of dimension {self.ndim}" - -def broadcast_shapes(*shapes): - """ - Broadcast the input shapes `shapes` to a single shape. - - This is the same as :py:func:`np.broadcast_shapes() - `. It is included as a separate helper function - because `np.broadcast_shapes()` is on available in NumPy 1.20 or newer, and - so that ndindex functions that use this function can do without requiring - NumPy to be installed. - - """ - - def _broadcast_shapes(shape1, shape2): - """Broadcasts `shape1` and `shape2`""" - N1 = len(shape1) - N2 = len(shape2) - N = max(N1, N2) - shape = [None for _ in range(N)] - i = N - 1 - while i >= 0: - n1 = N1 - N + i - if N1 - N + i >= 0: - d1 = shape1[n1] - else: - d1 = 1 - n2 = N2 - N + i - if N2 - N + i >= 0: - d2 = shape2[n2] - else: - d2 = 1 - - if d1 == 1: - shape[i] = d2 - elif d2 == 1: - shape[i] = d1 - elif d1 == d2: - shape[i] = d1 - else: - # TODO: Build an error message that matches NumPy - raise BroadcastError("shape mismatch: objects cannot be broadcast to a single shape.") - - i = i - 1 - - return tuple(shape) - - return functools.reduce(_broadcast_shapes, shapes, ()) - -def iter_indices(*shapes, skip_axes=(), _debug=False): - """ - Iterate indices for every element of an arrays of shape `shapes`. - - Each shape in `shapes` should be a shape tuple, which are broadcast - compatible. Each iteration step will produce a tuple of indices, one for - each shape, which would correspond to the same elements if the arrays of - the given shapes were first broadcast together. - - This is a generalization of the NumPy :py:class:`np.ndindex() - ` function (which otherwise has no relation). - `np.ndindex()` only iterates indices for a single shape, whereas - `iter_indices()` supports generating indices for multiple broadcast - compatible shapes at once. This is equivalent to first broadcasting the - arrays then generating indices for the single broadcasted shape. - - Additionally, this function supports the ability to skip axes of the - shapes using `skip_axes`. These axes will be fully sliced in each index. - The remaining axes will be indexed one element at a time with integer - indices. - - `skip_axes` should be a tuple of axes to skip. It can use negative - integers, e.g., `skip_axes=(-1,)` will skip the last axis. The order of - the axes in `skip_axes` does not matter, but it should not contain - duplicate axes. The axes in `skip_axes` refer to the final broadcasted - shape of `shapes`. For example, `iter_indices((3,), (1, 2, 3), - skip_axes=(0,))` will skip the first axis, and only applies to the second - shape, since the first shape corresponds to axis `2` of the final - broadcasted shape `(1, 2, 3)` - - For example, suppose `a` is an array with shape `(3, 2, 4, 4)`, which we - wish to think of as a `(3, 2)` stack of 4 x 4 matrices. We can generate an - iterator for each matrix in the "stack" with `iter_indices((3, 2, 4, 4), - skip_axes=(-1, -2))`: - - >>> from ndindex import iter_indices - >>> for idx in iter_indices((3, 2, 4, 4), skip_axes=(-1, -2)): - ... print(idx) - (Tuple(0, 0, slice(None, None, None), slice(None, None, None)),) - (Tuple(0, 1, slice(None, None, None), slice(None, None, None)),) - (Tuple(1, 0, slice(None, None, None), slice(None, None, None)),) - (Tuple(1, 1, slice(None, None, None), slice(None, None, None)),) - (Tuple(2, 0, slice(None, None, None), slice(None, None, None)),) - (Tuple(2, 1, slice(None, None, None), slice(None, None, None)),) - - Note that the iterates of `iter_indices` are always a tuple, even if only - a single shape is provided (one could instead use `for idx, in - iter_indices(...)` above). - - As another example, say `a` is shape `(1, 3)` and `b` is shape `(2, 1)`, - and we want to generate indices for every value of the broadcasted - operation `a + b`. We can do this by using `a[idx1.raw] + b[idx2.raw]` for every - `idx1` and `idx2` as below: - - >>> import numpy as np - >>> a = np.arange(3).reshape((1, 3)) - >>> b = np.arange(100, 111, 10).reshape((2, 1)) - >>> a - array([[0, 1, 2]]) - >>> b - array([[100], - [110]]) - >>> for idx1, idx2 in iter_indices((1, 3), (2, 1)): # doctest: +SKIP37 - ... print(f"{idx1 = }; {idx2 = }; {(a[idx1.raw], b[idx2.raw]) = }") - idx1 = Tuple(0, 0); idx2 = Tuple(0, 0); (a[idx1.raw], b[idx2.raw]) = (0, 100) - idx1 = Tuple(0, 1); idx2 = Tuple(0, 0); (a[idx1.raw], b[idx2.raw]) = (1, 100) - idx1 = Tuple(0, 2); idx2 = Tuple(0, 0); (a[idx1.raw], b[idx2.raw]) = (2, 100) - idx1 = Tuple(0, 0); idx2 = Tuple(1, 0); (a[idx1.raw], b[idx2.raw]) = (0, 110) - idx1 = Tuple(0, 1); idx2 = Tuple(1, 0); (a[idx1.raw], b[idx2.raw]) = (1, 110) - idx1 = Tuple(0, 2); idx2 = Tuple(1, 0); (a[idx1.raw], b[idx2.raw]) = (2, 110) - >>> a + b - array([[100, 101, 102], - [110, 111, 112]]) - - To include an index into the final broadcasted array, you can simply - include the final broadcasted shape as one of the shapes (the NumPy - function :func:`np.broadcast_shapes() ` is - useful here). - - >>> np.broadcast_shapes((1, 3), (2, 1)) - (2, 3) - >>> for idx1, idx2, broadcasted_idx in iter_indices((1, 3), (2, 1), (2, 3)): - ... print(broadcasted_idx) - Tuple(0, 0) - Tuple(0, 1) - Tuple(0, 2) - Tuple(1, 0) - Tuple(1, 1) - Tuple(1, 2) - - """ - if not shapes: - yield () - return - - shapes = [asshape(shape) for shape in shapes] - ndim = len(max(shapes, key=len)) - - if isinstance(skip_axes, int): - skip_axes = (skip_axes,) - _skip_axes = [] - for a in skip_axes: - try: - a = ndindex(a).reduce(ndim).args[0] - except IndexError: - raise AxisError(a, ndim) - if a in _skip_axes: - raise ValueError("skip_axes should not contain duplicate axes") - _skip_axes.append(a) - - _shapes = [(1,)*(ndim - len(shape)) + shape for shape in shapes] - iters = [[] for i in range(len(shapes))] - broadcasted_shape = broadcast_shapes(*shapes) - - for i in range(-1, -ndim-1, -1): - for it, shape, _shape in zip(iters, shapes, _shapes): - if -i > len(shape): - for j in range(len(it)): - if broadcasted_shape[i] not in [0, 1]: - it[j] = ncycles(it[j], broadcasted_shape[i]) - break - elif ndim + i in _skip_axes: - it.insert(0, [slice(None)]) - else: - if broadcasted_shape[i] != 1 and shape[i] == 1: - it.insert(0, ncycles(range(shape[i]), broadcasted_shape[i])) - else: - it.insert(0, range(shape[i])) - - if _debug: # pragma: no cover - print(iters) - # Use this instead when we drop Python 3.7 support - # print(f"{iters = }") - for idxes in itertools.zip_longest(*[itertools.product(*i) for i in - iters], fillvalue=()): - yield tuple(ndindex(idx) for idx in idxes) - -class ncycles: - """ - Iterate `iterable` repeated `n` times. - - This is based on a recipe from the `Python itertools docs - `_, - but improved to give a repr, and to denest when it can. This makes - debugging :func:`~.iter_indices` easier. - - >>> from ndindex.ndindex import ncycles - >>> ncycles(range(3), 2) - ncycles(range(0, 3), 2) - >>> list(_) - [0, 1, 2, 0, 1, 2] - >>> ncycles(ncycles(range(3), 3), 2) - ncycles(range(0, 3), 6) - - """ - def __new__(cls, iterable, n): - if n == 1: - return iterable - return object.__new__(cls) - - def __init__(self, iterable, n): - if isinstance(iterable, ncycles): - self.iterable = iterable.iterable - self.n = iterable.n*n - else: - self.iterable = iterable - self.n = n - - def __repr__(self): - return f"ncycles({self.iterable!r}, {self.n!r})" - - def __iter__(self): - return itertools.chain.from_iterable(itertools.repeat(tuple(self.iterable), self.n)) - -def asshape(shape, axis=None): - """ - Cast `shape` as a valid NumPy shape. - - The input can be an integer `n`, which is equivalent to `(n,)`, or a tuple - of integers. - - If the `axis` argument is provided, an `IndexError` is raised if it is out - of bounds for the shape. - - The resulting shape is always a tuple of nonnegative integers. - - All ndindex functions that take a shape input should use:: - - shape = asshape(shape) - - or:: - - shape = asshape(shape, axis=axis) - - """ - from .integer import Integer - from .tuple import Tuple - if isinstance(shape, (Tuple, Integer)): - raise TypeError("ndindex types are not meant to be used as a shape - " - "did you mean to use the built-in tuple type?") - - if isinstance(shape, numbers.Number): - shape = (operator_index(shape),) - - try: - l = len(shape) - except TypeError: - raise TypeError("expected sequence object with len >= 0 or a single integer") - - newshape = [] - # numpy uses __getitem__ rather than __iter__ to index into shape, so we - # match that - for i in range(l): - # Raise TypeError if invalid - newshape.append(operator_index(shape[i])) - - if shape[i] < 0: - raise ValueError("unknown (negative) dimensions are not supported") - - if axis is not None: - if len(newshape) <= axis: - raise IndexError(f"too many indices for array: array is {len(shape)}-dimensional, but {axis + 1} were indexed") - - return tuple(newshape) - def operator_index(idx): """ Convert `idx` into an integer index using `__index__()` or raise diff --git a/ndindex/newaxis.py b/ndindex/newaxis.py index 28df499b..120c0ef2 100644 --- a/ndindex/newaxis.py +++ b/ndindex/newaxis.py @@ -1,4 +1,5 @@ -from .ndindex import NDIndex, asshape +from .ndindex import NDIndex +from .shapetools import asshape class Newaxis(NDIndex): """ @@ -42,7 +43,7 @@ def _typecheck(self): def raw(self): return None - def reduce(self, shape=None, axis=0): + def reduce(self, shape=None, *, axis=0, negative_int=False): """ Reduce a `Newaxis` index @@ -69,6 +70,10 @@ def reduce(self, shape=None, axis=0): shape = asshape(shape) return self + def isvalid(self, shape): + shape = asshape(shape) + return True + def newshape(self, shape): # The docstring for this method is on the NDIndex base class shape = asshape(shape) diff --git a/ndindex/shapetools.py b/ndindex/shapetools.py new file mode 100644 index 00000000..567c9d91 --- /dev/null +++ b/ndindex/shapetools.py @@ -0,0 +1,490 @@ +import numbers +import itertools +from collections.abc import Sequence +from ._crt import prod + +from .ndindex import ndindex, operator_index + +class BroadcastError(ValueError): + """ + Exception raised by :func:`iter_indices()` and + :func:`broadcast_shapes()` when the input shapes are not broadcast + compatible. + + """ + __slots__ = ("arg1", "shape1", "arg2", "shape2") + + def __init__(self, arg1, shape1, arg2, shape2): + self.arg1 = arg1 + self.shape1 = shape1 + self.arg2 = arg2 + self.shape2 = shape2 + + def __str__(self): + arg1, shape1, arg2, shape2 = self.args + return f"shape mismatch: objects cannot be broadcast to a single shape. Mismatch is between arg {arg1} with shape {shape1} and arg {arg2} with shape {shape2}." + +class AxisError(ValueError, IndexError): + """ + Exception raised by :func:`iter_indices()` and + :func:`broadcast_shapes()` when the `skip_axes` argument is out-of-bounds. + + This is used instead of the NumPy exception of the same name so that + `iter_indices` does not need to depend on NumPy. + + """ + __slots__ = ("axis", "ndim") + + def __init__(self, axis, ndim): + # NumPy allows axis=-1 for 0-d arrays + if (ndim < 0 or -ndim <= axis < ndim) and not (ndim == 0 and axis == -1): + raise ValueError(f"Invalid AxisError ({axis}, {ndim})") + self.axis = axis + self.ndim = ndim + + def __str__(self): + return f"axis {self.axis} is out of bounds for array of dimension {self.ndim}" + +def broadcast_shapes(*shapes, skip_axes=()): + """ + Broadcast the input shapes `shapes` to a single shape. + + This is the same as :py:func:`np.broadcast_shapes() + `, except is also supports skipping axes in the + shape with `skip_axes`. + + If the shapes are not broadcast compatible (excluding `skip_axes`), + :class:`BroadcastError` is raised. + + >>> from ndindex import broadcast_shapes + >>> broadcast_shapes((2, 3), (3,), (4, 2, 1)) + (4, 2, 3) + >>> broadcast_shapes((2, 3), (5,), (4, 2, 1)) + Traceback (most recent call last): + ... + ndindex.shapetools.BroadcastError: shape mismatch: objects cannot be broadcast to a single shape. Mismatch is between arg 0 with shape (2, 3) and arg 1 with shape (5,). + + Axes in `skip_axes` apply to each shape *before* being broadcasted. Each + shape will be broadcasted together with these axes removed. The dimensions + in skip_axes do not need to be equal or broadcast compatible with one + another. The final broadcasted shape be the result of broadcasting all the + non-skip axes. + + >>> broadcast_shapes((10, 3, 2), (20, 2), skip_axes=(0,)) + (3, 2) + + """ + shapes = [asshape(shape, allow_int=False) for shape in shapes] + skip_axes = normalize_skip_axes(shapes, skip_axes) + + if not shapes: + return () + + non_skip_shapes = [remove_indices(shape, skip_axis) for shape, skip_axis in zip(shapes, skip_axes)] + dims = [len(shape) for shape in non_skip_shapes] + N = max(dims) + + broadcasted_shape = [1]*N + + arg = None + for i in range(-1, -N-1, -1): + for j in range(len(shapes)): + if dims[j] < -i: + continue + shape = non_skip_shapes[j] + broadcasted_side = broadcasted_shape[i] + shape_side = shape[i] + if shape_side == 1: + continue + elif broadcasted_side == 1: + broadcasted_side = shape_side + arg = j + elif shape_side != broadcasted_side: + raise BroadcastError(arg, shapes[arg], j, shapes[j]) + broadcasted_shape[i] = broadcasted_side + + return tuple(broadcasted_shape) + +def iter_indices(*shapes, skip_axes=(), _debug=False): + """ + Iterate indices for every element of an arrays of shape `shapes`. + + Each shape in `shapes` should be a shape tuple, which are broadcast + compatible along the non-skipped axes. Each iteration step will produce a + tuple of indices, one for each shape, which would correspond to the same + elements if the arrays of the given shapes were first broadcast together. + + This is a generalization of the NumPy :py:class:`np.ndindex() + ` function (which otherwise has no relation). + `np.ndindex()` only iterates indices for a single shape, whereas + `iter_indices()` supports generating indices for multiple broadcast + compatible shapes at once. This is equivalent to first broadcasting the + arrays then generating indices for the single broadcasted shape. + + Additionally, this function supports the ability to skip axes of the + shapes using `skip_axes`. These axes will be fully sliced in each index. + The remaining axes will be indexed one element at a time with integer + indices. + + `skip_axes` should be a tuple of axes to skip. It can use negative + integers, e.g., `skip_axes=(-1,)` will skip the last axis (but note that + mixing negative and nonnegative skip axes is currently not supported). The + order of the axes in `skip_axes` does not matter. The axes in `skip_axes` + refer to the shapes *before* broadcasting (if you want to refer to the + axes after broadcasting, either broadcast the shapes and arrays first, or + refer to the axes using negative integers). For example, + `iter_indices((10, 2), (20, 1, 2), skip_axes=(0,))` will skip the size + `10` axis of `(10, 2)` and the size `20` axis of `(20, 1, 2)`. The result + is two sets of indices, one for each element of the non-skipped + dimensions: + + >>> from ndindex import iter_indices + >>> for idx1, idx2 in iter_indices((10, 2), (20, 1, 2), skip_axes=(0,)): + ... print(idx1, idx2) + Tuple(slice(None, None, None), 0) Tuple(slice(None, None, None), 0, 0) + Tuple(slice(None, None, None), 1) Tuple(slice(None, None, None), 0, 1) + + The skipped axes do not themselves need to be broadcast compatible, but + the shapes with all the skipped axes removed should be broadcast + compatible. + + For example, suppose `a` is an array with shape `(3, 2, 4, 4)`, which we + wish to think of as a `(3, 2)` stack of 4 x 4 matrices. We can generate an + iterator for each matrix in the "stack" with `iter_indices((3, 2, 4, 4), + skip_axes=(-1, -2))`: + + >>> for idx in iter_indices((3, 2, 4, 4), skip_axes=(-1, -2)): + ... print(idx) + (Tuple(0, 0, slice(None, None, None), slice(None, None, None)),) + (Tuple(0, 1, slice(None, None, None), slice(None, None, None)),) + (Tuple(1, 0, slice(None, None, None), slice(None, None, None)),) + (Tuple(1, 1, slice(None, None, None), slice(None, None, None)),) + (Tuple(2, 0, slice(None, None, None), slice(None, None, None)),) + (Tuple(2, 1, slice(None, None, None), slice(None, None, None)),) + + .. note:: + + The iterates of `iter_indices` are always a tuple, even if only a + single shape is provided (one could instead use `for idx, in + iter_indices(...)` above). + + As another example, say `a` is shape `(1, 3)` and `b` is shape `(2, 1)`, + and we want to generate indices for every value of the broadcasted + operation `a + b`. We can do this by using `a[idx1.raw] + b[idx2.raw]` for every + `idx1` and `idx2` as below: + + >>> import numpy as np + >>> a = np.arange(3).reshape((1, 3)) + >>> b = np.arange(100, 111, 10).reshape((2, 1)) + >>> a + array([[0, 1, 2]]) + >>> b + array([[100], + [110]]) + >>> for idx1, idx2 in iter_indices((1, 3), (2, 1)): + ... print(f"{idx1 = }; {idx2 = }; {(a[idx1.raw], b[idx2.raw]) = }") # doctest: +SKIPNP1 + idx1 = Tuple(0, 0); idx2 = Tuple(0, 0); (a[idx1.raw], b[idx2.raw]) = (np.int64(0), np.int64(100)) + idx1 = Tuple(0, 1); idx2 = Tuple(0, 0); (a[idx1.raw], b[idx2.raw]) = (np.int64(1), np.int64(100)) + idx1 = Tuple(0, 2); idx2 = Tuple(0, 0); (a[idx1.raw], b[idx2.raw]) = (np.int64(2), np.int64(100)) + idx1 = Tuple(0, 0); idx2 = Tuple(1, 0); (a[idx1.raw], b[idx2.raw]) = (np.int64(0), np.int64(110)) + idx1 = Tuple(0, 1); idx2 = Tuple(1, 0); (a[idx1.raw], b[idx2.raw]) = (np.int64(1), np.int64(110)) + idx1 = Tuple(0, 2); idx2 = Tuple(1, 0); (a[idx1.raw], b[idx2.raw]) = (np.int64(2), np.int64(110)) + >>> a + b + array([[100, 101, 102], + [110, 111, 112]]) + + To include an index into the final broadcasted array, you can simply + include the final broadcasted shape as one of the shapes (the NumPy + function :func:`np.broadcast_shapes() ` is + useful here). + + >>> np.broadcast_shapes((1, 3), (2, 1)) + (2, 3) + >>> for idx1, idx2, broadcasted_idx in iter_indices((1, 3), (2, 1), (2, 3)): + ... print(broadcasted_idx) + Tuple(0, 0) + Tuple(0, 1) + Tuple(0, 2) + Tuple(1, 0) + Tuple(1, 1) + Tuple(1, 2) + + """ + skip_axes = normalize_skip_axes(shapes, skip_axes) + shapes = [asshape(shape, allow_int=False) for shape in shapes] + + if not shapes: + yield () + return + + shapes = [asshape(shape) for shape in shapes] + S = len(shapes) + + iters = [[] for i in range(S)] + broadcasted_shape = broadcast_shapes(*shapes, skip_axes=skip_axes) + + idxes = [-1]*S + + while any(i is not None for i in idxes): + for s, it, shape, sk in zip(range(S), iters, shapes, skip_axes): + i = idxes[s] + if i is None: + continue + if -i > len(shape): + if not shape: + pass + elif len(shape) == len(sk): + # The whole shape is skipped. Just repeat the most recent slice + it[0] = ncycles(it[0], prod(broadcasted_shape)) + else: + # Find the first non-skipped axis and repeat by however + # many implicit axes are left in the broadcasted shape + for j in range(-len(shape), 0): + if j not in sk: + break + it[j] = ncycles(it[j], prod(broadcasted_shape[:len(sk)-len(shape)+len(broadcasted_shape)])) + + idxes[s] = None + continue + + val = associated_axis(broadcasted_shape, i, sk) + if i in sk: + it.insert(0, [slice(None)]) + else: + if val == 0: + return + elif val != 1 and shape[i] == 1: + it.insert(0, ncycles(range(shape[i]), val)) + else: + it.insert(0, range(shape[i])) + idxes[s] -= 1 + + if _debug: # pragma: no cover + print(f"{iters = }") + for idxes in itertools.zip_longest(*[itertools.product(*i) for i in + iters], fillvalue=()): + yield tuple(ndindex(idx) for idx in idxes) + +#### Internal helpers + + +def asshape(shape, axis=None, *, allow_int=True, allow_negative=False): + """ + Cast `shape` as a valid NumPy shape. + + The input can be an integer `n` (if `allow_int=True`), which is equivalent + to `(n,)`, or a tuple of integers. + + If the `axis` argument is provided, an `IndexError` is raised if it is out + of bounds for the shape. + + The resulting shape is always a tuple of nonnegative integers. If + `allow_negative=True`, negative integers are also allowed. + + All ndindex functions that take a shape input should use:: + + shape = asshape(shape) + + or:: + + shape = asshape(shape, axis=axis) + + """ + from .integer import Integer + from .tuple import Tuple + if isinstance(shape, (Tuple, Integer)): + raise TypeError("ndindex types are not meant to be used as a shape - " + "did you mean to use the built-in tuple type?") + + if isinstance(shape, numbers.Number): + if allow_int: + shape = (operator_index(shape),) + else: + raise TypeError(f"expected sequence of integers, not {type(shape).__name__}") + + if not isinstance(shape, Sequence) or isinstance(shape, str): + raise TypeError("expected sequence of integers" + allow_int*" or a single integer" + ", not " + type(shape).__name__) + l = len(shape) + + newshape = [] + # numpy uses __getitem__ rather than __iter__ to index into shape, so we + # match that + for i in range(l): + # Raise TypeError if invalid + val = shape[i] + if val is None: + raise ValueError("unknonwn (None) dimensions are not supported") + + newshape.append(operator_index(shape[i])) + + if not allow_negative and val < 0: + raise ValueError("unknown (negative) dimensions are not supported") + + if axis is not None: + if len(newshape) <= axis: + raise IndexError(f"too many indices for array: array is {len(shape)}-dimensional, but {axis + 1} were indexed") + + return tuple(newshape) + +def associated_axis(broadcasted_shape, i, skip_axes): + """ + Return the associated element of `broadcasted_shape` corresponding to + `shape[i]` given `skip_axes`. If there is not such element (i.e., it's out + of bounds), returns None. + + This function makes implicit assumptions about its input and is only + designed for internal use. + + """ + skip_axes = sorted(skip_axes, reverse=True) + if i >= 0: + raise NotImplementedError + if i in skip_axes: + return None + # We assume skip_axes are all negative and sorted + j = i + for sk in skip_axes: + if sk >= i: + j += 1 + else: + break + if ndindex(j).isvalid(len(broadcasted_shape)): + return broadcasted_shape[j] + return None + +def remove_indices(x, idxes): + """ + Return `x` with the indices `idxes` removed. + + This function is only intended for internal usage. + """ + if isinstance(idxes, int): + idxes = (idxes,) + dim = len(x) + _idxes = sorted({i if i >= 0 else i + dim for i in idxes}) + _idxes = [i - a for i, a in zip(_idxes, range(len(_idxes)))] + _x = list(x) + for i in _idxes: + _x.pop(i) + return tuple(_x) + +def unremove_indices(x, idxes, *, val=None): + """ + Insert `val` in `x` so that it appears at `idxes`. + + Note that idxes must be either all negative or all nonnegative. + + This function is only intended for internal usage. + """ + if any(i >= 0 for i in idxes) and any(i < 0 for i in idxes): + # A mix of positive and negative indices presents a fundamental + # problem: sometimes the result is not unique. For example, x = [0]; + # idxes = [1, -1] could be satisfied by both [0, None] or [0, None, + # None], depending on whether each index refers to a separate None or + # not (note that both cases are supported by remove_indices(), because + # there it is unambiguous). But even worse, in some cases, there may + # be no way to satisfy the given requirement. For example, given x = + # [0, 1, 2, 3]; idxes = [3, -3], there is no way to insert None into x + # so that remove_indices(res, idxes) == x. To see this, simply observe + # that there is no size list x such that remove_indices(x, [3, -3]) + # returns a tuple of size 4: + # + # >>> [len(remove_indices(list(range(n)), [3, -3])) for n in range(4, 10)] + # [2, 3, 5, 5, 6, 7] + raise NotImplementedError("Mixing both negative and nonnegative idxes is not yet supported") + x = list(x) + n = len(idxes) + len(x) + _idxes = sorted({i if i >= 0 else i + n for i in idxes}) + for i in _idxes: + x.insert(i, val) + return tuple(x) + +class ncycles: + """ + Iterate `iterable` repeated `n` times. + + This is based on a recipe from the `Python itertools docs + `_, + but improved to give a repr, and to denest when it can. This makes + debugging :func:`~.iter_indices` easier. + + This is only intended for internal usage. + + >>> from ndindex.shapetools import ncycles + >>> ncycles(range(3), 2) + ncycles(range(0, 3), 2) + >>> list(_) + [0, 1, 2, 0, 1, 2] + >>> ncycles(ncycles(range(3), 3), 2) + ncycles(range(0, 3), 6) + + """ + def __new__(cls, iterable, n): + if n == 1: + return iterable + return object.__new__(cls) + + def __init__(self, iterable, n): + if isinstance(iterable, ncycles): + self.iterable = iterable.iterable + self.n = iterable.n*n + else: + self.iterable = iterable + self.n = n + + def __repr__(self): + return f"ncycles({self.iterable!r}, {self.n!r})" + + def __iter__(self): + return itertools.chain.from_iterable(itertools.repeat(tuple(self.iterable), self.n)) + +def normalize_skip_axes(shapes, skip_axes): + """ + Return a canonical form of `skip_axes` corresponding to `shapes`. + + A canonical form of `skip_axes` is a list of tuples of integers, one for + each shape in `shapes`, which are a unique set of axes for each + corresponding shape. + + If `skip_axes` is an integer, this is basically `[(skip_axes,) for s + in shapes]`. If `skip_axes` is a tuple, it is like `[skip_axes for s in + shapes]`. + + The `skip_axes` must always refer to unique axes in each shape. + + The returned `skip_axes` will always be negative integers and will be + sorted. + + This function is only intended for internal usage. + + """ + # Note: we assume asshape has already been called on the shapes in shapes + if isinstance(skip_axes, Sequence): + if skip_axes and all(isinstance(i, Sequence) for i in skip_axes): + if len(skip_axes) != len(shapes): + raise ValueError(f"Expected {len(shapes)} skip_axes") + return [normalize_skip_axes([shape], skip_axis)[0] for shape, skip_axis in zip(shapes, skip_axes)] + else: + try: + [operator_index(i) for i in skip_axes] + except TypeError: + raise TypeError("skip_axes must be an integer, a tuple of integers, or a list of tuples of integers") + + skip_axes = asshape(skip_axes, allow_negative=True) + + # From here, skip_axes is a single tuple of integers + + if not shapes and skip_axes: + raise ValueError("skip_axes must be empty if there are no shapes") + + new_skip_axes = [] + for shape in shapes: + s = tuple(sorted(ndindex(i).reduce(len(shape), negative_int=True, axiserror=True).raw for i in skip_axes)) + if len(s) != len(set(s)): + err = ValueError(f"skip_axes {skip_axes} are not unique for shape {shape}") + # For testing + err.skip_axes = skip_axes + err.shape = shape + raise err + new_skip_axes.append(s) + return new_skip_axes diff --git a/ndindex/slice.py b/ndindex/slice.py index 00497d89..c73aca43 100644 --- a/ndindex/slice.py +++ b/ndindex/slice.py @@ -1,5 +1,6 @@ -from .ndindex import NDIndex, asshape, operator_index +from .ndindex import NDIndex, operator_index from .subindex_helpers import subindex_slice +from .shapetools import asshape class default: """ @@ -28,8 +29,8 @@ class Slice(NDIndex): `Slice.args` always has three arguments, and does not make any distinction between, for instance, `Slice(x, y)` and `Slice(x, y, None)`. This is - because Python itself does not make the distinction between x:y and x:y: - syntactically. + because Python itself does not make the distinction between `x:y` and + `x:y:` syntactically. See :ref:`slices-docs` for a description of the semantic meaning of slices on arrays. @@ -75,9 +76,11 @@ def _typecheck(self, start, stop=default, step=None): return args def __hash__(self): - # We can't use the default hash(self.raw) because slices are not - # hashable - return hash(self.args) + # Slices are only hashable in Python 3.12+ + try: + return hash(self.raw) + except TypeError: # pragma: no cover + return hash(self.args) @property def raw(self): @@ -199,7 +202,7 @@ def __len__(self): return len(range(start, stop, step)) - def reduce(self, shape=None, axis=0): + def reduce(self, shape=None, *, axis=0, negative_int=False): """ `Slice.reduce` returns a slice that is canonicalized for an array of the given shape, or for any shape if `shape` is `None` (the default). @@ -470,6 +473,13 @@ def reduce(self, shape=None, axis=0): stop = start % -step - 1 return self.__class__(start, stop, step) + def isvalid(self, shape): + # The docstring for this method is on the NDIndex base class + shape = asshape(shape) + + # All slices are valid as long as there is at least one dimension + return bool(shape) + def newshape(self, shape): # The docstring for this method is on the NDIndex base class shape = asshape(shape) diff --git a/ndindex/tests/doctest.py b/ndindex/tests/doctest.py index d6917141..55a0a02e 100644 --- a/ndindex/tests/doctest.py +++ b/ndindex/tests/doctest.py @@ -3,12 +3,6 @@ This runs the doctests but ignores trailing ``` in Markdown documents. -This also adds the flag SKIP37 to allow skipping doctests in Python 3.7. - ->>> import sys ->>> sys.version_info[1] > 7 # doctest: +SKIP37 -True - Running this separately from pytest also allows us to not include the doctests in the coverage. It also allows us to force a separate namespace for each docstring's doctest, which the pytest doctest integration does not allow. @@ -22,17 +16,22 @@ """ +import numpy + import sys import unittest import glob import os from contextlib import contextmanager from doctest import (DocTestRunner, DocFileSuite, DocTestSuite, - NORMALIZE_WHITESPACE, register_optionflag, SKIP) + NORMALIZE_WHITESPACE, register_optionflag) -SKIP37 = register_optionflag("SKIP37") - -PY37 = sys.version_info[:2] == (3, 7) +SKIPNP1 = register_optionflag("SKIPNP1") +NP1 = numpy.__version__.startswith('1') +if NP1: + SKIP_THIS_VERSION = SKIPNP1 +else: + SKIP_THIS_VERSION = 0 @contextmanager def patch_doctest(): @@ -44,13 +43,17 @@ def patch_doctest(): orig_run = DocTestRunner.run def run(self, test, **kwargs): + filtered_examples = [] + for example in test.examples: - if PY37 and SKIP37 in example.options: - example.options[SKIP] = True + if SKIP_THIS_VERSION not in example.options: + filtered_examples.append(example) + # Remove ``` example.want = example.want.replace('```\n', '') example.exc_msg = example.exc_msg and example.exc_msg.replace('```\n', '') + test.examples = filtered_examples return orig_run(self, test, **kwargs) try: diff --git a/ndindex/tests/helpers.py b/ndindex/tests/helpers.py index a14ddd19..1cceaff9 100644 --- a/ndindex/tests/helpers.py +++ b/ndindex/tests/helpers.py @@ -1,7 +1,7 @@ import sys from itertools import chain -from functools import reduce -from operator import mul +import warnings +from functools import wraps from numpy import intp, bool_, array, broadcast_shapes import numpy.testing @@ -11,11 +11,13 @@ from hypothesis import assume, note from hypothesis.strategies import (integers, none, one_of, lists, just, builds, shared, composite, sampled_from, - booleans) + nothing, tuples as hypothesis_tuples) from hypothesis.extra.numpy import (arrays, mutually_broadcastable_shapes as - mbs, BroadcastableShapes) + mbs, BroadcastableShapes, valid_tuple_axes) from ..ndindex import ndindex +from ..shapetools import remove_indices, unremove_indices +from .._crt import prod # Hypothesis strategies for generating indices. Note that some of these # strategies are nominally already defined in hypothesis, but we redefine them @@ -23,10 +25,6 @@ # hypothesis's slices strategy does not generate slices with negative indices. # Similarly, hypothesis.extra.numpy.basic_indices only generates tuples. -# np.prod has overflow and math.prod is Python 3.8+ only -def prod(seq): - return reduce(mul, seq, 1) - nonnegative_ints = integers(0, 10) negative_ints = integers(-10, -1) ints = lambda: one_of(nonnegative_ints, negative_ints) @@ -50,19 +48,62 @@ def tuples(elements, *, min_size=0, max_size=None, unique_by=None, unique=False) # See https://github.com/numpy/numpy/issues/15753 lambda shape: prod([i for i in shape if i]) < MAX_ARRAY_SIZE) -_short_shapes = tuples(integers(0, 10)).filter( +_short_shapes = lambda n: tuples(integers(0, 10), min_size=n).filter( # numpy gives errors with empty arrays with large shapes. # See https://github.com/numpy/numpy/issues/15753 lambda shape: prod([i for i in shape if i]) < SHORT_MAX_ARRAY_SIZE) +# short_shapes should be used in place of shapes in any test function that +# uses ndindices, boolean_arrays, or tuples +short_shapes = shared(_short_shapes(0)) + +_integer_arrays = arrays(intp, short_shapes) +integer_scalars = arrays(intp, ()).map(lambda x: x[()]) +integer_arrays = one_of(integer_scalars, _integer_arrays.flatmap(lambda x: one_of(just(x), just(x.tolist())))) + +# We need to make sure shapes for boolean arrays are generated in a way that +# makes them related to the test array shape. Otherwise, it will be very +# difficult for the boolean array index to match along the test array, which +# means we won't test any behavior other than IndexError. + +@composite +def subsequences(draw, sequence): + seq = draw(sequence) + start = draw(integers(0, max(0, len(seq)-1))) + stop = draw(integers(start, len(seq))) + return seq[start:stop] + +_boolean_arrays = arrays(bool_, one_of(subsequences(short_shapes), short_shapes)) +boolean_scalars = arrays(bool_, ()).map(lambda x: x[()]) +boolean_arrays = one_of(boolean_scalars, _boolean_arrays.flatmap(lambda x: one_of(just(x), just(x.tolist())))) + +def _doesnt_raise(idx): + try: + ndindex(idx) + except (IndexError, ValueError, NotImplementedError): + return False + return True + +Tuples = tuples(one_of(ellipses(), ints(), slices(), newaxes(), + integer_arrays, boolean_arrays)).filter(_doesnt_raise) + +ndindices = one_of( + ints(), + slices(), + ellipses(), + newaxes(), + Tuples, + integer_arrays, + boolean_arrays, +).filter(_doesnt_raise) + # Note: We could use something like this: # mutually_broadcastable_shapes = shared(integers(1, 32).flatmap(lambda i: mbs(num_shapes=i).filter( # lambda broadcastable_shapes: prod([i for i in broadcastable_shapes.result_shape if i]) < MAX_ARRAY_SIZE))) - @composite -def _mutually_broadcastable_shapes(draw): +def _mutually_broadcastable_shapes(draw, *, shapes=short_shapes, min_shapes=0, max_shapes=32, min_side=0): # mutually_broadcastable_shapes() with the default inputs doesn't generate # very interesting examples (see # https://github.com/HypothesisWorks/hypothesis/issues/3170). It's very @@ -77,23 +118,23 @@ def _mutually_broadcastable_shapes(draw): # like. But it generates enough "real" interesting shapes that both of # these workarounds are worth doing (plus I don't know if any other better # way of handling the situation). - base_shape = draw(short_shapes) + base_shape = draw(shapes) input_shapes, result_shape = draw( mbs( - num_shapes=32, + num_shapes=max_shapes, base_shape=base_shape, - min_side=0, + min_side=min_side, )) # The hypothesis mutually_broadcastable_shapes doesn't allow num_shapes to # be a strategy. It's tempting to do something like num_shapes = - # draw(integers(1, 32)), but this shrinks poorly. See + # draw(integers(min_shapes, max_shapes)), but this shrinks poorly. See # https://github.com/HypothesisWorks/hypothesis/issues/3151. So instead of - # using a strategy to draw the number of shapes, we just generate 32 + # using a strategy to draw the number of shapes, we just generate max_shapes # shapes and pick a subset of them. - final_input_shapes = draw(lists(sampled_from(input_shapes), min_size=0, max_size=32, - unique_by=id,)) + final_input_shapes = draw(lists(sampled_from(input_shapes), + min_size=min_shapes, max_size=max_shapes)) # Note: result_shape is input_shapes broadcasted with base_shape, but @@ -106,69 +147,230 @@ def _mutually_broadcastable_shapes(draw): # is already somewhat limited by the mutually_broadcastable_shapes # defaults, and pretty unlikely, but we filter again here just to be safe. if not prod([i for i in final_result_shape if i]) < SHORT_MAX_ARRAY_SIZE: # pragma: no cover - note(f"Filtering {result_shape}") + note(f"Filtering the shape {result_shape} (too many elements)") assume(False) return BroadcastableShapes(final_input_shapes, final_result_shape) mutually_broadcastable_shapes = shared(_mutually_broadcastable_shapes()) -@composite -def skip_axes(draw): - shapes, result_shape = draw(mutually_broadcastable_shapes) - n = len(result_shape) - axes = draw(one_of(none(), - lists(integers(-n, max(0, n-1)), max_size=n))) - if isinstance(axes, list): - axes = tuple(axes) - # Sometimes return an integer - if len(axes) == 1 and draw(booleans()): # pragma: no cover - return axes[0] - return axes +def _fill_shape(draw, + *, + result_shape, + skip_axes, + skip_axes_values): + max_n = max([i + 1 if i >= 0 else -i for i in skip_axes], default=0) + assume(max_n <= len(skip_axes) + len(result_shape)) + dim = draw(integers(min_value=max_n, max_value=len(skip_axes) + len(result_shape))) + new_shape = ['placeholder']*dim + for i in skip_axes: + assume(new_shape[i] is not None) # skip_axes must be unique + new_shape[i] = None + j = -1 + for i in range(-1, -dim - 1, -1): + if new_shape[i] is None: + new_shape[i] = draw(skip_axes_values) + else: + new_shape[i] = draw(sampled_from([result_shape[j], 1])) + j -= 1 + while new_shape and new_shape[0] == 'placeholder': # pragma: no cover + # Can happen if positive and negative skip_axes refer to the same + # entry + new_shape.pop(0) + + # This will happen if the skip axes are too large + assume('placeholder' not in new_shape) + + if prod([i for i in new_shape if i]) >= SHORT_MAX_ARRAY_SIZE: + note(f"Filtering the shape {new_shape} (too many elements)") + assume(False) -# We need to make sure shapes for boolean arrays are generated in a way that -# makes them related to the test array shape. Otherwise, it will be very -# difficult for the boolean array index to match along the test array, which -# means we won't test any behavior other than IndexError. + return tuple(new_shape) -# short_shapes should be used in place of shapes in any test function that -# uses ndindices, boolean_arrays, or tuples -short_shapes = shared(_short_shapes) +skip_axes_with_broadcasted_shape_type = shared(sampled_from([int, tuple, list])) -_integer_arrays = arrays(intp, short_shapes) -integer_scalars = arrays(intp, ()).map(lambda x: x[()]) -integer_arrays = one_of(integer_scalars, _integer_arrays.flatmap(lambda x: one_of(just(x), just(x.tolist())))) +@composite +def _mbs_and_skip_axes( + draw, + shapes=short_shapes, + min_shapes=0, + max_shapes=32, + skip_axes_type_st=skip_axes_with_broadcasted_shape_type, + skip_axes_values=integers(0, 20), + num_skip_axes=None, +): + """ + mutually_broadcastable_shapes except skip_axes() axes might not be + broadcastable + + The result_shape will be None in the position of skip_axes. + """ + skip_axes_type = draw(skip_axes_type_st) + _result_shape = draw(shapes) + if _result_shape == (): + assume(num_skip_axes is None) + + ndim = len(_result_shape) + num_shapes = draw(integers(min_value=min_shapes, max_value=max_shapes)) + if not num_shapes: + assume(num_skip_axes is None) + num_skip_axes = 0 + if not ndim: + return BroadcastableShapes([()]*num_shapes, ()), () + + if num_skip_axes is not None: + min_skip_axes = max_skip_axes = num_skip_axes + else: + min_skip_axes = 0 + max_skip_axes = None + + # int and single tuple cases must be limited to N to ensure that they are + # correct for all shapes + if skip_axes_type == int: + assume(num_skip_axes in [None, 1]) + skip_axes = draw(valid_tuple_axes(ndim, min_size=1, max_size=1))[0] + _skip_axes = [(skip_axes,)]*num_shapes + elif skip_axes_type == tuple: + skip_axes = draw(tuples(integers(-ndim, ndim-1), min_size=min_skip_axes, + max_size=max_skip_axes, unique=True)) + _skip_axes = [skip_axes]*num_shapes + elif skip_axes_type == list: + skip_axes = [] + for i in range(num_shapes): + skip_axes.append(draw(tuples(integers(-ndim, ndim+1), min_size=min_skip_axes, + max_size=max_skip_axes, unique=True))) + _skip_axes = skip_axes + + shapes = [] + for i in range(num_shapes): + shapes.append(_fill_shape(draw, result_shape=_result_shape, skip_axes=_skip_axes[i], + skip_axes_values=skip_axes_values)) + + non_skip_shapes = [remove_indices(shape, sk) for shape, sk in + zip(shapes, _skip_axes)] + # Broadcasting the result _fill_shape may produce a shape different from + # _result_shape because it might not have filled all dimensions, or it + # might have chosen 1 for a dimension every time. Ideally we would just be + # using shapes from mutually_broadcastable_shapes, but I don't know how to + # reverse inject skip axes into shapes in general (see the comment in + # unremove_indices). So for now, we just use the actual broadcast of the + # non-skip shapes. Note that we use np.broadcast_shapes here instead of + # ndindex.broadcast_shapes because test_broadcast_shapes itself uses this + # strategy. + broadcasted_shape = broadcast_shapes(*non_skip_shapes) + + return BroadcastableShapes(shapes, broadcasted_shape), skip_axes + +mbs_and_skip_axes = shared(_mbs_and_skip_axes()) + +mutually_broadcastable_shapes_with_skipped_axes = mbs_and_skip_axes.map( + lambda i: i[0]) +skip_axes_st = mbs_and_skip_axes.map(lambda i: i[1]) @composite -def subsequences(draw, sequence): - seq = draw(sequence) - start = draw(integers(0, max(0, len(seq)-1))) - stop = draw(integers(start, len(seq))) - return seq[start:stop] +def _cross_shapes_and_skip_axes(draw): + (shapes, _broadcasted_shape), skip_axes = draw(_mbs_and_skip_axes( + shapes=_short_shapes(2), + min_shapes=2, + max_shapes=2, + num_skip_axes=1, + # TODO: Test other skip axes types + skip_axes_type_st=just(list), + skip_axes_values=just(3), + )) + + broadcasted_skip_axis = draw(integers(-len(_broadcasted_shape)-1, len(_broadcasted_shape))) + broadcasted_shape = unremove_indices(_broadcasted_shape, + [broadcasted_skip_axis], val=3) + skip_axes.append((broadcasted_skip_axis,)) + + return BroadcastableShapes(shapes, broadcasted_shape), skip_axes + +cross_shapes_and_skip_axes = shared(_cross_shapes_and_skip_axes()) +cross_shapes = cross_shapes_and_skip_axes.map(lambda i: i[0]) +cross_skip_axes = cross_shapes_and_skip_axes.map(lambda i: i[1]) -_boolean_arrays = arrays(bool_, one_of(subsequences(short_shapes), short_shapes)) -boolean_scalars = arrays(bool_, ()).map(lambda x: x[()]) -boolean_arrays = one_of(boolean_scalars, _boolean_arrays.flatmap(lambda x: one_of(just(x), just(x.tolist())))) +@composite +def cross_arrays_st(draw): + broadcastable_shapes = draw(cross_shapes) + shapes, broadcasted_shape = broadcastable_shapes + + # Sanity check + assert len(shapes) == 2 + # We need to generate fairly random arrays. Otherwise, if they are too + # similar to each other, like two arange arrays would be, the cross + # product will be 0. We also disable the fill feature in arrays() for the + # same reason, as it would otherwise generate too many vectors that are + # colinear. + a = draw(arrays(dtype=int, shape=shapes[0], elements=integers(-100, 100), fill=nothing())) + b = draw(arrays(dtype=int, shape=shapes[1], elements=integers(-100, 100), fill=nothing())) + + return a, b + +@composite +def _matmul_shapes_and_skip_axes(draw): + (shapes, _broadcasted_shape), skip_axes = draw(_mbs_and_skip_axes( + shapes=_short_shapes(2), + min_shapes=2, + max_shapes=2, + num_skip_axes=2, + # TODO: Test other skip axes types + skip_axes_type_st=just(list), + skip_axes_values=just(None), + )) + + broadcasted_skip_axes = draw(hypothesis_tuples(*[ + integers(-len(_broadcasted_shape)-1, len(_broadcasted_shape)) + ]*2)) -def _doesnt_raise(idx): try: - ndindex(idx) - except (IndexError, ValueError, NotImplementedError): - return False - return True + broadcasted_shape = unremove_indices(_broadcasted_shape, + broadcasted_skip_axes) + except NotImplementedError: + # TODO: unremove_indices only works with both positive or both negative + assume(False) + # Make sure the indices are unique + assume(len(set(broadcasted_skip_axes)) == len(broadcasted_skip_axes)) + + skip_axes.append(broadcasted_skip_axes) + + # (n, m) @ (m, k) -> (n, k) + n, m, k = draw(hypothesis_tuples(integers(0, 10), integers(0, 10), + integers(0, 10))) + shape1, shape2 = map(list, shapes) + ax1, ax2 = skip_axes[0] + shape1[ax1] = n + shape1[ax2] = m + ax1, ax2 = skip_axes[1] + shape2[ax1] = m + shape2[ax2] = k + broadcasted_shape = list(broadcasted_shape) + ax1, ax2 = skip_axes[2] + broadcasted_shape[ax1] = n + broadcasted_shape[ax2] = k + + shapes = (tuple(shape1), tuple(shape2)) + broadcasted_shape = tuple(broadcasted_shape) + + return BroadcastableShapes(shapes, broadcasted_shape), skip_axes + +matmul_shapes_and_skip_axes = shared(_matmul_shapes_and_skip_axes()) +matmul_shapes = matmul_shapes_and_skip_axes.map(lambda i: i[0]) +matmul_skip_axes = matmul_shapes_and_skip_axes.map(lambda i: i[1]) -Tuples = tuples(one_of(ellipses(), ints(), slices(), newaxes(), - integer_arrays, boolean_arrays)).filter(_doesnt_raise) +@composite +def matmul_arrays_st(draw): + broadcastable_shapes = draw(matmul_shapes) + shapes, broadcasted_shape = broadcastable_shapes -ndindices = one_of( - ints(), - slices(), - ellipses(), - newaxes(), - Tuples, - integer_arrays, - boolean_arrays, -).filter(_doesnt_raise) + # Sanity check + assert len(shapes) == 2 + a = draw(arrays(dtype=int, shape=shapes[0], elements=integers(-100, 100))) + b = draw(arrays(dtype=int, shape=shapes[1], elements=integers(-100, 100))) + + return a, b + +reduce_kwargs = sampled_from([{}, {'negative_int': False}, {'negative_int': True}]) def assert_equal(actual, desired, err_msg='', verbose=True): """ @@ -181,7 +383,16 @@ def assert_equal(actual, desired, err_msg='', verbose=True): assert actual.shape == desired.shape, err_msg or f"{actual.shape} != {desired.shape}" assert actual.dtype == desired.dtype, err_msg or f"{actual.dtype} != {desired.dtype}" -def check_same(a, idx, raw_func=lambda a, idx: a[idx], +def warnings_are_errors(f): + @wraps(f) + def inner(*args, **kwargs): + with warnings.catch_warnings(): + warnings.simplefilter("error") + return f(*args, **kwargs) + return inner + +@warnings_are_errors +def check_same(a, idx, *, raw_func=lambda a, idx: a[idx], ndindex_func=lambda a, index: a[index.raw], same_exception=True, assert_equal=assert_equal): """ @@ -215,8 +426,11 @@ def assert_equal(x, y): try: a_raw = raw_func(a, idx) except Warning as w: - if ("Using a non-tuple sequence for multidimensional indexing is deprecated" in w.args[0]): - idx = array(idx) + # In NumPy < 1.23, this is a FutureWarning. In 1.23 the + # deprecation was removed and lists are always interpreted as + # array indices. + if ("Using a non-tuple sequence for multidimensional indexing is deprecated" in w.args[0]): # pragma: no cover + idx = array(idx, dtype=intp) a_raw = raw_func(a, idx) elif "Out of bound index found. This was previously ignored when the indexing result contained no elements. In the future the index error will be raised. This error occurs either due to an empty slice, or if an array has zero elements even before indexing." in w.args[0]: same_exception = False diff --git a/ndindex/tests/test_as_subindex.py b/ndindex/tests/test_as_subindex.py index a664e0bb..f6a466a4 100644 --- a/ndindex/tests/test_as_subindex.py +++ b/ndindex/tests/test_as_subindex.py @@ -8,7 +8,7 @@ from ..ndindex import ndindex from ..integerarray import IntegerArray from ..tuple import Tuple -from .helpers import ndindices, short_shapes, assert_equal +from .helpers import ndindices, short_shapes, assert_equal, warnings_are_errors @example((slice(0, 8), slice(0, 9), slice(0, 10)), ([2, 5, 6, 7], slice(1, 9, 1), slice(5, 10, 1)), @@ -50,6 +50,7 @@ @example(0, (slice(None, 0, None), Ellipsis), 1) @example(0, (slice(1, 2),), 1) @given(ndindices, ndindices, one_of(integers(0, 100), short_shapes)) +@warnings_are_errors def test_as_subindex_hypothesis(idx1, idx2, shape): if isinstance(shape, int): a = arange(shape) diff --git a/ndindex/tests/test_booleanarray.py b/ndindex/tests/test_booleanarray.py index f18f2e9c..171140b6 100644 --- a/ndindex/tests/test_booleanarray.py +++ b/ndindex/tests/test_booleanarray.py @@ -1,11 +1,13 @@ -from numpy import prod, arange, array, bool_, empty, full +from numpy import prod, arange, array, bool_, empty, full, __version__ as np_version + +NP1 = np_version.startswith('1') from hypothesis import given, example from hypothesis.strategies import one_of, integers from pytest import raises -from .helpers import boolean_arrays, short_shapes, check_same, assert_equal +from .helpers import boolean_arrays, short_shapes, check_same, assert_equal, reduce_kwargs from ..booleanarray import BooleanArray @@ -38,8 +40,8 @@ def test_booleanarray_hypothesis(idx, shape): a = arange(prod(shape)).reshape(shape) check_same(a, idx) -@given(boolean_arrays, one_of(short_shapes, integers(0, 10))) -def test_booleanarray_reduce_no_shape_hypothesis(idx, shape): +@given(boolean_arrays, one_of(short_shapes, integers(0, 10)), reduce_kwargs) +def test_booleanarray_reduce_no_shape_hypothesis(idx, shape, kwargs): if isinstance(shape, int): a = arange(shape) else: @@ -47,12 +49,12 @@ def test_booleanarray_reduce_no_shape_hypothesis(idx, shape): index = BooleanArray(idx) - check_same(a, index.raw, ndindex_func=lambda a, x: a[x.reduce().raw]) + check_same(a, index.raw, ndindex_func=lambda a, x: a[x.reduce(**kwargs).raw]) -@example(full((1, 9), True), (3, 3)) -@example(full((1, 9), False), (3, 3)) -@given(boolean_arrays, one_of(short_shapes, integers(0, 10))) -def test_booleanarray_reduce_hypothesis(idx, shape): +@example(full((1, 9), True), (3, 3), {}) +@example(full((1, 9), False), (3, 3), {}) +@given(boolean_arrays, one_of(short_shapes, integers(0, 10)), reduce_kwargs) +def test_booleanarray_reduce_hypothesis(idx, shape, kwargs): if isinstance(shape, int): a = arange(shape) else: @@ -60,10 +62,12 @@ def test_booleanarray_reduce_hypothesis(idx, shape): index = BooleanArray(idx) - check_same(a, index.raw, ndindex_func=lambda a, x: a[x.reduce(shape).raw]) + same_exception = not NP1 + check_same(a, index.raw, ndindex_func=lambda a, x: a[x.reduce(shape, **kwargs).raw], + same_exception=same_exception) try: - reduced = index.reduce(shape) + reduced = index.reduce(shape, **kwargs) except IndexError: pass else: @@ -72,8 +76,8 @@ def test_booleanarray_reduce_hypothesis(idx, shape): assert reduced == index # Idempotency - assert reduced.reduce() == reduced - assert reduced.reduce(shape) == reduced + assert reduced.reduce(**kwargs) == reduced + assert reduced.reduce(shape, **kwargs) == reduced @given(boolean_arrays, one_of(short_shapes, integers(0, 10))) def test_booleanarray_isempty_hypothesis(idx, shape): diff --git a/ndindex/tests/test_broadcast_arrays.py b/ndindex/tests/test_broadcast_arrays.py index 64858fda..75780a93 100644 --- a/ndindex/tests/test_broadcast_arrays.py +++ b/ndindex/tests/test_broadcast_arrays.py @@ -9,7 +9,7 @@ from ..integerarray import IntegerArray from ..integer import Integer from ..tuple import Tuple -from .helpers import ndindices, check_same, short_shapes +from .helpers import ndindices, check_same, short_shapes, warnings_are_errors @example((..., False, False), 1) @example((True, False), 1) @@ -21,6 +21,7 @@ @example(False, 1) @example([[True, False], [False, False]], (2, 2, 3)) @given(ndindices, one_of(short_shapes, integers(0, 10))) +@warnings_are_errors def test_broadcast_arrays_hypothesis(idx, shape): if isinstance(shape, int): a = arange(shape) diff --git a/ndindex/tests/test_chunking.py b/ndindex/tests/test_chunking.py index 0ccdb959..34a7ff7a 100644 --- a/ndindex/tests/test_chunking.py +++ b/ndindex/tests/test_chunking.py @@ -50,7 +50,7 @@ def test_ChunkSize_args(chunk_size_tuple, idx): try: ndindex(idx) - except ValueError: + except ValueError: # pragma: no cover # Filter out invalid slices (TODO: do this in the strategy) assume(False) diff --git a/ndindex/tests/test_ellipsis.py b/ndindex/tests/test_ellipsis.py index 1b005172..4912cc6f 100644 --- a/ndindex/tests/test_ellipsis.py +++ b/ndindex/tests/test_ellipsis.py @@ -4,7 +4,7 @@ from hypothesis.strategies import one_of, integers from ..ndindex import ndindex -from .helpers import check_same, prod, shapes, ellipses +from .helpers import check_same, prod, shapes, ellipses, reduce_kwargs def test_ellipsis_exhaustive(): for n in range(10): @@ -21,20 +21,20 @@ def test_ellipsis_reduce_exhaustive(): a = arange(n) check_same(a, ..., ndindex_func=lambda a, x: a[x.reduce((n,)).raw]) -@given(ellipses(), shapes) -def test_ellipsis_reduce_hypothesis(idx, shape): +@given(ellipses(), shapes, reduce_kwargs) +def test_ellipsis_reduce_hypothesis(idx, shape, kwargs): a = arange(prod(shape)).reshape(shape) - check_same(a, idx, ndindex_func=lambda a, x: a[x.reduce(shape).raw]) + check_same(a, idx, ndindex_func=lambda a, x: a[x.reduce(shape, **kwargs).raw]) def test_ellipsis_reduce_no_shape_exhaustive(): for n in range(10): a = arange(n) check_same(a, ..., ndindex_func=lambda a, x: a[x.reduce().raw]) -@given(ellipses(), shapes) -def test_ellipsis_reduce_no_shape_hypothesis(idx, shape): +@given(ellipses(), shapes, reduce_kwargs) +def test_ellipsis_reduce_no_shape_hypothesis(idx, shape, kwargs): a = arange(prod(shape)).reshape(shape) - check_same(a, idx, ndindex_func=lambda a, x: a[x.reduce().raw]) + check_same(a, idx, ndindex_func=lambda a, x: a[x.reduce(**kwargs).raw]) @given(ellipses(), one_of(shapes, integers(0, 10))) def test_ellipsis_isempty_hypothesis(idx, shape): diff --git a/ndindex/tests/test_integer.py b/ndindex/tests/test_integer.py index fd354ee4..53c3407a 100644 --- a/ndindex/tests/test_integer.py +++ b/ndindex/tests/test_integer.py @@ -7,7 +7,7 @@ from ..integer import Integer from ..slice import Slice -from .helpers import check_same, ints, prod, shapes, iterslice, assert_equal +from .helpers import check_same, ints, prod, shapes, iterslice, assert_equal, reduce_kwargs def test_integer_args(): zero = Integer(0) @@ -46,51 +46,66 @@ def test_integer_len_hypothesis(i): idx = Integer(i) assert len(idx) == 1 - def test_integer_reduce_exhaustive(): a = arange(10) for i in range(-12, 12): - check_same(a, i, ndindex_func=lambda a, x: a[x.reduce((10,)).raw]) + for kwargs in [{'negative_int': False}, {'negative_int': True}, {}]: + check_same(a, i, ndindex_func=lambda a, x: a[x.reduce((10,), **kwargs).raw]) - try: - reduced = Integer(i).reduce(10) - except IndexError: - pass - else: - assert reduced.raw >= 0 + negative_int = kwargs.get('negative_int', False) + + try: + reduced = Integer(i).reduce(10, **kwargs) + except IndexError: + pass + else: + if negative_int: + assert reduced.raw < 0 + else: + assert reduced.raw >= 0 - # Idempotency - assert reduced.reduce() == reduced - assert reduced.reduce(10) == reduced + # Idempotency + assert reduced.reduce(**kwargs) == reduced + assert reduced.reduce(10, **kwargs) == reduced -@given(ints(), shapes) -def test_integer_reduce_hypothesis(i, shape): +@given(ints(), shapes, reduce_kwargs) +def test_integer_reduce_hypothesis(i, shape, kwargs): a = arange(prod(shape)).reshape(shape) # The axis argument is tested implicitly in the Tuple.reduce test. It is # difficult to test here because we would have to pass in a Tuple to # check_same. - check_same(a, i, ndindex_func=lambda a, x: a[x.reduce(shape).raw]) + check_same(a, i, ndindex_func=lambda a, x: a[x.reduce(shape, **kwargs).raw]) + + negative_int = kwargs.get('negative_int', False) try: - reduced = Integer(i).reduce(shape) + reduced = Integer(i).reduce(shape, **kwargs) except IndexError: pass else: - assert reduced.raw >= 0 + if negative_int: + assert reduced.raw < 0 + else: + assert reduced.raw >= 0 # Idempotency - assert reduced.reduce() == reduced - assert reduced.reduce(shape) == reduced + assert reduced.reduce(**kwargs) == reduced + assert reduced.reduce(shape, **kwargs) == reduced def test_integer_reduce_no_shape_exhaustive(): a = arange(10) for i in range(-12, 12): check_same(a, i, ndindex_func=lambda a, x: a[x.reduce().raw]) -@given(ints(), shapes) -def test_integer_reduce_no_shape_hypothesis(i, shape): +@given(ints(), shapes, reduce_kwargs) +def test_integer_reduce_no_shape_hypothesis(i, shape, kwargs): a = arange(prod(shape)).reshape(shape) - check_same(a, i, ndindex_func=lambda a, x: a[x.reduce().raw]) + check_same(a, i, ndindex_func=lambda a, x: a[x.reduce(**kwargs).raw]) + +@given(ints()) +def test_integer_reduce_no_shape_unchanged(i): + idx = Integer(i) + assert idx.reduce() == idx.reduce(negative_int=False) == idx.reduce(negative_int=True) == i def test_integer_newshape_exhaustive(): shape = 5 diff --git a/ndindex/tests/test_integerarray.py b/ndindex/tests/test_integerarray.py index 615c38af..27ae73cd 100644 --- a/ndindex/tests/test_integerarray.py +++ b/ndindex/tests/test_integerarray.py @@ -5,7 +5,7 @@ from pytest import raises -from .helpers import integer_arrays, short_shapes, check_same, assert_equal +from .helpers import integer_arrays, short_shapes, check_same, assert_equal, reduce_kwargs from ..integer import Integer from ..integerarray import IntegerArray @@ -39,8 +39,8 @@ def test_integerarray_hypothesis(idx, shape): a = arange(prod(shape)).reshape(shape) check_same(a, idx) -@given(integer_arrays, one_of(short_shapes, integers(0, 10))) -def test_integerarray_reduce_no_shape_hypothesis(idx, shape): +@given(integer_arrays, one_of(short_shapes, integers(0, 10)), reduce_kwargs) +def test_integerarray_reduce_no_shape_hypothesis(idx, shape, kwargs): if isinstance(shape, int): a = arange(shape) else: @@ -48,13 +48,23 @@ def test_integerarray_reduce_no_shape_hypothesis(idx, shape): index = IntegerArray(idx) - check_same(a, index.raw, ndindex_func=lambda a, x: a[x.reduce().raw]) + check_same(a, index.raw, ndindex_func=lambda a, x: a[x.reduce(**kwargs).raw]) -@example(array([2, 0]), (1, 0)) -@example(array(0), 1) -@example(array([], dtype=intp), 0) -@given(integer_arrays, one_of(short_shapes, integers(0, 10))) -def test_integerarray_reduce_hypothesis(idx, shape): +@given(integer_arrays) +def test_integerarray_reduce_no_shape_unchanged(idx): + index = IntegerArray(idx) + assert index.reduce() == index.reduce(negative_int=False) == index.reduce(negative_int=True) + if index.ndim != 0: + assert index.reduce() == index + + +@example(array([2, -2]), (4,), {'negative_int': True}) +@example(array(2), (4,), {'negative_int': True}) +@example(array([2, 0]), (1, 0), {}) +@example(array(0), 1, {}) +@example(array([], dtype=intp), 0, {}) +@given(integer_arrays, one_of(short_shapes, integers(0, 10)), reduce_kwargs) +def test_integerarray_reduce_hypothesis(idx, shape, kwargs): if isinstance(shape, int): a = arange(shape) else: @@ -62,22 +72,30 @@ def test_integerarray_reduce_hypothesis(idx, shape): index = IntegerArray(idx) - check_same(a, index.raw, ndindex_func=lambda a, x: a[x.reduce(shape).raw]) + check_same(a, index.raw, ndindex_func=lambda a, x: a[x.reduce(shape, **kwargs).raw]) + + negative_int = kwargs.get('negative_int', False) try: - reduced = index.reduce(shape) + reduced = index.reduce(shape, **kwargs) except IndexError: pass else: if isinstance(reduced, Integer): - assert reduced.raw >= 0 + if negative_int: + assert reduced.raw < 0 + else: + assert reduced.raw >= 0 else: assert isinstance(reduced, IntegerArray) - assert (reduced.raw >= 0).all() + if negative_int: + assert (reduced.raw < 0).all() + else: + assert (reduced.raw >= 0).all() # Idempotency - assert reduced.reduce() == reduced - assert reduced.reduce(shape) == reduced + assert reduced.reduce(**kwargs) == reduced + assert reduced.reduce(shape, **kwargs) == reduced @example([], (1,)) @example([0], (1, 0)) diff --git a/ndindex/tests/test_isvalid.py b/ndindex/tests/test_isvalid.py new file mode 100644 index 00000000..13995b81 --- /dev/null +++ b/ndindex/tests/test_isvalid.py @@ -0,0 +1,43 @@ +from hypothesis import given, example +from hypothesis.strategies import one_of, integers + +from numpy import arange + +from .helpers import ndindices, shapes, MAX_ARRAY_SIZE, check_same, prod + +@example([0], (1,)) +@example(..., (1, 2, 3)) +@example(slice(0, 1), ()) +@example(slice(0, 1), (1,)) +@example((0, 1), (2, 2)) +@example((0,), ()) +@example([[1]], (0, 0, 1)) +@example(None, ()) +@given(ndindices, one_of(shapes, integers(0, MAX_ARRAY_SIZE))) +def test_isvalid_hypothesis(idx, shape): + if isinstance(shape, int): + a = arange(shape) + else: + a = arange(prod(shape)).reshape(shape) + + def raw_func(a, idx): + try: + a[idx] + return True + except Warning as w: + # check_same unconditionally turns this warning into raise + # IndexError, so we have to handle it separately here. + if "Out of bound index found. This was previously ignored when the indexing result contained no elements. In the future the index error will be raised. This error occurs either due to an empty slice, or if an array has zero elements even before indexing." in w.args[0]: + return False + raise # pragma: no cover + except IndexError: + return False + + def ndindex_func(a, index): + return index.isvalid(a.shape) + + def assert_equal(x, y): + assert x == y + + check_same(a, idx, raw_func=raw_func, ndindex_func=ndindex_func, + assert_equal=assert_equal) diff --git a/ndindex/tests/test_ndindex.py b/ndindex/tests/test_ndindex.py index 0f90b8e1..8b537a99 100644 --- a/ndindex/tests/test_ndindex.py +++ b/ndindex/tests/test_ndindex.py @@ -1,21 +1,21 @@ import inspect +import warnings import numpy as np from hypothesis import given, example, settings -from hypothesis.strategies import integers -from pytest import raises, warns +from pytest import raises -from ..ndindex import ndindex, asshape, iter_indices, ncycles, BroadcastError, AxisError +from ..ndindex import ndindex from ..booleanarray import BooleanArray from ..integer import Integer from ..ellipsis import ellipsis from ..integerarray import IntegerArray -from ..tuple import Tuple -from .helpers import (ndindices, check_same, assert_equal, prod, - mutually_broadcastable_shapes, skip_axes) +from .helpers import ndindices, check_same, assert_equal + +@example([1, 2]) @given(ndindices) def test_eq(idx): index = ndindex(idx) @@ -101,10 +101,15 @@ def test_ndindex_invalid(): np.array([])]: check_same(a, idx) - # This index is allowed by NumPy, but gives a deprecation warnings. We are - # not going to allow indices that give deprecation warnings in ndindex. - with warns(None) as r: # Make sure no warnings are emitted from ndindex() - raises(IndexError, lambda: ndindex([1, []])) + # Older versions of NumPy gives a deprecation warning for this index. We + # are not going to allow indices that give deprecation warnings in + # ndindex. + with warnings.catch_warnings(record=True) as r: + # Make sure no warnings are emitted from ndindex() + warnings.simplefilter("error") + # Newer numpy versions raise ValueError with this index (although + # perhaps they shouldn't) + raises((IndexError, ValueError), lambda: ndindex([1, []])) assert not r def test_ndindex_ellipsis(): @@ -132,179 +137,3 @@ def test_repr_str(idx): # Str may not be re-creatable. Just test that it doesn't give an exception. str(index) - -def test_asshape(): - assert asshape(1) == (1,) - assert asshape(np.int64(2)) == (2,) - assert type(asshape(np.int64(2))[0]) == int - assert asshape((1, 2)) == (1, 2) - assert asshape([1, 2]) == (1, 2) - assert asshape((np.int64(1), np.int64(2))) == (1, 2) - assert type(asshape((np.int64(1), np.int64(2)))[0]) == int - assert type(asshape((np.int64(1), np.int64(2)))[1]) == int - - raises(TypeError, lambda: asshape(1.0)) - raises(TypeError, lambda: asshape((1.0,))) - raises(ValueError, lambda: asshape(-1)) - raises(ValueError, lambda: asshape((1, -1))) - raises(TypeError, lambda: asshape(...)) - raises(TypeError, lambda: asshape(Integer(1))) - raises(TypeError, lambda: asshape(Tuple(1, 2))) - raises(TypeError, lambda: asshape((True,))) - -@example([((1, 1), (1, 1)), (1, 1)], (0, 0)) -@example([((), (0,)), (0,)], (0,)) -@example([((1, 2), (2, 1)), (2, 2)], 1) -@given(mutually_broadcastable_shapes, skip_axes()) -def test_iter_indices(broadcastable_shapes, skip_axes): - shapes, broadcasted_shape = broadcastable_shapes - - if skip_axes is None: - res = iter_indices(*shapes) - broadcasted_res = iter_indices(np.broadcast_shapes(*shapes)) - skip_axes = () - else: - res = iter_indices(*shapes, skip_axes=skip_axes) - broadcasted_res = iter_indices(np.broadcast_shapes(*shapes), - skip_axes=skip_axes) - - if isinstance(skip_axes, int): - skip_axes = (skip_axes,) - - sizes = [prod(shape) for shape in shapes] - ndim = len(broadcasted_shape) - arrays = [np.arange(size).reshape(shape) for size, shape in zip(sizes, shapes)] - broadcasted_arrays = np.broadcast_arrays(*arrays) - - # Use negative indices to index the skip axes since only shapes that have - # the skip axis will include a slice. - normalized_skip_axes = sorted(ndindex(i).reduce(ndim).args[0] - ndim for i in skip_axes) - skip_shapes = [tuple(shape[i] for i in normalized_skip_axes if -i <= len(shape)) for shape in shapes] - broadcasted_skip_shape = tuple(broadcasted_shape[i] for i in normalized_skip_axes) - - broadcasted_non_skip_shape = tuple(broadcasted_shape[i] for i in range(-1, -ndim-1, -1) if i not in normalized_skip_axes) - nitems = prod(broadcasted_non_skip_shape) - broadcasted_nitems = prod(broadcasted_shape) - - vals = [] - n = -1 - try: - for n, (idxes, bidxes) in enumerate(zip(res, broadcasted_res)): - assert len(idxes) == len(shapes) - for idx, shape in zip(idxes, shapes): - assert isinstance(idx, Tuple) - assert len(idx.args) == len(shape) - for i in range(-1, -len(idx.args)-1, -1): - if i in normalized_skip_axes and len(idx.args) >= -i: - assert idx.args[i] == slice(None) - else: - assert isinstance(idx.args[i], Integer) - - aidxes = tuple([a[idx.raw] for a, idx in zip(arrays, idxes)]) - a_broadcasted_idxs = [a[idx.raw] for a, idx in - zip(broadcasted_arrays, bidxes)] - - for aidx, abidx, skip_shape in zip(aidxes, a_broadcasted_idxs, skip_shapes): - if skip_shape == broadcasted_skip_shape: - assert_equal(aidx, abidx) - assert aidx.shape == skip_shape - - if skip_axes: - # If there are skipped axes, recursively call iter_indices to - # get each individual element of the resulting subarrays. - for subidxes in iter_indices(*[x.shape for x in aidxes]): - items = [x[i.raw] for x, i in zip(aidxes, subidxes)] - # An empty array means the iteration would be skipped. - if any(a.size == 0 for a in items): - continue - vals.append(tuple(items)) - else: - vals.append(aidxes) - except ValueError as e: - if "duplicate axes" in str(e): - # There should be actual duplicate axes - assert len({broadcasted_shape[i] for i in skip_axes}) < len(skip_axes) - return - raise # pragma: no cover - - assert len(set(vals)) == len(vals) == broadcasted_nitems - - # The indices should correspond to the values that would be matched up - # if the arrays were broadcasted together. - if not arrays: - assert vals == [()] - else: - correct_vals = [tuple(i) for i in np.stack(broadcasted_arrays, axis=-1) - .reshape((broadcasted_nitems, len(arrays)))] - # Also test that the indices are produced in a lexicographic order - # (even though this isn't strictly guaranteed by the iter_indices - # docstring) in the case when there are no skip axes. The order when - # there are skip axes is more complicated because the skipped axes are - # iterated together. - if not skip_axes: - assert vals == correct_vals - else: - assert set(vals) == set(correct_vals) - - assert n == nitems - 1 - -def test_iter_indices_errors(): - try: - list(iter_indices((10,), skip_axes=(2,))) - except AxisError as e: - msg1 = str(e) - else: - raise RuntimeError("iter_indices did not raise AxisError") # pragma: no cover - - # Check that the message is the same one used by NumPy - try: - np.sum(np.arange(10), axis=2) - except np.AxisError as e: - msg2 = str(e) - else: - raise RuntimeError("np.sum() did not raise AxisError") # pragma: no cover - - assert msg1 == msg2 - - try: - list(iter_indices((2, 3), (3, 2))) - except BroadcastError as e: - msg1 = str(e) - else: - raise RuntimeError("iter_indices did not raise BroadcastError") # pragma: no cover - - # TODO: Check that the message is the same one used by NumPy - # try: - # np.broadcast_shapes((2, 3), (3, 2)) - # except np.Error as e: - # msg2 = str(e) - # else: - # raise RuntimeError("np.broadcast_shapes() did not raise AxisError") # pragma: no cover - # - # assert msg1 == msg2 - -@example(1, 1, 1) -@given(integers(0, 100), integers(0, 100), integers(0, 100)) -def test_ncycles(i, n, m): - N = ncycles(range(i), n) - if n == 1: - assert N == range(i) - else: - assert isinstance(N, ncycles) - assert N.iterable == range(i) - assert N.n == n - assert f"range(0, {i})" in repr(N) - assert str(n) in repr(N) - - L = list(N) - assert len(L) == i*n - for j in range(i*n): - assert L[j] == j % i - - M = ncycles(N, m) - if n*m == 1: - assert M == range(i) - else: - assert isinstance(M, ncycles) - assert M.iterable == range(i) - assert M.n == n*m diff --git a/ndindex/tests/test_newaxis.py b/ndindex/tests/test_newaxis.py index bc655a7d..abe44503 100644 --- a/ndindex/tests/test_newaxis.py +++ b/ndindex/tests/test_newaxis.py @@ -4,7 +4,7 @@ from hypothesis.strategies import one_of, integers from ..ndindex import ndindex -from .helpers import check_same, prod, shapes, newaxes +from .helpers import check_same, prod, shapes, newaxes, reduce_kwargs def test_newaxis_exhaustive(): for n in range(10): @@ -24,10 +24,10 @@ def test_newaxis_reduce_exhaustive(): check_same(a, newaxis, ndindex_func=lambda a, x: a[x.reduce((n,)).raw]) -@given(newaxes(), shapes) -def test_newaxis_reduce_hypothesis(idx, shape): +@given(newaxes(), shapes, reduce_kwargs) +def test_newaxis_reduce_hypothesis(idx, shape, kwargs): a = arange(prod(shape)).reshape(shape) - check_same(a, idx, ndindex_func=lambda a, x: a[x.reduce(shape).raw]) + check_same(a, idx, ndindex_func=lambda a, x: a[x.reduce(shape, **kwargs).raw]) def test_newaxis_reduce_no_shape_exhaustive(): @@ -35,10 +35,10 @@ def test_newaxis_reduce_no_shape_exhaustive(): a = arange(n) check_same(a, newaxis, ndindex_func=lambda a, x: a[x.reduce().raw]) -@given(newaxes(), shapes) -def test_newaxis_reduce_no_shape_hypothesis(idx, shape): +@given(newaxes(), shapes, reduce_kwargs) +def test_newaxis_reduce_no_shape_hypothesis(idx, shape, kwargs): a = arange(prod(shape)).reshape(shape) - check_same(a, idx, ndindex_func=lambda a, x: a[x.reduce().raw]) + check_same(a, idx, ndindex_func=lambda a, x: a[x.reduce(**kwargs).raw]) @given(newaxes(), one_of(shapes, integers(0, 10))) def test_newaxis_isempty_hypothesis(idx, shape): diff --git a/ndindex/tests/test_shapetools.py b/ndindex/tests/test_shapetools.py new file mode 100644 index 00000000..ba8333e3 --- /dev/null +++ b/ndindex/tests/test_shapetools.py @@ -0,0 +1,611 @@ +import numpy as np +try: + from numpy import AxisError as np_AxisError +except ImportError: # pragma: no cover + from numpy.exceptions import AxisError as np_AxisError + +from hypothesis import assume, given, example +from hypothesis.strategies import (one_of, integers, tuples as + hypothesis_tuples, just, lists, shared, + ) + +from pytest import raises + +from ..ndindex import ndindex +from ..shapetools import (asshape, iter_indices, ncycles, BroadcastError, + AxisError, broadcast_shapes, remove_indices, + unremove_indices, associated_axis, + normalize_skip_axes) +from ..integer import Integer +from ..tuple import Tuple +from .helpers import (prod, mutually_broadcastable_shapes_with_skipped_axes, + skip_axes_st, mutually_broadcastable_shapes, tuples, + shapes, assert_equal, cross_shapes, cross_skip_axes, + cross_arrays_st, matmul_shapes, matmul_skip_axes, + matmul_arrays_st) + +@example([[(1, 1), (1, 1)], (1,)], (0,)) +@example([[(0,), (0,)], ()], (0,)) +@example([[(1, 2), (2, 1)], (2,)], 1) +@given(mutually_broadcastable_shapes_with_skipped_axes, skip_axes_st) +def test_iter_indices(broadcastable_shapes, skip_axes): + # broadcasted_shape will contain None on the skip_axes, as those axes + # might not be broadcast compatible + shapes, broadcasted_shape = broadcastable_shapes + # We need no more than 31 dimensions so that the np.stack call below + # doesn't fail. + assume(len(broadcasted_shape) < 32) + + # 1. Normalize inputs + _skip_axes = normalize_skip_axes(shapes, skip_axes) + _skip_axes_kwarg_default = [()]*len(shapes) + + # Skipped axes may not be broadcast compatible. Since the index for a + # skipped axis should always be a slice(None), the result should be the + # same if the skipped axes are all moved to the end of the shape. + canonical_shapes = [] + for s, sk in zip(shapes, _skip_axes): + c = remove_indices(s, sk) + canonical_shapes.append(c) + + non_skip_shapes = [remove_indices(shape, sk) for shape, sk in zip(shapes, _skip_axes)] + assert np.broadcast_shapes(*non_skip_shapes) == broadcasted_shape + + nitems = prod(broadcasted_shape) + + if skip_axes == (): # kwarg default + res = iter_indices(*shapes) + else: + res = iter_indices(*shapes, skip_axes=skip_axes) + broadcasted_res = iter_indices(broadcasted_shape) + + sizes = [prod(shape) for shape in shapes] + arrays = [np.arange(size).reshape(shape) for size, shape in zip(sizes, shapes)] + canonical_sizes = [prod(shape) for shape in canonical_shapes] + canonical_arrays = [np.arange(size).reshape(shape) for size, shape in zip(canonical_sizes, canonical_shapes)] + canonical_broadcasted_array = np.arange(nitems).reshape(broadcasted_shape) + + # 2. Check that iter_indices is the same whether or not the shapes are + # broadcasted together first. Also check that every iterated index is the + # expected type and there are as many as expected. + vals = [] + bvals = [] + n = -1 + + def _remove_slices(idx): + assert isinstance(idx, Tuple) + idx2 = [i for i in idx.args if i != slice(None)] + return Tuple(*idx2) + + for n, (idxes, bidxes) in enumerate(zip(res, broadcasted_res)): + assert len(idxes) == len(shapes) + assert len(bidxes) == 1 + for idx, shape, sk in zip(idxes, shapes, _skip_axes): + assert isinstance(idx, Tuple) + assert len(idx.args) == len(shape) + + for i in range(-1, -len(idx.args) - 1, -1): + if i in sk: + assert idx.args[i] == slice(None) + else: + assert isinstance(idx.args[i], Integer) + + canonical_idxes = [_remove_slices(idx) for idx in idxes] + a_indexed = tuple([a[idx.raw] for a, idx in zip(arrays, idxes)]) + canonical_a_indexed = tuple([a[idx.raw] for a, idx in + zip(canonical_arrays, canonical_idxes)]) + canonical_b_indexed = canonical_broadcasted_array[bidxes[0].raw] + + for c_indexed in canonical_a_indexed: + assert c_indexed.shape == () + assert canonical_b_indexed.shape == () + + if _skip_axes != _skip_axes_kwarg_default: + vals.append(tuple(canonical_a_indexed)) + else: + vals.append(a_indexed) + + bvals.append(canonical_b_indexed) + + # assert both iterators have the same length + raises(StopIteration, lambda: next(res)) + raises(StopIteration, lambda: next(broadcasted_res)) + + # Check that the correct number of items are iterated + assert n == nitems - 1 + assert len(set(vals)) == len(vals) == nitems + + # 3. Check that every element of the (broadcasted) arrays is represented + # by an iterated index. + + # The indices should correspond to the values that would be matched up + # if the arrays were broadcasted together. + if not arrays: + assert vals == [()] + else: + correct_vals = list(zip(*[x.flat for x in np.broadcast_arrays(*canonical_arrays)])) + # Also test that the indices are produced in a lexicographic order + # (even though this isn't strictly guaranteed by the iter_indices + # docstring) in the case when there are no skip axes. The order when + # there are skip axes is more complicated because the skipped axes are + # iterated together. + if _skip_axes == _skip_axes_kwarg_default: + assert vals == correct_vals + else: + assert set(vals) == set(correct_vals) + assert bvals == list(canonical_broadcasted_array.flat) + +@given(cross_arrays_st(), cross_shapes, cross_skip_axes) +def test_iter_indices_cross(cross_arrays, broadcastable_shapes, _skip_axes): + # Test iter_indices behavior against np.cross, which effectively skips the + # crossed axis. Note that we don't test against cross products of size 2 + # because a 2 x 2 cross product just returns the z-axis (i.e., it doesn't + # actually skip an axis in the result shape), and also that behavior is + # going to be removed in NumPy 2.0. + a, b = cross_arrays + shapes, broadcasted_shape = broadcastable_shapes + + # Sanity check + skip_axes = normalize_skip_axes([*shapes, broadcasted_shape], _skip_axes) + for sh, sk in zip([*shapes, broadcasted_shape], skip_axes): + assert len(sk) == 1 + assert sh[sk[0]] == 3 + + res = np.cross(a, b, axisa=skip_axes[0][0], axisb=skip_axes[1][0], axisc=skip_axes[2][0]) + assert res.shape == broadcasted_shape + + for idx1, idx2, idx3 in iter_indices(*shapes, broadcasted_shape, skip_axes=_skip_axes): + assert a[idx1.raw].shape == (3,) + assert b[idx2.raw].shape == (3,) + assert_equal(np.cross( + a[idx1.raw], + b[idx2.raw]), + res[idx3.raw]) + +@given(matmul_arrays_st(), matmul_shapes, matmul_skip_axes) +def test_iter_indices_matmul(matmul_arrays, broadcastable_shapes, skip_axes): + # Test iter_indices behavior against np.matmul, which effectively skips the + # contracted axis (they aren't broadcasted together, even when they are + # broadcast compatible). + a, b = matmul_arrays + shapes, broadcasted_shape = broadcastable_shapes + + # Note, we don't use normalize_skip_axes here because it sorts the skip + # axes + + ax1, ax2 = skip_axes[0] + ax3 = skip_axes[1][1] + n, m, k = shapes[0][ax1], shapes[0][ax2], shapes[1][ax3] + + # Sanity check + sk0, sk1, sk2 = skip_axes + shape1, shape2 = shapes + assert a.shape == shape1 + assert b.shape == shape2 + assert shape1[sk0[0]] == n + assert shape1[sk0[1]] == m + assert shape2[sk1[0]] == m + assert shape2[sk1[1]] == k + assert broadcasted_shape[sk2[0]] == n + assert broadcasted_shape[sk2[1]] == k + + res = np.matmul(a, b, axes=skip_axes) + assert res.shape == broadcasted_shape + + is_ordered = lambda sk, shape: (Integer(sk[0]).reduce(len(shape)).raw <= Integer(sk[1]).reduce(len(shape)).raw) + orders = [ + is_ordered(sk0, shapes[0]), + is_ordered(sk1, shapes[1]), + is_ordered(sk2, broadcasted_shape), + ] + + for idx1, idx2, idx3 in iter_indices(*shapes, broadcasted_shape, skip_axes=skip_axes): + assert a[idx1.raw].shape == (n, m) if orders[0] else (m, n) + assert b[idx2.raw].shape == (m, k) if orders[1] else (k, m) + sub_res_axes = [ + (0, 1) if orders[0] else (1, 0), + (0, 1) if orders[1] else (1, 0), + (0, 1) if orders[2] else (1, 0), + ] + sub_res = np.matmul(a[idx1.raw], b[idx2.raw], axes=sub_res_axes) + assert_equal(sub_res, res[idx3.raw]) + +def test_iter_indices_errors(): + try: + list(iter_indices((10,), skip_axes=(2,))) + except AxisError as e: + ndindex_msg = str(e) + else: + raise RuntimeError("iter_indices did not raise AxisError") # pragma: no cover + + # Check that the message is the same one used by NumPy + try: + np.sum(np.arange(10), axis=2) + except np_AxisError as e: + np_msg = str(e) + else: + raise RuntimeError("np.sum() did not raise AxisError") # pragma: no cover + + assert ndindex_msg == np_msg + + try: + list(iter_indices((2, 3), (3, 2))) + except BroadcastError as e: + ndindex_msg = str(e) + else: + raise RuntimeError("iter_indices did not raise BroadcastError") # pragma: no cover + + try: + np.broadcast_shapes((2, 3), (3, 2)) + except ValueError as e: + np_msg = str(e) + else: + raise RuntimeError("np.broadcast_shapes() did not raise ValueError") # pragma: no cover + + + if 'Mismatch' in str(np_msg): # pragma: no cover + # Older versions of NumPy do not have the more helpful error message + assert ndindex_msg == np_msg + + with raises(ValueError, match=r"not unique"): + list(iter_indices((1, 2), skip_axes=(0, 1, 0))) + + raises(AxisError, lambda: list(iter_indices((0,), skip_axes=(3,)))) + raises(ValueError, lambda: list(iter_indices(skip_axes=(0,)))) + raises(TypeError, lambda: list(iter_indices(1, 2))) + raises(TypeError, lambda: list(iter_indices(1, 2, (2, 2)))) + raises(TypeError, lambda: list(iter_indices([(1, 2), (2, 2)]))) + +@example(1, 1, 1) +@given(integers(0, 100), integers(0, 100), integers(0, 100)) +def test_ncycles(i, n, m): + N = ncycles(range(i), n) + if n == 1: + assert N == range(i) + else: + assert isinstance(N, ncycles) + assert N.iterable == range(i) + assert N.n == n + assert f"range(0, {i})" in repr(N) + assert str(n) in repr(N) + + L = list(N) + assert len(L) == i*n + for j in range(i*n): + assert L[j] == j % i + + M = ncycles(N, m) + if n*m == 1: + assert M == range(i) + else: + assert isinstance(M, ncycles) + assert M.iterable == range(i) + assert M.n == n*m + +@given(one_of(mutually_broadcastable_shapes, + hypothesis_tuples(tuples(shapes), just(None)))) +def test_broadcast_shapes(broadcastable_shapes): + shapes, broadcasted_shape = broadcastable_shapes + if broadcasted_shape is not None: + assert broadcast_shapes(*shapes) == broadcasted_shape + + arrays = [np.empty(shape) for shape in shapes] + broadcastable = True + try: + broadcasted_shape = np.broadcast(*arrays).shape + except ValueError: + broadcastable = False + + if broadcastable: + assert broadcast_shapes(*shapes) == broadcasted_shape + else: + raises(BroadcastError, lambda: broadcast_shapes(*shapes)) + + +@given(lists(shapes, max_size=32)) +def test_broadcast_shapes_errors(shapes): + error = True + try: + broadcast_shapes(*shapes) + except BroadcastError as exc: + e = exc + else: + error = False + + # The ndindex and numpy errors won't match in general, because + # ndindex.broadcast_shapes gives an error with the first two shapes that + # aren't broadcast compatible, but numpy doesn't always, due to different + # implementation algorithms (e.g., the message from + # np.broadcast_shapes((0,), (0, 2), (2, 0)) mentions the last two shapes + # whereas ndindex.broadcast_shapes mentions the first two). + + # Instead, just confirm that the error message is correct as stated, and + # check against the numpy error message when just broadcasting the two + # reportedly bad shapes. + + if not error: + try: + np.broadcast_shapes(*shapes) + except: # pragma: no cover + raise RuntimeError("ndindex.broadcast_shapes raised but np.broadcast_shapes did not") + return + + assert shapes[e.arg1] == e.shape1 + assert shapes[e.arg2] == e.shape2 + + try: + np.broadcast_shapes(e.shape1, e.shape2) + except ValueError as np_exc: + # Check that they do in fact not broadcast, and the error messages are + # the same modulo the different arg positions. + if 'Mismatch' in str(np_exc): # pragma: no cover + # Older versions of NumPy do not have the more helpful error message + assert str(BroadcastError(0, e.shape1, 1, e.shape2)) == str(np_exc) + else: # pragma: no cover + raise RuntimeError("ndindex.broadcast_shapes raised but np.broadcast_shapes did not") + + raises(TypeError, lambda: broadcast_shapes(1, 2)) + raises(TypeError, lambda: broadcast_shapes(1, 2, (2, 2))) + raises(TypeError, lambda: broadcast_shapes([(1, 2), (2, 2)])) + +@given(mutually_broadcastable_shapes_with_skipped_axes, skip_axes_st) +def test_broadcast_shapes_skip_axes(broadcastable_shapes, skip_axes): + shapes, broadcasted_shape = broadcastable_shapes + assert broadcast_shapes(*shapes, skip_axes=skip_axes) == broadcasted_shape + +@example([[], ()], (0,)) +@example([[(0, 1)], (0, 1)], (2,)) +@example([[(0, 1)], (0, 1)], (0, -1)) +@example([[(0, 1, 0, 0, 0), (2, 0, 0, 0)], (0, 2, 0, 0, 0)], [1]) +@given(mutually_broadcastable_shapes, + one_of( + integers(-20, 20), + tuples(integers(-20, 20), max_size=20), + lists(tuples(integers(-20, 20), max_size=20), max_size=32))) +def test_broadcast_shapes_skip_axes_errors(broadcastable_shapes, skip_axes): + shapes, broadcasted_shape = broadcastable_shapes + + # All errors should come from normalize_skip_axes, which is tested + # separately below. + try: + normalize_skip_axes(shapes, skip_axes) + except (TypeError, ValueError, IndexError) as e: + raises(type(e), lambda: broadcast_shapes(*shapes, + skip_axes=skip_axes)) + return + + try: + broadcast_shapes(*shapes, skip_axes=skip_axes) + except IndexError: + raise RuntimeError("broadcast_shapes raised but should not have") # pragma: no cover + except BroadcastError: + # Broadcastable shapes can become unbroadcastable after skipping axes + # (see the @example above). + pass + +remove_indices_n = shared(integers(0, 100)) + +@given(remove_indices_n, + remove_indices_n.flatmap(lambda n: lists(integers(-n, n), unique=True))) +def test_remove_indices(n, idxes): + if idxes: + assume(max(idxes) < n) + assume(min(idxes) >= -n) + a = tuple(range(n)) + b = remove_indices(a, idxes) + if len(idxes) == 1: + assert remove_indices(a, idxes[0]) == b + + A = list(a) + for i in idxes: + A[i] = None + + assert set(A) - set(b) == ({None} if idxes else set()) + assert set(b) - set(A) == set() + + # Check the order is correct + j = 0 + for i in range(n): + val = A[i] + if val == None: + assert val not in b + else: + assert b[j] == val + j += 1 + + # Test that unremove_indices is the inverse + if all(i >= 0 for i in idxes) or all(i < 0 for i in idxes): + assert unremove_indices(b, idxes) == tuple(A) + else: + raises(NotImplementedError, lambda: unremove_indices(b, idxes)) + +# Meta-test for the hypothesis strategy +@given(mutually_broadcastable_shapes_with_skipped_axes, skip_axes_st) +def test_mutually_broadcastable_shapes_with_skipped_axes(broadcastable_shapes, + skip_axes): # pragma: no cover + shapes, broadcasted_shape = broadcastable_shapes + _skip_axes = normalize_skip_axes(shapes, skip_axes) + + assert len(_skip_axes) == len(shapes) + + for shape in shapes: + assert None not in shape + assert None not in broadcasted_shape + + _shapes = [remove_indices(shape, sk) for shape, sk in zip(shapes, _skip_axes)] + + assert broadcast_shapes(*_shapes) == broadcasted_shape + +@example([[(2, 10, 3, 4), (10, 3, 4)], (2, 3, 4)], (-3,)) +@example([[(0, 10, 2, 3, 10, 4), (1, 10, 1, 0, 10, 2, 3, 4)], + (1, 1, 0, 2, 3, 4)], (1, 4)) +@example([[(2, 0, 3, 4)], (2, 3, 4)], (1,)) +@example([[(0, 0, 0, 0, 0), (0, 0, 0, 0, 0, 0)], (0, 0, 0, 0)], (1, 2)) +@given(mutually_broadcastable_shapes_with_skipped_axes, skip_axes_st) +def test_associated_axis(broadcastable_shapes, skip_axes): + shapes, broadcasted_shape = broadcastable_shapes + _skip_axes = normalize_skip_axes(shapes, skip_axes) + + for shape, sk in zip(shapes, _skip_axes): + n = len(shape) + for i in range(-len(shape), 0): + val = shape[i] + + bval = associated_axis(broadcasted_shape, i, sk) + if bval is None: + assert ndindex(i).reduce(n, negative_int=True) in sk, (shape, i) + else: + assert val == 1 or bval == val, (shape, i) + + + sk = max(_skip_axes, key=len, default=()) + for i in range(-len(broadcasted_shape)-len(sk)-10, -len(broadcasted_shape)-len(sk)): + assert associated_axis(broadcasted_shape, i, sk) is None + +# TODO: add a hypothesis test for asshape +def test_asshape(): + assert asshape(1) == (1,) + assert asshape(np.int64(2)) == (2,) + assert type(asshape(np.int64(2))[0]) == int + assert asshape((1, 2)) == (1, 2) + assert asshape([1, 2]) == (1, 2) + assert asshape((1, 2), allow_int=False) == (1, 2) + assert asshape([1, 2], allow_int=False) == (1, 2) + assert asshape((np.int64(1), np.int64(2))) == (1, 2) + assert type(asshape((np.int64(1), np.int64(2)))[0]) == int + assert type(asshape((np.int64(1), np.int64(2)))[1]) == int + assert asshape((-1, -2), allow_negative=True) == (-1, -2) + assert asshape(-2, allow_negative=True) == (-2,) + + + raises(TypeError, lambda: asshape(1.0)) + raises(TypeError, lambda: asshape((1.0,))) + raises(ValueError, lambda: asshape(-1)) + raises(ValueError, lambda: asshape((1, -1))) + raises(ValueError, lambda: asshape((1, None))) + raises(TypeError, lambda: asshape(...)) + raises(TypeError, lambda: asshape(Integer(1))) + raises(TypeError, lambda: asshape(Tuple(1, 2))) + raises(TypeError, lambda: asshape((True,))) + raises(TypeError, lambda: asshape({1, 2})) + raises(TypeError, lambda: asshape({1: 2})) + raises(TypeError, lambda: asshape('1')) + raises(TypeError, lambda: asshape(1, allow_int=False)) + raises(TypeError, lambda: asshape(-1, allow_int=False)) + raises(TypeError, lambda: asshape(-1, allow_negative=True, allow_int=False)) + raises(TypeError, lambda: asshape(np.int64(1), allow_int=False)) + raises(IndexError, lambda: asshape((2, 3), 3)) + +@example([], []) +@example([()], []) +@example([(0, 1)], 0) +@example([(2, 3), (2, 3, 4)], [(3,), (0,)]) +@example([(0, 1)], 0) +@example([(2, 3)], (0, -2)) +@example([(2, 4), (2, 3, 4)], [(0,), (-3,)]) +@given(lists(tuples(integers(0))), + one_of(integers(), tuples(integers()), lists(tuples(integers())))) +def test_normalize_skip_axes(shapes, skip_axes): + if not shapes: + if skip_axes in [(), []]: + assert normalize_skip_axes(shapes, skip_axes) == [] + else: + raises(ValueError, lambda: normalize_skip_axes(shapes, skip_axes)) + return + + min_dim = min(len(shape) for shape in shapes) + + if isinstance(skip_axes, int): + if not (-min_dim <= skip_axes < min_dim): + raises(AxisError, lambda: normalize_skip_axes(shapes, skip_axes)) + return + _skip_axes = [(skip_axes,)]*len(shapes) + skip_len = 1 + elif isinstance(skip_axes, tuple): + if not all(-min_dim <= s < min_dim for s in skip_axes): + raises(AxisError, lambda: normalize_skip_axes(shapes, skip_axes)) + return + _skip_axes = [skip_axes]*len(shapes) + skip_len = len(skip_axes) + elif not skip_axes: + # empty list will be interpreted as a single skip_axes tuple + assert normalize_skip_axes(shapes, skip_axes) == [()]*len(shapes) + return + else: + if len(shapes) != len(skip_axes): + raises(ValueError, lambda: normalize_skip_axes(shapes, skip_axes)) + return + _skip_axes = skip_axes + skip_len = len(skip_axes[0]) + + try: + res = normalize_skip_axes(shapes, skip_axes) + except AxisError as e: + axis, ndim = e.args + assert any(axis in s for s in _skip_axes) + assert any(ndim == len(shape) for shape in shapes) + assert axis < -ndim or axis >= ndim + return + except ValueError as e: + if 'not unique' in str(e): + bad_skip_axes, bad_shape = e.skip_axes, e.shape + assert str(bad_skip_axes) in str(e) + assert str(bad_shape) in str(e) + assert bad_skip_axes in _skip_axes + assert bad_shape in shapes + indexed = [bad_shape[i] for i in bad_skip_axes] + assert len(indexed) != len(set(indexed)) + return + else: # pragma: no cover + raise + + assert isinstance(res, list) + assert all(isinstance(x, tuple) for x in res) + assert all(isinstance(i, int) for x in res for i in x) + + assert len(res) == len(shapes) + for shape, new_skip_axes in zip(shapes, res): + assert len(new_skip_axes) == len(set(new_skip_axes)) == skip_len + assert new_skip_axes == tuple(sorted(new_skip_axes)) + for i in new_skip_axes: + assert i < 0 + assert ndindex(i).reduce(len(shape), negative_int=True) == i + + # TODO: Assert the order is maintained (doesn't actually matter for now + # but could for future applications) + +def test_normalize_skip_axes_errors(): + raises(TypeError, lambda: normalize_skip_axes([(1,)], {0: 1})) + raises(TypeError, lambda: normalize_skip_axes([(1,)], {0})) + raises(TypeError, lambda: normalize_skip_axes([(1,)], [(0,), 0])) + raises(TypeError, lambda: normalize_skip_axes([(1,)], [0, (0,)])) + +@example(10, 5) +@given(integers(), integers()) +def test_axiserror(axis, ndim): + if ndim == 0 and axis in [0, -1]: + # NumPy allows axis=0 or -1 for 0-d arrays + AxisError(axis, ndim) + return + + try: + if ndim >= 0: + range(ndim)[axis] + except IndexError: + e = AxisError(axis, ndim) + else: + raises(ValueError, lambda: AxisError(axis, ndim)) + return + + try: + raise e + except AxisError as e2: + assert e2.args == (axis, ndim) + if ndim <= 32 and -1000 < axis < 1000: + a = np.empty((0,)*ndim) + try: + np.sum(a, axis=axis) + except np_AxisError as e3: + assert str(e2) == str(e3) + else: + raise RuntimeError("numpy didn't raise AxisError") # pragma: no cover diff --git a/ndindex/tests/test_slice.py b/ndindex/tests/test_slice.py index 6135cc9b..ee50e3c8 100644 --- a/ndindex/tests/test_slice.py +++ b/ndindex/tests/test_slice.py @@ -8,8 +8,8 @@ from ..slice import Slice from ..integer import Integer from ..ellipsis import ellipsis -from ..ndindex import asshape -from .helpers import check_same, slices, prod, shapes, iterslice, assert_equal +from ..shapetools import asshape +from .helpers import check_same, slices, prod, shapes, iterslice, assert_equal, reduce_kwargs def test_slice_args(): # Test the behavior when not all three arguments are given @@ -144,8 +144,8 @@ def test_slice_reduce_no_shape_exhaustive(): slices[B] = reduced -@given(slices(), one_of(integers(0, 100), shapes)) -def test_slice_reduce_no_shape_hypothesis(s, shape): +@given(slices(), one_of(integers(0, 100), shapes), reduce_kwargs) +def test_slice_reduce_no_shape_hypothesis(s, shape, kwargs): if isinstance(shape, int): a = arange(shape) else: @@ -158,10 +158,10 @@ def test_slice_reduce_no_shape_hypothesis(s, shape): # The axis argument is tested implicitly in the Tuple.reduce test. It is # difficult to test here because we would have to pass in a Tuple to # check_same. - check_same(a, S.raw, ndindex_func=lambda a, x: a[x.reduce().raw]) + check_same(a, S.raw, ndindex_func=lambda a, x: a[x.reduce(**kwargs).raw]) # Check the conditions stated by the Slice.reduce() docstring - reduced = S.reduce() + reduced = S.reduce(**kwargs) assert reduced.start != None if S.start != None and S.start >= 0: assert reduced.start >= 0 @@ -171,7 +171,7 @@ def test_slice_reduce_no_shape_hypothesis(s, shape): if reduced.stop is None: assert S.stop is None # Idempotency - assert reduced.reduce() == reduced, S + assert reduced.reduce(**kwargs) == reduced, S def test_slice_reduce_exhaustive(): for n in range(30): @@ -232,11 +232,11 @@ def test_slice_reduce_exhaustive(): assert reduced.reduce() == reduced, S assert reduced.reduce((n,)) == reduced, S -@example(slice(None, None, -1), 2) -@example(slice(-10, 11, 3), 10) -@example(slice(-1, 3, -3), 10) -@given(slices(), one_of(integers(0, 100), shapes)) -def test_slice_reduce_hypothesis(s, shape): +@example(slice(None, None, -1), 2, {}) +@example(slice(-10, 11, 3), 10, {}) +@example(slice(-1, 3, -3), 10, {}) +@given(slices(), one_of(integers(0, 100), shapes), reduce_kwargs) +def test_slice_reduce_hypothesis(s, shape, kwargs): if isinstance(shape, int): a = arange(shape) else: @@ -250,11 +250,11 @@ def test_slice_reduce_hypothesis(s, shape): # The axis argument is tested implicitly in the Tuple.reduce test. It is # difficult to test here because we would have to pass in a Tuple to # check_same. - check_same(a, S.raw, ndindex_func=lambda a, x: a[x.reduce(shape).raw]) + check_same(a, S.raw, ndindex_func=lambda a, x: a[x.reduce(shape, **kwargs).raw]) # Check the conditions stated by the Slice.reduce() docstring try: - reduced = S.reduce(shape) + reduced = S.reduce(shape, **kwargs) except IndexError: # shape == () return @@ -294,8 +294,8 @@ def test_slice_reduce_hypothesis(s, shape): assert reduced == Slice(reduced.start, reduced.start+1, 1) # Idempotency - assert reduced.reduce() == reduced, S - assert reduced.reduce(shape) == reduced, S + assert reduced.reduce(**kwargs) == reduced, S + assert reduced.reduce(shape, **kwargs) == reduced, S def test_slice_newshape_exhaustive(): def raw_func(a, idx): diff --git a/ndindex/tests/test_tuple.py b/ndindex/tests/test_tuple.py index b1ecdffa..8297ed7d 100644 --- a/ndindex/tests/test_tuple.py +++ b/ndindex/tests/test_tuple.py @@ -1,6 +1,6 @@ from itertools import product -from numpy import arange, array, intp, empty +from numpy import arange, array, intp, empty, all as np_all from hypothesis import given, example from hypothesis.strategies import integers, one_of @@ -10,7 +10,8 @@ from ..ndindex import ndindex from ..tuple import Tuple from ..integer import Integer -from .helpers import check_same, Tuples, prod, short_shapes, iterslice +from ..integerarray import IntegerArray +from .helpers import check_same, Tuples, prod, short_shapes, iterslice, reduce_kwargs def test_tuple_constructor(): # Test things in the Tuple constructor that are not tested by the other @@ -81,10 +82,10 @@ def ndindex_func(a, index): check_same(a, t, ndindex_func=ndindex_func) -@example((True, 0, False), 1) -@example((..., None), ()) -@given(Tuples, one_of(short_shapes, integers(0, 10))) -def test_tuple_reduce_no_shape_hypothesis(t, shape): +@example((True, 0, False), 1, {}) +@example((..., None), (), {}) +@given(Tuples, one_of(short_shapes, integers(0, 10)), reduce_kwargs) +def test_tuple_reduce_no_shape_hypothesis(t, shape, kwargs): if isinstance(shape, int): a = arange(shape) else: @@ -92,31 +93,33 @@ def test_tuple_reduce_no_shape_hypothesis(t, shape): index = Tuple(*t) - check_same(a, index.raw, ndindex_func=lambda a, x: a[x.reduce().raw], + check_same(a, index.raw, ndindex_func=lambda a, x: a[x.reduce(**kwargs).raw], same_exception=False) - reduced = index.reduce() + reduced = index.reduce(**kwargs) if isinstance(reduced, Tuple): assert len(reduced.args) != 1 assert reduced == () or reduced.args[-1] != ... # Idempotency - assert reduced.reduce() == reduced - -@example((..., None), ()) -@example((..., empty((0, 0), dtype=bool)), (0, 0)) -@example((empty((0, 0), dtype=bool), 0), (0, 0, 1)) -@example((array([], dtype=intp), 0), (0, 0)) -@example((array([], dtype=intp), array(0)), (0, 0)) -@example((array([], dtype=intp), [0]), (0, 0)) -@example((0, 1, ..., 2, 3), (2, 3, 4, 5, 6, 7)) -@example((0, slice(None), ..., slice(None), 3), (2, 3, 4, 5, 6, 7)) -@example((0, ..., slice(None)), (2, 3, 4, 5, 6, 7)) -@example((slice(None, None, -1),), (2,)) -@example((..., slice(None, None, -1),), (2, 3, 4)) -@example((..., False, slice(None)), 0) -@given(Tuples, one_of(short_shapes, integers(0, 10))) -def test_tuple_reduce_hypothesis(t, shape): + assert reduced.reduce(**kwargs) == reduced + +@example((..., empty((1, 0), dtype=intp)), (1, 0), {}) +@example((1, -1, [1, -1]), (3, 3, 3), {'negative_int': True}) +@example((..., None), (), {}) +@example((..., empty((0, 0), dtype=bool)), (0, 0), {}) +@example((empty((0, 0), dtype=bool), 0), (0, 0, 1), {}) +@example((array([], dtype=intp), 0), (0, 0), {}) +@example((array([], dtype=intp), array(0)), (0, 0), {}) +@example((array([], dtype=intp), [0]), (0, 0), {}) +@example((0, 1, ..., 2, 3), (2, 3, 4, 5, 6, 7), {}) +@example((0, slice(None), ..., slice(None), 3), (2, 3, 4, 5, 6, 7), {}) +@example((0, ..., slice(None)), (2, 3, 4, 5, 6, 7), {}) +@example((slice(None, None, -1),), (2,), {}) +@example((..., slice(None, None, -1),), (2, 3, 4), {}) +@example((..., False, slice(None)), 0, {}) +@given(Tuples, one_of(short_shapes, integers(0, 10)), reduce_kwargs) +def test_tuple_reduce_hypothesis(t, shape, kwargs): if isinstance(shape, int): a = arange(shape) else: @@ -124,11 +127,13 @@ def test_tuple_reduce_hypothesis(t, shape): index = Tuple(*t) - check_same(a, index.raw, ndindex_func=lambda a, x: a[x.reduce(shape).raw], + check_same(a, index.raw, ndindex_func=lambda a, x: a[x.reduce(shape, **kwargs).raw], same_exception=False) + negative_int = kwargs.get('negative_int', False) + try: - reduced = index.reduce(shape) + reduced = index.reduce(shape, **kwargs) except IndexError: pass else: @@ -138,11 +143,23 @@ def test_tuple_reduce_hypothesis(t, shape): # TODO: Check the other properties from the Tuple.reduce docstring. # Idempotency - assert reduced.reduce() == reduced + assert reduced.reduce(**kwargs) == reduced # This is currently not implemented, for example, (..., False, :) # takes two steps to remove the redundant slice. # assert reduced.reduce(shape) == reduced + for arg in reduced.args: + if isinstance(arg, Integer): + if negative_int: + assert arg.raw < 0 + else: + assert arg.raw >= 0 + elif isinstance(arg, IntegerArray): + if negative_int: + assert np_all(arg.raw < 0) + else: + assert np_all(arg.raw >= 0) + def test_tuple_reduce_explicit(): # Some aspects of Tuple.reduce are hard to test as properties, so include # some explicit tests here. diff --git a/ndindex/tuple.py b/ndindex/tuple.py index 46afe00c..30aa9646 100644 --- a/ndindex/tuple.py +++ b/ndindex/tuple.py @@ -1,7 +1,8 @@ import sys -from .ndindex import NDIndex, ndindex, asshape +from .ndindex import NDIndex, ndindex from .subindex_helpers import subindex_slice +from .shapetools import asshape, broadcast_shapes, BroadcastError class Tuple(NDIndex): """ @@ -92,15 +93,12 @@ def _typecheck(self, *args): if has_boolean_scalar: raise NotImplementedError("Tuples mixing boolean scalars (True or False) with arrays are not yet supported.") - from numpy import broadcast try: - broadcast(*[i for i in arrays]) - except ValueError as e: - assert str(e).startswith("shape mismatch: objects cannot be broadcast to a single shape"), e.args - # TODO: Newer versions of NumPy include where the mismatch is - # in the error message in a more informative way than this - # (but we can't use it directly because it talks about the - # "arg"s to broadcast()). + broadcast_shapes(*[i.shape for i in arrays]) + except BroadcastError: + # This matches the NumPy error message. The BroadcastError has + # a better error message, but it will be shown in the chained + # traceback. raise IndexError("shape mismatch: indexing arrays could not be broadcast together with shapes %s" % ' '.join([str(i.shape) for i in arrays])) return tuple(newargs) @@ -183,7 +181,7 @@ def ellipsis_index(self): def raw(self): return tuple(i.raw for i in self.args) - def reduce(self, shape=None): + def reduce(self, shape=None, *, negative_int=False): r""" Reduce a Tuple index on an array of shape `shape` @@ -248,8 +246,8 @@ def reduce(self, shape=None): Integer(1) >>> a[..., 1] array(1) - >>> a[1] - 1 + >>> a[1] # doctest: +SKIPNP1 + np.int64(1) See https://github.com/Quansight-Labs/ndindex/issues/22. @@ -280,7 +278,7 @@ def reduce(self, shape=None): seen_boolean_scalar = True else: _args.append(s) - return type(self)(*_args).reduce(shape) + return type(self)(*_args).reduce(shape, negative_int=negative_int) arrays = [] for i in args: @@ -292,9 +290,9 @@ def reduce(self, shape=None): # TODO: Avoid explicitly calling nonzero arrays.extend(i.raw.nonzero()) if arrays: - from numpy import broadcast, broadcast_to + from numpy import broadcast_to - broadcast_shape = broadcast(*arrays).shape + broadcast_shape = broadcast_shapes(*[a.shape for a in arrays]) else: broadcast_shape = () @@ -342,7 +340,7 @@ def reduce(self, shape=None): elif isinstance(s, BooleanArray): begin_offset += s.ndim - 1 axis = ellipsis_i - i - begin_offset - reduced = s.reduce(shape, axis=axis) + reduced = s.reduce(shape, axis=axis, negative_int=negative_int) if (removable and isinstance(reduced, Slice) and reduced == Slice(0, shape[axis], 1)): @@ -352,7 +350,7 @@ def reduce(self, shape=None): preargs.insert(0, reduced) if shape is None: - endargs = [s.reduce() for s in args[ellipsis_i+1:]] + endargs = [s.reduce(negative_int=negative_int) for s in args[ellipsis_i+1:]] else: endargs = [] end_offset = 0 @@ -365,7 +363,7 @@ def reduce(self, shape=None): if not (isinstance(s, IntegerArray) and (0 in broadcast_shape or False in args)): # Array bounds are not checked when the broadcast shape is empty - s = s.reduce(shape, axis=axis) + s = s.reduce(shape, axis=axis, negative_int=negative_int) endargs.insert(0, s) if shape is not None: @@ -428,9 +426,9 @@ def broadcast_arrays(self): if not arrays: return self - from numpy import array, broadcast, broadcast_to, intp + from numpy import array, broadcast_to, intp - broadcast_shape = broadcast(*arrays).shape + broadcast_shape = broadcast_shapes(*[a.shape for a in arrays]) newargs = [] for s in args: @@ -487,9 +485,9 @@ def expand(self, shape): arrays.extend(i.raw.nonzero()) if arrays: - from numpy import broadcast, broadcast_to, array, intp + from numpy import broadcast_to, array, intp - broadcast_shape = broadcast(*arrays).shape + broadcast_shape = broadcast_shapes(*[a.shape for a in arrays]) # If the broadcast shape is empty, out of bounds indices in # non-empty arrays are ignored, e.g., ([], [10]) would broadcast to # ([], []), so the bounds for 10 are not checked. Thus, we must do diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 00000000..7d960665 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,8 @@ +hypothesis +numpy +packaging +pyflakes +pytest +pytest-cov +pytest-doctestplus +pytest-flakes diff --git a/rever.xsh b/rever.xsh index b443608e..33e66b3b 100644 --- a/rever.xsh +++ b/rever.xsh @@ -25,7 +25,8 @@ def run_tests(): @activity def build_docs(): - with run_in_conda_env(['python=3.10', 'sphinx', 'myst-parser', 'numpy']): + with run_in_conda_env(['python=3.10', 'sphinx', 'myst-parser', 'numpy', + 'sphinx-copybutton', 'furo']): cd docs make html cd .. @@ -47,7 +48,7 @@ $ACTIVITIES = [ 'pypi', # Sends the package to pypi 'push_tag', # Pushes the tag up to the $TAG_REMOTE 'ghrelease', # Creates a Github release entry for the new tag - 'ghpages', # Update GitHub Pages + # 'ghpages', # Update GitHub Pages ] $PUSH_TAG_REMOTE = 'git@github.com:Quansight-Labs/ndindex.git' # Repo to push tags to diff --git a/setup.py b/setup.py index 5d7e7efe..376e75cc 100644 --- a/setup.py +++ b/setup.py @@ -18,7 +18,9 @@ def check_cython(): from Cython.Build import cythonize sys.argv = argv_org[:1] + ["build_ext"] setuptools.setup(name="foo", version="1.0.0", - ext_modules=cythonize(["ndindex/__init__.py"])) + ext_modules=cythonize( + ["ndindex/__init__.py"], + language_level="3")) except: return False finally: @@ -37,7 +39,8 @@ def check_cython(): if use_cython: from Cython.Build import cythonize - ext_modules = cythonize(["ndindex/*.py"]) + ext_modules = cythonize(["ndindex/*.py"], + language_level="3") else: ext_modules = [] @@ -67,7 +70,7 @@ def check_cython(): "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", ], - python_requires='>=3.7', + python_requires='>=3.8', ) print("CYTHONIZE_NDINDEX: %r" % CYTHONIZE_NDINDEX) diff --git a/versioneer.py b/versioneer.py index 13901fcd..1e461ba0 100644 --- a/versioneer.py +++ b/versioneer.py @@ -339,9 +339,9 @@ def get_config_from_root(root): # configparser.NoOptionError (if it lacks "VCS="). See the docstring at # the top of versioneer.py for instructions on writing your setup.cfg . setup_cfg = os.path.join(root, "setup.cfg") - parser = configparser.SafeConfigParser() + parser = configparser.ConfigParser() with open(setup_cfg, "r") as f: - parser.readfp(f) + parser.read_file(f) VCS = parser.get("versioneer", "VCS") # mandatory def get(parser, name):