diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 0000000..6a5c910 --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,127 @@ +# Circle CI configuration file +# https://circleci.com/docs/ + +--- +version: 2.1 + +####################################### +# Define some common steps as commands. +# + +commands: + check-skip: + steps: + - run: + name: Check-skip + command: | + export git_log=$(git log --max-count=1 --pretty=format:"%B" | + tr "\n" " ") + echo "Got commit message:" + echo "${git_log}" + if [[ -v CIRCLE_PULL_REQUEST ]] && ( \ + [[ "$git_log" == *"[skip circle]"* ]] || \ + [[ "$git_log" == *"[circle skip]"* ]]); then + echo "Skip detected, exiting job ${CIRCLE_JOB} for PR ${CIRCLE_PULL_REQUEST}." + circleci-agent step halt; + fi + + merge: + steps: + - run: + name: Merge with upstream + command: | + if ! git remote -v | grep upstream; then + git remote add upstream https://github.com/matplotlib/cycler.git + fi + git fetch upstream + if [[ "$CIRCLE_BRANCH" != "main" ]] && \ + [[ "$CIRCLE_PR_NUMBER" != "" ]]; then + echo "Merging ${CIRCLE_PR_NUMBER}" + git pull --ff-only upstream "refs/pull/${CIRCLE_PR_NUMBER}/merge" + fi + + pip-install: + description: Upgrade pip to get as clean an install as possible + steps: + - run: + name: Upgrade pip + command: | + python -m pip install --upgrade --user pip + + cycler-install: + steps: + - run: + name: Install Cycler + command: | + python -m pip install --user -ve .[docs] + + doc-build: + steps: + - restore_cache: + keys: + - sphinx-env-v1-{{ .BuildNum }}-{{ .Environment.CIRCLE_JOB }} + - sphinx-env-v1-{{ .Environment.CIRCLE_PREVIOUS_BUILD_NUM }}-{{ .Environment.CIRCLE_JOB }} + - run: + name: Build documentation + command: | + # Set epoch to date of latest tag. + export SOURCE_DATE_EPOCH="$(git log -1 --format=%at $(git describe --abbrev=0))" + mkdir -p logs + make html SPHINXOPTS="-T -j4 -w /tmp/sphinxerrorswarnings.log" + rm -r build/html/_sources + working_directory: doc + - save_cache: + key: sphinx-env-v1-{{ .BuildNum }}-{{ .Environment.CIRCLE_JOB }} + paths: + - doc/build/doctrees + + doc-show-errors-warnings: + steps: + - run: + name: Extract possible build errors and warnings + command: | + (grep "WARNING\|ERROR" /tmp/sphinxerrorswarnings.log || + echo "No errors or warnings") + # Save logs as an artifact, and convert from absolute paths to + # repository-relative paths. + sed "s~$PWD/~~" /tmp/sphinxerrorswarnings.log > \ + doc/logs/sphinx-errors-warnings.log + when: always + - store_artifacts: + path: doc/logs/sphinx-errors-warnings.log + +########################################## +# Here is where the real jobs are defined. +# + +jobs: + docs-python39: + docker: + - image: cimg/python:3.9 + resource_class: large + steps: + - checkout + - check-skip + - merge + + - pip-install + + - cycler-install + + - doc-build + - doc-show-errors-warnings + + - store_artifacts: + path: doc/build/html + +######################################### +# Defining workflows gets us parallelism. +# + +workflows: + version: 2 + build: + jobs: + # NOTE: If you rename this job, then you must update the `if` condition + # and `circleci-jobs` option in `.github/workflows/circleci.yml`. + - docs-python39 diff --git a/.circleci/fetch_doc_logs.py b/.circleci/fetch_doc_logs.py new file mode 100644 index 0000000..0a5552a --- /dev/null +++ b/.circleci/fetch_doc_logs.py @@ -0,0 +1,66 @@ +""" +Download artifacts from CircleCI for a documentation build. + +This is run by the :file:`.github/workflows/circleci.yml` workflow in order to +get the warning/deprecation logs that will be posted on commits as checks. Logs +are downloaded from the :file:`docs/logs` artifact path and placed in the +:file:`logs` directory. + +Additionally, the artifact count for a build is produced as a workflow output, +by appending to the file specified by :env:`GITHUB_OUTPUT`. + +If there are no logs, an "ERROR" message is printed, but this is not fatal, as +the initial 'status' workflow runs when the build has first started, and there +are naturally no artifacts at that point. + +This script should be run by passing the CircleCI build URL as its first +argument. In the GitHub Actions workflow, this URL comes from +``github.event.target_url``. +""" +import json +import os +from pathlib import Path +import sys +from urllib.parse import urlparse +from urllib.request import URLError, urlopen + + +if len(sys.argv) != 2: + print('USAGE: fetch_doc_results.py CircleCI-build-url') + sys.exit(1) + +target_url = urlparse(sys.argv[1]) +*_, organization, repository, build_id = target_url.path.split('/') +print(f'Fetching artifacts from {organization}/{repository} for {build_id}') + +artifact_url = ( + f'https://circleci.com/api/v2/project/gh/' + f'{organization}/{repository}/{build_id}/artifacts' +) +print(artifact_url) +try: + with urlopen(artifact_url) as response: + artifacts = json.load(response) +except URLError: + artifacts = {'items': []} +artifact_count = len(artifacts['items']) +print(f'Found {artifact_count} artifacts') + +with open(os.environ['GITHUB_OUTPUT'], 'w+') as fd: + fd.write(f'count={artifact_count}\n') + +logs = Path('logs') +logs.mkdir(exist_ok=True) + +found = False +for item in artifacts['items']: + path = item['path'] + if path.startswith('doc/logs/'): + path = Path(path).name + print(f'Downloading {path} from {item["url"]}') + with urlopen(item['url']) as response: + (logs / path).write_bytes(response.read()) + found = True + +if not found: + print('ERROR: Did not find any artifact logs!') diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..dbcb44a --- /dev/null +++ b/.coveragerc @@ -0,0 +1,6 @@ +[run] +source=cycler +branch=True +[report] +omit = + *test* diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..407d802 --- /dev/null +++ b/.flake8 @@ -0,0 +1,26 @@ +[flake8] +max-line-length = 88 +select = + # flake8 default + D, E, F, W, +ignore = + # flake8 default + E121,E123,E126,E226,E24,E704,W503,W504, + # pydocstyle + D100, D101, D102, D103, D104, D105, D106, + D200, D202, D204, D205, + D301, + D400, D401, D403, D404 + # ignored by pydocstyle numpy docstring convention + D107, D203, D212, D213, D402, D413, D415, D416, D417, + +exclude = + .git + build + # External files. + .tox + .eggs + +per-file-ignores = + setup.py: E402 +force-check = True diff --git a/.github/workflows/circleci.yml b/.github/workflows/circleci.yml new file mode 100644 index 0000000..384bc8e --- /dev/null +++ b/.github/workflows/circleci.yml @@ -0,0 +1,58 @@ +--- +name: "CircleCI artifact handling" +on: [status] +jobs: + circleci_artifacts_redirector_job: + if: "${{ github.event.context == 'ci/circleci: docs-python39' }}" + permissions: + statuses: write + runs-on: ubuntu-latest + name: Run CircleCI artifacts redirector + steps: + - name: GitHub Action step + uses: larsoner/circleci-artifacts-redirector-action@master + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + artifact-path: 0/doc/build/html/index.html + circleci-jobs: docs-python39 + job-title: View the built docs + + post_warnings_as_review: + if: "${{ github.event.context == 'ci/circleci: docs-python39' }}" + permissions: + contents: read + checks: write + pull-requests: write + runs-on: ubuntu-latest + name: Post warnings/errors as review + steps: + - uses: actions/checkout@v3 + + - name: Fetch result artifacts + id: fetch-artifacts + run: | + python .circleci/fetch_doc_logs.py "${{ github.event.target_url }}" + + - name: Set up reviewdog + if: "${{ steps.fetch-artifacts.outputs.count != 0 }}" + uses: reviewdog/action-setup@v1 + with: + reviewdog_version: latest + + - name: Post review + if: "${{ steps.fetch-artifacts.outputs.count != 0 }}" + env: + REVIEWDOG_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REVIEWDOG_SKIP_DOGHOUSE: "true" + CI_COMMIT: ${{ github.event.sha }} + CI_REPO_OWNER: ${{ github.event.repository.owner.login }} + CI_REPO_NAME: ${{ github.event.repository.name }} + run: | + # The 'status' event does not contain information in the way that + # reviewdog expects, so we unset those so it reads from the + # environment variables we set above. + unset GITHUB_ACTIONS GITHUB_EVENT_PATH + cat logs/sphinx-deprecations.log | \ + reviewdog \ + -efm '%f\:%l: %m' \ + -name=examples -tee -reporter=github-check -filter-mode=nofilter diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..024c8d9 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,64 @@ +--- + +name: Release +on: + release: + types: + - published + +jobs: + build: + name: Build Release Packages + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 10 + + - name: Set up Python + id: setup + uses: actions/setup-python@v4 + with: + python-version: 3.x + + - name: Install build tools + run: | + python -m pip install --upgrade pip + python -m pip install --upgrade build + + - name: Build packages + run: python -m build + + - name: Save built packages as artifact + uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 + with: + name: packages-${{ runner.os }}-${{ steps.setup.outputs.python-version }} + path: dist/ + if-no-files-found: error + retention-days: 5 + + publish: + name: Upload release to PyPI + needs: build + runs-on: ubuntu-latest + environment: release + permissions: + id-token: write + steps: + - name: Download packages + uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + with: + pattern: packages-* + path: dist + merge-multiple: true + + - name: Print out packages + run: ls dist + + - name: Generate artifact attestation for sdist and wheel + uses: actions/attest-build-provenance@7668571508540a607bdfd90a87a560489fe372eb # v2.1.0 + with: + subject-path: dist/cycler-* + + - name: Publish package distributions to PyPI + uses: pypa/gh-action-pypi-publish@67339c736fd9354cd4f8cb0b744f2b82a74b5c70 # v1.12.3 diff --git a/.github/workflows/reviewdog.yml b/.github/workflows/reviewdog.yml new file mode 100644 index 0000000..ce95577 --- /dev/null +++ b/.github/workflows/reviewdog.yml @@ -0,0 +1,79 @@ +--- +name: Linting +on: + push: + branches-ignore: + - auto-backport-of-pr-[0-9]+ + - v[0-9]+.[0-9]+.[0-9x]+-doc + pull_request: + +jobs: + flake8: + name: flake8 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python 3 + uses: actions/setup-python@v4 + with: + python-version: 3.9 + + - name: Install flake8 + run: pip3 install flake8 + + - name: Set up reviewdog + run: | + mkdir -p "$HOME/bin" + curl -sfL \ + https://github.com/reviewdog/reviewdog/raw/master/install.sh | \ + sh -s -- -b "$HOME/bin" + echo "$HOME/bin" >> $GITHUB_PATH + + - name: Run flake8 + env: + REVIEWDOG_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -o pipefail + flake8 | \ + reviewdog -f=pep8 -name=flake8 \ + -tee -reporter=github-check -filter-mode nofilter + + mypy: + name: "Mypy" + runs-on: ubuntu-20.04 + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: 3.9 + + - name: Set up reviewdog + run: | + mkdir -p "$HOME/bin" + curl -sfL \ + https://github.com/reviewdog/reviewdog/raw/master/install.sh | \ + sh -s -- -b "$HOME/bin" + echo "$HOME/bin" >> $GITHUB_PATH + + - name: Install Python dependencies + run: | + python -m pip install --upgrade pip + python -m pip install --upgrade mypy==1.5.1 + + - name: Install cycler + run: | + python -m pip install --no-deps -e . + + - name: Run mypy + env: + REVIEWDOG_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + mypy cycler test_cycler.py | \ + reviewdog -f=mypy -name=mypy \ + -tee -reporter=github-check -filter-mode nofilter diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..98ceb87 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,45 @@ +--- + +name: Tests + +on: + push: + branches-ignore: + - auto-backport-of-pr-[0-9]+ + - v[0-9]+.[0-9]+.[0-9x]+-doc + pull_request: + +jobs: + test: + name: "Python ${{ matrix.python-version }} ${{ matrix.name-suffix }}" + runs-on: ubuntu-20.04 + + strategy: + matrix: + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + allow-prereleases: true + + - name: Upgrade pip + run: | + python -m pip install --upgrade pip + + - name: Install cycler + run: | + python -m pip install .[tests] + + - name: Run pytest + run: | + pytest -raR -n auto --cov --cov-report= + + - name: Upload code coverage + uses: codecov/codecov-action@v3 diff --git a/.gitignore b/.gitignore index be42995..20908de 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,7 @@ htmlcov/ cover/ .coverage .cache +.pytest_cache nosetests.xml coverage.xml cover/ diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index bfc5179..0000000 --- a/.travis.yml +++ /dev/null @@ -1,22 +0,0 @@ -language: python - -matrix: - include: - - python: 2.6 - - python: 2.7 - - python: 3.3 - - python: 3.4 - - python: "nightly" - env: PRE=--pre - allow_failures: - - python : "nightly" - -install: - - python setup.py install - - pip install coveralls six - -script: - - python run_tests.py - -after_success: - coveralls diff --git a/README.rst b/README.rst index cb68e97..18712c9 100644 --- a/README.rst +++ b/README.rst @@ -1,4 +1,21 @@ +|PyPi|_ |Conda|_ |Supported Python versions|_ |GitHub Actions|_ |Codecov|_ + +.. |PyPi| image:: https://img.shields.io/pypi/v/cycler.svg?style=flat +.. _PyPi: https://pypi.python.org/pypi/cycler + +.. |Conda| image:: https://img.shields.io/conda/v/conda-forge/cycler +.. _Conda: https://anaconda.org/conda-forge/cycler + +.. |Supported Python versions| image:: https://img.shields.io/pypi/pyversions/cycler.svg +.. _Supported Python versions: https://pypi.python.org/pypi/cycler + +.. |GitHub Actions| image:: https://github.com/matplotlib/cycler/actions/workflows/tests.yml/badge.svg +.. _GitHub Actions: https://github.com/matplotlib/cycler/actions + +.. |Codecov| image:: https://codecov.io/github/matplotlib/cycler/badge.svg?branch=main&service=github +.. _Codecov: https://codecov.io/github/matplotlib/cycler?branch=main + cycler: composable cycles ========================= -Docs: http://tacaswell.github.io/cycler/ +Docs: https://matplotlib.org/cycler/ diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..b134e78 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,14 @@ +# Security Policy + + +## Reporting a Vulnerability + + +To report a security vulnerability, please use the [Tidelift security +contact](https://tidelift.com/security). Tidelift will coordinate the fix and +disclosure. + +If you have found a security vulnerability, in order to keep it confidential, +please do not report an issue on GitHub. + +We do not award bounties for security vulnerabilities. diff --git a/appveyor.yml b/appveyor.yml new file mode 100644 index 0000000..57ca08c --- /dev/null +++ b/appveyor.yml @@ -0,0 +1,40 @@ +# AppVeyor.com is a Continuous Integration service to build and run tests under +# Windows + +image: Visual Studio 2019 + +environment: + matrix: + - PYTHON: "C:\\Python38" + - PYTHON: "C:\\Python38-x64" + - PYTHON: "C:\\Python39" + - PYTHON: "C:\\Python39-x64" + +install: + - "SET PATH=%PYTHON%;%PYTHON%\\Scripts;%PATH%" + + # Check that we have the expected version and architecture for Python + - "python --version" + - "python -c \"import struct; print(struct.calcsize('P') * 8)\"" + + # Install the build and runtime dependencies of the project. + - "pip install -v pytest pytest-cov pytest-xdist" + + # Install the generated wheel package to test it + - "python setup.py install" + + +# Not a .NET project, we build scikit-image in the install step instead +build: false + +test_script: + + # Run unit tests with pytest + - "python -m pytest -raR -n auto" + +artifacts: + # Archive the generated wheel package in the ci.appveyor.com build report. + - path: dist\* + +#on_success: +# - TODO: upload the content of dist/*.whl to a public wheelhouse diff --git a/cycler.py b/cycler.py deleted file mode 100644 index d0d983a..0000000 --- a/cycler.py +++ /dev/null @@ -1,309 +0,0 @@ -from __future__ import (absolute_import, division, print_function, - unicode_literals) - -import six -from itertools import product -from six.moves import zip, reduce -from operator import mul, add -import copy - -__version__ = '0.9.0' - - -def _process_keys(left, right): - """ - Helper function to compose cycler keys - - Parameters - ---------- - left, right : Cycler or None - The cyclers to be composed - Returns - ------- - keys : set - The keys in the composition of the two cyclers - """ - l_key = left.keys if left is not None else set() - r_key = right.keys if right is not None else set() - if l_key & r_key: - raise ValueError("Can not compose overlapping cycles") - return l_key | r_key - - -class Cycler(object): - """ - Composable cycles - - This class has compositions methods: - - ``+`` - for 'inner' products (zip) - - ``+=`` - in-place ``+`` - - ``*`` - for outer products (itertools.product) and integer multiplication - - ``*=`` - in-place ``*`` - - and supports basic slicing via ``[]`` - - Parameters - ---------- - left : Cycler or None - The 'left' cycler - - right : Cycler or None - The 'right' cycler - - op : func or None - Function which composes the 'left' and 'right' cyclers. - - """ - def __init__(self, left, right=None, op=None): - """Semi-private init - - Do not use this directly, use `cycler` function instead. - """ - self._keys = _process_keys(left, right) - self._left = copy.copy(left) - self._right = copy.copy(right) - self._op = op - - @property - def keys(self): - """ - The keys this Cycler knows about - """ - return set(self._keys) - - def _compose(self): - """ - Compose the 'left' and 'right' components of this cycle - with the proper operation (zip or product as of now) - """ - for a, b in self._op(self._left, self._right): - out = dict() - out.update(a) - out.update(b) - yield out - - @classmethod - def _from_iter(cls, label, itr): - """ - Class method to create 'base' Cycler objects - that do not have a 'right' or 'op' and for which - the 'left' object is not another Cycler. - - Parameters - ---------- - label : str - The property key. - - itr : iterable - Finite length iterable of the property values. - - Returns - ------- - cycler : Cycler - New 'base' `Cycler` - """ - ret = cls(None) - ret._left = list({label: v} for v in itr) - ret._keys = set([label]) - return ret - - def __getitem__(self, key): - # TODO : maybe add numpy style fancy slicing - if isinstance(key, slice): - trans = self._transpose() - return reduce(add, (cycler(k, v[key]) - for k, v in six.iteritems(trans))) - else: - raise ValueError("Can only use slices with Cycler.__getitem__") - - def __iter__(self): - if self._right is None: - return iter(self._left) - - return self._compose() - - def __add__(self, other): - """ - Pair-wise combine two equal length cycles (zip) - - Parameters - ---------- - other : Cycler - The second Cycler - """ - if len(self) != len(other): - raise ValueError("Can only add equal length cycles, " - "not {0} and {1}".format(len(self), len(other))) - return Cycler(self, other, zip) - - def __mul__(self, other): - """ - Outer product of two cycles (`itertools.product`) or integer - multiplication. - - Parameters - ---------- - other : Cycler or int - The second Cycler or integer - """ - if isinstance(other, Cycler): - return Cycler(self, other, product) - elif isinstance(other, int): - trans = self._transpose() - return reduce(add, (cycler(k, v*other) - for k, v in six.iteritems(trans))) - else: - return NotImplemented - - def __rmul__(self, other): - return self * other - - def __len__(self): - op_dict = {zip: min, product: mul} - if self._right is None: - return len(self._left) - l_len = len(self._left) - r_len = len(self._right) - return op_dict[self._op](l_len, r_len) - - def __iadd__(self, other): - """ - In-place pair-wise combine two equal length cycles (zip) - - Parameters - ---------- - other : Cycler - The second Cycler - """ - old_self = copy.copy(self) - self._keys = _process_keys(old_self, other) - self._left = old_self - self._op = zip - self._right = copy.copy(other) - return self - - def __imul__(self, other): - """ - In-place outer product of two cycles (`itertools.product`) - - Parameters - ---------- - other : Cycler - The second Cycler - """ - - old_self = copy.copy(self) - self._keys = _process_keys(old_self, other) - self._left = old_self - self._op = product - self._right = copy.copy(other) - return self - - def __repr__(self): - op_map = {zip: '+', product: '*'} - if self._right is None: - lab = self.keys.pop() - itr = list(v[lab] for v in self) - return "cycler({lab!r}, {itr!r})".format(lab=lab, itr=itr) - else: - op = op_map.get(self._op, '?') - msg = "({left!r} {op} {right!r})" - return msg.format(left=self._left, op=op, right=self._right) - - def _repr_html_(self): - # an table showing the value of each key through a full cycle - output = "" - sorted_keys = sorted(self.keys) - for key in sorted_keys: - output += "".format(key=key) - for d in iter(self): - output += "" - for k in sorted_keys: - output += "".format(val=d[k]) - output += "" - output += "
{key!r}
{val!r}
" - return output - - def _transpose(self): - """ - Internal helper function which iterates through the - styles and returns a dict of lists instead of a list of - dicts. This is needed for multiplying by integers and - for __getitem__ - - Returns - ------- - trans : dict - dict of lists for the styles - """ - - # TODO : sort out if this is a bottle neck, if there is a better way - # and if we care. - - keys = self.keys - # change this to dict comprehension when drop 2.6 - out = dict((k, list()) for k in keys) - - for d in self: - for k in keys: - out[k].append(d[k]) - return out - - def simplify(self): - """Simplify the Cycler - - Returned as a composition using only sums (no multiplications) - - Returns - ------- - simple : Cycler - An equivalent cycler using only summation""" - # TODO: sort out if it is worth the effort to make sure this is - # balanced. Currently it is is - # (((a + b) + c) + d) vs - # ((a + b) + (c + d)) - # I would believe that there is some performance implications - - trans = self._transpose() - return reduce(add, (cycler(k, v) for k, v in six.iteritems(trans))) - - -def cycler(label, itr): - """ - Create a new `Cycler` object from a property name and - iterable of values. - - Parameters - ---------- - label : str - The property key. - - itr : iterable - Finite length iterable of the property values. - - Returns - ------- - cycler : Cycler - New `Cycler` for the given property - """ - if isinstance(itr, Cycler): - keys = itr.keys - if len(keys) != 1: - msg = "Can not create Cycler from a multi-property Cycler" - raise ValueError(msg) - - if label in keys: - return copy.copy(itr) - else: - lab = keys.pop() - itr = list(v[lab] for v in itr) - - return Cycler._from_iter(label, itr) diff --git a/cycler/__init__.py b/cycler/__init__.py new file mode 100644 index 0000000..db476b8 --- /dev/null +++ b/cycler/__init__.py @@ -0,0 +1,581 @@ +""" +Cycler +====== + +Cycling through combinations of values, producing dictionaries. + +You can add cyclers:: + + from cycler import cycler + cc = (cycler(color=list('rgb')) + + cycler(linestyle=['-', '--', '-.'])) + for d in cc: + print(d) + +Results in:: + + {'color': 'r', 'linestyle': '-'} + {'color': 'g', 'linestyle': '--'} + {'color': 'b', 'linestyle': '-.'} + + +You can multiply cyclers:: + + from cycler import cycler + cc = (cycler(color=list('rgb')) * + cycler(linestyle=['-', '--', '-.'])) + for d in cc: + print(d) + +Results in:: + + {'color': 'r', 'linestyle': '-'} + {'color': 'r', 'linestyle': '--'} + {'color': 'r', 'linestyle': '-.'} + {'color': 'g', 'linestyle': '-'} + {'color': 'g', 'linestyle': '--'} + {'color': 'g', 'linestyle': '-.'} + {'color': 'b', 'linestyle': '-'} + {'color': 'b', 'linestyle': '--'} + {'color': 'b', 'linestyle': '-.'} +""" + + +from __future__ import annotations + +from collections.abc import Hashable, Iterable, Generator +import copy +from functools import reduce +from itertools import product, cycle +from operator import mul, add +# Dict, List, Union required for runtime cast calls +from typing import TypeVar, Generic, Callable, Union, Dict, List, Any, overload, cast + + +__version__ = "0.13.0.dev0" + +K = TypeVar("K", bound=Hashable) +L = TypeVar("L", bound=Hashable) +V = TypeVar("V") +U = TypeVar("U") + + +def _process_keys( + left: Cycler[K, V] | Iterable[dict[K, V]], + right: Cycler[K, V] | Iterable[dict[K, V]] | None, +) -> set[K]: + """ + Helper function to compose cycler keys. + + Parameters + ---------- + left, right : iterable of dictionaries or None + The cyclers to be composed. + + Returns + ------- + keys : set + The keys in the composition of the two cyclers. + """ + l_peek: dict[K, V] = next(iter(left)) if left != [] else {} + r_peek: dict[K, V] = next(iter(right)) if right is not None else {} + l_key: set[K] = set(l_peek.keys()) + r_key: set[K] = set(r_peek.keys()) + if common_keys := l_key & r_key: + raise ValueError( + f"Cannot compose overlapping cycles, duplicate key(s): {common_keys}" + ) + + return l_key | r_key + + +def concat(left: Cycler[K, V], right: Cycler[K, U]) -> Cycler[K, V | U]: + r""" + Concatenate `Cycler`\s, as if chained using `itertools.chain`. + + The keys must match exactly. + + Examples + -------- + >>> num = cycler('a', range(3)) + >>> let = cycler('a', 'abc') + >>> num.concat(let) + cycler('a', [0, 1, 2, 'a', 'b', 'c']) + + Returns + ------- + `Cycler` + The concatenated cycler. + """ + if left.keys != right.keys: + raise ValueError( + "Keys do not match:\n" + "\tIntersection: {both!r}\n" + "\tDisjoint: {just_one!r}".format( + both=left.keys & right.keys, just_one=left.keys ^ right.keys + ) + ) + _l = cast(Dict[K, List[Union[V, U]]], left.by_key()) + _r = cast(Dict[K, List[Union[V, U]]], right.by_key()) + return reduce(add, (_cycler(k, _l[k] + _r[k]) for k in left.keys)) + + +class Cycler(Generic[K, V]): + """ + Composable cycles. + + This class has compositions methods: + + ``+`` + for 'inner' products (zip) + + ``+=`` + in-place ``+`` + + ``*`` + for outer products (`itertools.product`) and integer multiplication + + ``*=`` + in-place ``*`` + + and supports basic slicing via ``[]``. + + Parameters + ---------- + left, right : Cycler or None + The 'left' and 'right' cyclers. + op : func or None + Function which composes the 'left' and 'right' cyclers. + """ + + def __call__(self): + return cycle(self) + + def __init__( + self, + left: Cycler[K, V] | Iterable[dict[K, V]] | None, + right: Cycler[K, V] | None = None, + op: Any = None, + ): + """ + Semi-private init. + + Do not use this directly, use `cycler` function instead. + """ + if isinstance(left, Cycler): + self._left: Cycler[K, V] | list[dict[K, V]] = Cycler( + left._left, left._right, left._op + ) + elif left is not None: + # Need to copy the dictionary or else that will be a residual + # mutable that could lead to strange errors + self._left = [copy.copy(v) for v in left] + else: + self._left = [] + + if isinstance(right, Cycler): + self._right: Cycler[K, V] | None = Cycler( + right._left, right._right, right._op + ) + else: + self._right = None + + self._keys: set[K] = _process_keys(self._left, self._right) + self._op: Any = op + + def __contains__(self, k): + return k in self._keys + + @property + def keys(self) -> set[K]: + """The keys this Cycler knows about.""" + return set(self._keys) + + def change_key(self, old: K, new: K) -> None: + """ + Change a key in this cycler to a new name. + Modification is performed in-place. + + Does nothing if the old key is the same as the new key. + Raises a ValueError if the new key is already a key. + Raises a KeyError if the old key isn't a key. + """ + if old == new: + return + if new in self._keys: + raise ValueError( + f"Can't replace {old} with {new}, {new} is already a key" + ) + if old not in self._keys: + raise KeyError( + f"Can't replace {old} with {new}, {old} is not a key" + ) + + self._keys.remove(old) + self._keys.add(new) + + if self._right is not None and old in self._right.keys: + self._right.change_key(old, new) + + # self._left should always be non-None + # if self._keys is non-empty. + elif isinstance(self._left, Cycler): + self._left.change_key(old, new) + else: + # It should be completely safe at this point to + # assume that the old key can be found in each + # iteration. + self._left = [{new: entry[old]} for entry in self._left] + + @classmethod + def _from_iter(cls, label: K, itr: Iterable[V]) -> Cycler[K, V]: + """ + Class method to create 'base' Cycler objects + that do not have a 'right' or 'op' and for which + the 'left' object is not another Cycler. + + Parameters + ---------- + label : hashable + The property key. + + itr : iterable + Finite length iterable of the property values. + + Returns + ------- + `Cycler` + New 'base' cycler. + """ + ret: Cycler[K, V] = cls(None) + ret._left = list({label: v} for v in itr) + ret._keys = {label} + return ret + + def __getitem__(self, key: slice) -> Cycler[K, V]: + # TODO : maybe add numpy style fancy slicing + if isinstance(key, slice): + trans = self.by_key() + return reduce(add, (_cycler(k, v[key]) for k, v in trans.items())) + else: + raise ValueError("Can only use slices with Cycler.__getitem__") + + def __iter__(self) -> Generator[dict[K, V], None, None]: + if self._right is None: + for left in self._left: + yield dict(left) + else: + if self._op is None: + raise TypeError( + "Operation cannot be None when both left and right are defined" + ) + for a, b in self._op(self._left, self._right): + out = {} + out.update(a) + out.update(b) + yield out + + def __add__(self, other: Cycler[L, U]) -> Cycler[K | L, V | U]: + """ + Pair-wise combine two equal length cyclers (zip). + + Parameters + ---------- + other : Cycler + """ + if len(self) != len(other): + raise ValueError( + f"Can only add equal length cycles, not {len(self)} and {len(other)}" + ) + return Cycler( + cast(Cycler[Union[K, L], Union[V, U]], self), + cast(Cycler[Union[K, L], Union[V, U]], other), + zip + ) + + @overload + def __mul__(self, other: Cycler[L, U]) -> Cycler[K | L, V | U]: + ... + + @overload + def __mul__(self, other: int) -> Cycler[K, V]: + ... + + def __mul__(self, other): + """ + Outer product of two cyclers (`itertools.product`) or integer + multiplication. + + Parameters + ---------- + other : Cycler or int + """ + if isinstance(other, Cycler): + return Cycler( + cast(Cycler[Union[K, L], Union[V, U]], self), + cast(Cycler[Union[K, L], Union[V, U]], other), + product + ) + elif isinstance(other, int): + trans = self.by_key() + return reduce( + add, (_cycler(k, v * other) for k, v in trans.items()) + ) + else: + return NotImplemented + + @overload + def __rmul__(self, other: Cycler[L, U]) -> Cycler[K | L, V | U]: + ... + + @overload + def __rmul__(self, other: int) -> Cycler[K, V]: + ... + + def __rmul__(self, other): + return self * other + + def __len__(self) -> int: + op_dict: dict[Callable, Callable[[int, int], int]] = {zip: min, product: mul} + if self._right is None: + return len(self._left) + l_len = len(self._left) + r_len = len(self._right) + return op_dict[self._op](l_len, r_len) + + # iadd and imul do not exapand the the type as the returns must be consistent with + # self, thus they flag as inconsistent with add/mul + def __iadd__(self, other: Cycler[K, V]) -> Cycler[K, V]: # type: ignore[misc] + """ + In-place pair-wise combine two equal length cyclers (zip). + + Parameters + ---------- + other : Cycler + """ + if not isinstance(other, Cycler): + raise TypeError("Cannot += with a non-Cycler object") + if len(self) != len(other): + raise ValueError( + f"Can only add equal length cycles, not {len(self)} and {len(other)}" + ) + # True shallow copy of self is fine since this is in-place + old_self = copy.copy(self) + self._keys = _process_keys(old_self, other) + self._left = old_self + self._op = zip + self._right = Cycler(other._left, other._right, other._op) + return self + + def __imul__(self, other: Cycler[K, V] | int) -> Cycler[K, V]: # type: ignore[misc] + """ + In-place outer product of two cyclers (`itertools.product`). + + Parameters + ---------- + other : Cycler + """ + if not isinstance(other, Cycler): + raise TypeError("Cannot *= with a non-Cycler object") + # True shallow copy of self is fine since this is in-place + old_self = copy.copy(self) + self._keys = _process_keys(old_self, other) + self._left = old_self + self._op = product + self._right = Cycler(other._left, other._right, other._op) + return self + + def __eq__(self, other: object) -> bool: + if not isinstance(other, Cycler): + return False + if len(self) != len(other): + return False + if self.keys ^ other.keys: + return False + return all(a == b for a, b in zip(self, other)) + + __hash__ = None # type: ignore + + def __repr__(self) -> str: + op_map = {zip: "+", product: "*"} + if self._right is None: + lab = self.keys.pop() + itr = list(v[lab] for v in self) + return f"cycler({lab!r}, {itr!r})" + else: + op = op_map.get(self._op, "?") + msg = "({left!r} {op} {right!r})" + return msg.format(left=self._left, op=op, right=self._right) + + def _repr_html_(self) -> str: + # an table showing the value of each key through a full cycle + output = "" + sorted_keys = sorted(self.keys, key=repr) + for key in sorted_keys: + output += f"" + for d in iter(self): + output += "" + for k in sorted_keys: + output += f"" + output += "" + output += "
{key!r}
{d[k]!r}
" + return output + + def by_key(self) -> dict[K, list[V]]: + """ + Values by key. + + This returns the transposed values of the cycler. Iterating + over a `Cycler` yields dicts with a single value for each key, + this method returns a `dict` of `list` which are the values + for the given key. + + The returned value can be used to create an equivalent `Cycler` + using only `+`. + + Returns + ------- + transpose : dict + dict of lists of the values for each key. + """ + + # TODO : sort out if this is a bottle neck, if there is a better way + # and if we care. + + keys = self.keys + out: dict[K, list[V]] = {k: list() for k in keys} + + for d in self: + for k in keys: + out[k].append(d[k]) + return out + + # for back compatibility + _transpose = by_key + + def simplify(self) -> Cycler[K, V]: + """ + Simplify the cycler into a sum (but no products) of cyclers. + + Returns + ------- + simple : Cycler + """ + # TODO: sort out if it is worth the effort to make sure this is + # balanced. Currently it is is + # (((a + b) + c) + d) vs + # ((a + b) + (c + d)) + # I would believe that there is some performance implications + trans = self.by_key() + return reduce(add, (_cycler(k, v) for k, v in trans.items())) + + concat = concat + + +@overload +def cycler(arg: Cycler[K, V]) -> Cycler[K, V]: + ... + + +@overload +def cycler(**kwargs: Iterable[V]) -> Cycler[str, V]: + ... + + +@overload +def cycler(label: K, itr: Iterable[V]) -> Cycler[K, V]: + ... + + +def cycler(*args, **kwargs): + """ + Create a new `Cycler` object from a single positional argument, + a pair of positional arguments, or the combination of keyword arguments. + + cycler(arg) + cycler(label1=itr1[, label2=iter2[, ...]]) + cycler(label, itr) + + Form 1 simply copies a given `Cycler` object. + + Form 2 composes a `Cycler` as an inner product of the + pairs of keyword arguments. In other words, all of the + iterables are cycled simultaneously, as if through zip(). + + Form 3 creates a `Cycler` from a label and an iterable. + This is useful for when the label cannot be a keyword argument + (e.g., an integer or a name that has a space in it). + + Parameters + ---------- + arg : Cycler + Copy constructor for Cycler (does a shallow copy of iterables). + label : name + The property key. In the 2-arg form of the function, + the label can be any hashable object. In the keyword argument + form of the function, it must be a valid python identifier. + itr : iterable + Finite length iterable of the property values. + Can be a single-property `Cycler` that would + be like a key change, but as a shallow copy. + + Returns + ------- + cycler : Cycler + New `Cycler` for the given property + + """ + if args and kwargs: + raise TypeError( + "cycler() can only accept positional OR keyword arguments -- not both." + ) + + if len(args) == 1: + if not isinstance(args[0], Cycler): + raise TypeError( + "If only one positional argument given, it must " + "be a Cycler instance." + ) + return Cycler(args[0]) + elif len(args) == 2: + return _cycler(*args) + elif len(args) > 2: + raise TypeError( + "Only a single Cycler can be accepted as the lone " + "positional argument. Use keyword arguments instead." + ) + + if kwargs: + return reduce(add, (_cycler(k, v) for k, v in kwargs.items())) + + raise TypeError("Must have at least a positional OR keyword arguments") + + +def _cycler(label: K, itr: Iterable[V]) -> Cycler[K, V]: + """ + Create a new `Cycler` object from a property name and iterable of values. + + Parameters + ---------- + label : hashable + The property key. + itr : iterable + Finite length iterable of the property values. + + Returns + ------- + cycler : Cycler + New `Cycler` for the given property + """ + if isinstance(itr, Cycler): + keys = itr.keys + if len(keys) != 1: + msg = "Can not create Cycler from a multi-property Cycler" + raise ValueError(msg) + + lab = keys.pop() + # Doesn't need to be a new list because + # _from_iter() will be creating that new list anyway. + itr = (v[lab] for v in itr) + + return Cycler._from_iter(label, itr) diff --git a/cycler/py.typed b/cycler/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/doc/source/conf.py b/doc/source/conf.py index 2685527..906a782 100644 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -1,5 +1,4 @@ #!/usr/bin/env python3 -# -*- coding: utf-8 -*- # # cycler documentation build configuration file, created by # sphinx-quickstart on Wed Jul 1 13:32:53 2015. @@ -13,13 +12,10 @@ # All configuration values have a default; values that are commented out # serve to show the default. -import sys -import os - # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. -#sys.path.insert(0, os.path.abspath('.')) +# sys.path.insert(0, os.path.abspath('.')) # -- General configuration ------------------------------------------------ @@ -36,7 +32,6 @@ 'sphinx.ext.mathjax', 'sphinx.ext.viewcode', 'sphinx.ext.autosummary', - 'matplotlib.sphinxext.only_directives', 'matplotlib.sphinxext.plot_directive', 'IPython.sphinxext.ipython_directive', 'IPython.sphinxext.ipython_console_highlighting', @@ -47,7 +42,7 @@ autosummary_generate = True numpydoc_show_class_members = False -autodoc_default_flags = ['members'] +autodoc_default_options = {'members': True} # Add any paths that contain templates here, relative to this directory. @@ -57,7 +52,7 @@ source_suffix = '.rst' # The encoding of source files. -#source_encoding = 'utf-8-sig' +# source_encoding = 'utf-8-sig' # The master toctree document. master_doc = 'index' @@ -70,20 +65,20 @@ # |version| and |release|, also used in various other places throughout the # built documents. # -# The short X.Y version. -version = '0.9.0' # The full version, including alpha/beta/rc tags. -release = '0.9.0' +from cycler import __version__ as release # noqa +# The short X.Y version. +version = '.'.join(release.split('.')[:2]) # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. -#language = None +# language = None # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: -#today = '' +# today = '' # Else, today_fmt is used as the format for a strftime call. -#today_fmt = '%B %d, %Y' +# today_fmt = '%B %d, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. @@ -94,24 +89,24 @@ default_role = 'obj' # If true, '()' will be appended to :func: etc. cross-reference text. -#add_function_parentheses = True +# add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). -#add_module_names = True +# add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. -#show_authors = False +# show_authors = False # The name of the Pygments (syntax highlighting) style to use. pygments_style = 'sphinx' # A list of ignored prefixes for module index sorting. -#modindex_common_prefix = [] +# modindex_common_prefix = [] # If true, keep warnings as "system message" paragraphs in the built documents. -#keep_warnings = False +# keep_warnings = False # -- Options for HTML output ---------------------------------------------- @@ -123,77 +118,77 @@ # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. -#html_theme_options = {} +# html_theme_options = {} # Add any paths that contain custom themes here, relative to this directory. -#html_theme_path = [] +# html_theme_path = [] # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". -#html_title = None +# html_title = None # A shorter title for the navigation bar. Default is the same as html_title. -#html_short_title = None +# html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. -#html_logo = None +# html_logo = None # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. -#html_favicon = None +# html_favicon = None # 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_static_path = [] # Add any extra paths that contain custom files (such as robots.txt or # .htaccess) here, relative to this directory. These files are copied # directly to the root of the documentation. -#html_extra_path = [] +# html_extra_path = [] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. -#html_last_updated_fmt = '%b %d, %Y' +# html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. -#html_use_smartypants = True +# html_use_smartypants = True # Custom sidebar templates, maps document names to template names. -#html_sidebars = {} +# html_sidebars = {} # Additional templates that should be rendered to pages, maps page names to # template names. -#html_additional_pages = {} +# html_additional_pages = {} # If false, no module index is generated. -#html_domain_indices = True +# html_domain_indices = True # If false, no index is generated. -#html_use_index = True +# html_use_index = True # If true, the index is split into individual pages for each letter. -#html_split_index = False +# html_split_index = False # If true, links to the reST sources are added to the pages. -#html_show_sourcelink = True +# html_show_sourcelink = True # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. -#html_show_sphinx = True +# html_show_sphinx = True # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. -#html_show_copyright = True +# html_show_copyright = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. -#html_use_opensearch = '' +# html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). -#html_file_suffix = None +# html_file_suffix = None # Output file base name for HTML help builder. htmlhelp_basename = 'cyclerdoc' @@ -202,14 +197,14 @@ # -- Options for LaTeX output --------------------------------------------- latex_elements = { -# The paper size ('letterpaper' or 'a4paper'). -#'papersize': 'letterpaper', + # The paper size ('letterpaper' or 'a4paper'). + # 'papersize': 'letterpaper', -# The font size ('10pt', '11pt' or '12pt'). -#'pointsize': '10pt', + # The font size ('10pt', '11pt' or '12pt'). + # 'pointsize': '10pt', -# Additional stuff for the LaTeX preamble. -#'preamble': '', + # Additional stuff for the LaTeX preamble. + # 'preamble': '', } # Grouping the document tree into LaTeX files. List of tuples @@ -222,23 +217,23 @@ # The name of an image file (relative to this directory) to place at the top of # the title page. -#latex_logo = None +# latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. -#latex_use_parts = False +# latex_use_parts = False # If true, show page references after internal links. -#latex_show_pagerefs = False +# latex_show_pagerefs = False # If true, show URL addresses after external links. -#latex_show_urls = False +# latex_show_urls = False # Documents to append as an appendix to all manuals. -#latex_appendices = [] +# latex_appendices = [] # If false, no module index is generated. -#latex_domain_indices = True +# latex_domain_indices = True # -- Options for manual page output --------------------------------------- @@ -251,7 +246,7 @@ ] # If true, show URL addresses after external links. -#man_show_urls = False +# man_show_urls = False # -- Options for Texinfo output ------------------------------------------- @@ -266,19 +261,19 @@ ] # Documents to append as an appendix to all manuals. -#texinfo_appendices = [] +# texinfo_appendices = [] # If false, no module index is generated. -#texinfo_domain_indices = True +# texinfo_domain_indices = True # How to display URL addresses: 'footnote', 'no', or 'inline'. -#texinfo_show_urls = 'footnote' +# texinfo_show_urls = 'footnote' # If true, do not generate a @detailmenu in the "Top" node's menu. -#texinfo_no_detailmenu = False +# texinfo_no_detailmenu = False -intersphinx_mapping = {'python': ('https://docs.python.org/3.4', None), - 'matplotlb': ('http://matplotlib.org', None)} +intersphinx_mapping = {'python': ('https://docs.python.org/3', None), + 'matplotlb': ('https://matplotlib.org', None)} -################# numpydoc config #################### +# ################ numpydoc config #################### numpydoc_show_class_members = False diff --git a/doc/source/index.rst b/doc/source/index.rst index 79f6808..126de4b 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -1,14 +1,22 @@ .. currentmodule:: cycler -==================== - Style/kwarg cycler -==================== +=================== + Composable cycles +=================== -.. htmlonly:: +.. only:: html - :Release: |version| + :Version: |version| :Date: |today| + +====== ==================================== +Docs https://matplotlib.org/cycler +PyPI https://pypi.python.org/pypi/Cycler +GitHub https://github.com/matplotlib/cycler +====== ==================================== + + :py:mod:`cycler` API ==================== @@ -17,12 +25,13 @@ cycler Cycler + concat - -The public API of :py:mod:`cycler` consists of a class `Cycler` and a -factory function :func:`cycler`. The function provides a simple interface for -creating 'base' `Cycler` objects while the class takes care of the composition -and iteration logic. +The public API of :py:mod:`cycler` consists of a class `Cycler`, a +factory function :func:`cycler`, and a concatenation function +:func:`concat`. The factory function provides a simple interface for +creating 'base' `Cycler` objects while the class takes care of the +composition and iteration logic. `Cycler` Usage @@ -31,21 +40,20 @@ and iteration logic. Base ---- -A single entry `Cycler` object can be used to easily -cycle over a single style. To create the `Cycler` use the :py:func:`cycler` -function to link a key/style/kwarg to series of values. The key must be -hashable (as it will eventually be used as the key in a :obj:`dict`). +A single entry `Cycler` object can be used to easily cycle over a single style. +To create the `Cycler` use the :py:func:`cycler` function to link a +key/style/keyword argument to series of values. The key must be hashable (as it +will eventually be used as the key in a :obj:`dict`). .. ipython:: python from __future__ import print_function from cycler import cycler - - color_cycle = cycler('color', ['r', 'g', 'b']) + color_cycle = cycler(color=['r', 'g', 'b']) color_cycle -The `Cycler` knows it's length and keys: +The `Cycler` knows its length and keys: .. ipython:: python @@ -61,12 +69,22 @@ the label for v in color_cycle: print(v) -`Cycler` objects can be passed as the second argument to :func:`cycler` +`Cycler` objects can be passed as the argument to :func:`cycler` which returns a new `Cycler` with a new label, but the same values. .. ipython:: python - cycler('ec', color_cycle) + cycler(ec=color_cycle) + + +Iterating over a `Cycler` results in the finite list of entries, to +get an infinite cycle, call the `Cycler` object (a-la a generator) + +.. ipython:: python + + cc = color_cycle() + for j, c in zip(range(5), cc): + print(j, c) Composition @@ -79,17 +97,17 @@ create complex multi-key cycles. Addition ~~~~~~~~ -Equal length `Cycler` s with different keys can be added to get the +Equal length `Cycler`\s with different keys can be added to get the 'inner' product of two cycles .. ipython:: python - lw_cycle = cycler('lw', range(1, 4)) + lw_cycle = cycler(lw=range(1, 4)) wc = lw_cycle + color_cycle The result has the same length and has keys which are the union of the -two input `Cycler` s. +two input `Cycler`'s. .. ipython:: python @@ -113,6 +131,17 @@ As with arithmetic, addition is commutative for j, (a, b) in enumerate(zip(lw_c, c_lw)): print('({j}) A: {A!r} B: {B!r}'.format(j=j, A=a, B=b)) +For convenience, the :func:`cycler` function can have multiple +key-value pairs and will automatically compose them into a single +`Cycler` via addition + +.. ipython:: python + + wc = cycler(c=['r', 'g', 'b'], lw=range(3)) + + for s in wc: + print(s) + Multiplication ~~~~~~~~~~~~~~ @@ -121,12 +150,12 @@ Any pair of `Cycler` can be multiplied .. ipython:: python - m_cycle = cycler('marker', ['s', 'o']) + m_cycle = cycler(marker=['s', 'o']) m_c = m_cycle * color_cycle which gives the 'outer product' of the two cycles (same as -:func:`itertools.prod` ) +:func:`itertools.product` ) .. ipython:: python @@ -151,7 +180,7 @@ matrices) Integer Multiplication ~~~~~~~~~~~~~~~~~~~~~~ -`Cycler` s can also be multiplied by integer values to increase the length. +`Cycler`\s can also be multiplied by integer values to increase the length. .. ipython:: python @@ -159,6 +188,22 @@ Integer Multiplication 2 * color_cycle +Concatenation +~~~~~~~~~~~~~ + +`Cycler` objects can be concatenated either via the :py:meth:`Cycler.concat` method + +.. ipython:: python + + color_cycle.concat(color_cycle) + +or the top-level :py:func:`concat` function + +.. ipython:: python + + from cycler import concat + concat(color_cycle, color_cycle) + Slicing ------- @@ -173,6 +218,26 @@ Cycles can be sliced with :obj:`slice` objects to return a sub-set of the cycle as a new `Cycler`. +Inspecting the `Cycler` +----------------------- + +To inspect the values of the transposed `Cycler` use +the `Cycler.by_key` method: + +.. ipython:: python + + c_m.by_key() + +This `dict` can be mutated and used to create a new `Cycler` with +the updated values + +.. ipython:: python + + bk = c_m.by_key() + bk['color'] = ['green'] * len(c_m) + cycler(**bk) + + Examples -------- @@ -189,7 +254,7 @@ We can use `Cycler` instances to cycle over one or more ``kwarg`` to figsize=(8, 4)) x = np.arange(10) - color_cycle = cycler('c', ['r', 'g', 'b']) + color_cycle = cycler(c=['r', 'g', 'b']) for i, sty in enumerate(color_cycle): ax1.plot(x, x*(i+1), **sty) @@ -209,7 +274,7 @@ We can use `Cycler` instances to cycle over one or more ``kwarg`` to figsize=(8, 4)) x = np.arange(10) - color_cycle = cycler('c', ['r', 'g', 'b']) + color_cycle = cycler(c=['r', 'g', 'b']) ls_cycle = cycler('ls', ['-', '--']) lw_cycle = cycler('lw', range(1, 4)) @@ -224,23 +289,61 @@ We can use `Cycler` instances to cycle over one or more ``kwarg`` to ax2.plot(x, x*(i+1), **sty) +Persistent Cycles +----------------- + +It can be useful to associate a given label with a style via +dictionary lookup and to dynamically generate that mapping. This +can easily be accomplished using a `~collections.defaultdict` + +.. ipython:: python + + from cycler import cycler as cy + from collections import defaultdict + + cyl = cy('c', 'rgb') + cy('lw', range(1, 4)) + +To get a finite set of styles + +.. ipython:: python + + finite_cy_iter = iter(cyl) + dd_finite = defaultdict(lambda : next(finite_cy_iter)) + +or repeating + +.. ipython:: python + + loop_cy_iter = cyl() + dd_loop = defaultdict(lambda : next(loop_cy_iter)) + +This can be helpful when plotting complex data which has both a classification +and a label :: + + finite_cy_iter = iter(cyl) + styles = defaultdict(lambda : next(finite_cy_iter)) + for group, label, data in DataSet: + ax.plot(data, label=label, **styles[group]) + +which will result in every ``data`` with the same ``group`` being plotted with +the same style. + Exceptions ---------- - -A :obj:`ValueError` is raised if unequal length `Cycler` s are added together +A :obj:`ValueError` is raised if unequal length `Cycler`\s are added together .. ipython:: python :okexcept: - cycler('c', ['r', 'g', 'b']) + cycler('ls', ['-', '--']) + cycler(c=['r', 'g', 'b']) + cycler(ls=['-', '--']) or if two cycles which have overlapping keys are composed .. ipython:: python :okexcept: - color_cycle = cycler('c', ['r', 'g', 'b']) + color_cycle = cycler(c=['r', 'g', 'b']) color_cycle + color_cycle @@ -297,6 +400,6 @@ However, if you want to do something more complicated: ax.legend(loc=0) -the plotting logic can quickly become very involved. To address this and allow easy -cycling over arbitrary ``kwargs`` the `Cycler` class, a composable -kwarg iterator, was developed. +the plotting logic can quickly become very involved. To address this and allow +easy cycling over arbitrary ``kwargs`` the `Cycler` class, a composable keyword +argument iterator, was developed. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c7c79cb --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,52 @@ +[project] +name = "cycler" +dynamic = ["version"] +description = "Composable style cycles" +authors = [ + {name = "Thomas A Caswell", email = "matplotlib-users@python.org"}, +] +readme = "README.rst" +license = {file = "LICENSE"} +requires-python = ">=3.8" +classifiers = [ + "License :: OSI Approved :: BSD License", + "Development Status :: 4 - Beta", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3 :: Only", +] +keywords = ["cycle kwargs"] + +[project.urls] +homepage = "https://matplotlib.org/cycler/" +repository = "https://github.com/matplotlib/cycler" + +[project.optional-dependencies] +docs = [ + "ipython", + "matplotlib", + "numpydoc", + "sphinx", +] +tests = [ + "pytest", + "pytest-cov", + "pytest-xdist", +] + +[tool.setuptools] +packages = ["cycler"] + +[tool.setuptools.dynamic] +version = {attr = "cycler.__version__"} + +[tool.setuptools.package-data] +cycler = ["py.typed"] + +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" diff --git a/run_tests.py b/run_tests.py deleted file mode 100644 index c47d8c7..0000000 --- a/run_tests.py +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env python -# This file is closely based on tests.py from matplotlib -# -# This allows running the matplotlib tests from the command line: e.g. -# -# $ python run_tests.py -v -d -# -# The arguments are identical to the arguments accepted by nosetests. -# -# See https://nose.readthedocs.org/ for a detailed description of -# these options. -import nose - - -env = {"NOSE_WITH_COVERAGE": 1, - 'NOSE_COVER_PACKAGE': ['cycler'], - 'NOSE_COVER_HTML': 1} -plugins = [] - - -def run(): - - nose.main(addplugins=[x() for x in plugins], env=env) - - -if __name__ == '__main__': - run() diff --git a/setup.py b/setup.py deleted file mode 100644 index 19db1b3..0000000 --- a/setup.py +++ /dev/null @@ -1,22 +0,0 @@ -from setuptools import setup - -setup(name='cycler', - version='0.9.0', - author='Thomas A Caswell', - py_modules=['cycler'], - description='Composable style cycles', - url='http://github.com/matplotlib/cycler', - platforms='Cross platform (Linux, Mac OSX, Windows)', - install_requires=['six'], - license="BSD", - classifiers=['Development Status :: 4 - Beta', - 'Programming Language :: Python :: 2', - 'Programming Language :: Python :: 2.6', - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.3', - 'Programming Language :: Python :: 3.4', - 'Programming Language :: Python :: 3.5', - ], - keywords='cycle kwargs', - ) diff --git a/test_cycler.py b/test_cycler.py index 032c417..1eccac3 100644 --- a/test_cycler.py +++ b/test_cycler.py @@ -1,150 +1,364 @@ -from __future__ import (absolute_import, division, print_function) - -import six -from six.moves import zip, range -from cycler import cycler, Cycler -from nose.tools import assert_equal, assert_raises -from itertools import product +from collections import defaultdict from operator import add, iadd, mul, imul +from itertools import product, cycle, chain +import pytest # type: ignore + +from cycler import cycler, Cycler, concat -def _cycler_helper(c, length, keys, values): - assert_equal(len(c), length) - assert_equal(len(c), len(list(c))) - assert_equal(c.keys, set(keys)) +def _cycler_helper(c, length, keys, values): + assert len(c) == length + assert len(c) == len(list(c)) + assert c.keys == set(keys) for k, vals in zip(keys, values): for v, v_target in zip(c, vals): - assert_equal(v[k], v_target) + assert v[k] == v_target def _cycles_equal(c1, c2): - assert_equal(list(c1), list(c2)) + assert list(c1) == list(c2) + assert c1 == c2 -def test_creation(): - c = cycler('c', 'rgb') - yield _cycler_helper, c, 3, ['c'], [['r', 'g', 'b']] - c = cycler('c', list('rgb')) - yield _cycler_helper, c, 3, ['c'], [['r', 'g', 'b']] +@pytest.mark.parametrize('c', [cycler(c='rgb'), + cycler(c=list('rgb')), + cycler(cycler(c='rgb'))], + ids=['from string', + 'from list', + 'from cycler']) +def test_creation(c): + _cycler_helper(c, 3, ['c'], [['r', 'g', 'b']]) -def test_compose(): - c1 = cycler('c', 'rgb') - c2 = cycler('lw', range(3)) - c3 = cycler('lw', range(15)) +def test_add(): + c1 = cycler(c='rgb') + c2 = cycler(lw=range(3)) # addition - yield _cycler_helper, c1+c2, 3, ['c', 'lw'], [list('rgb'), range(3)] - yield _cycler_helper, c2+c1, 3, ['c', 'lw'], [list('rgb'), range(3)] - yield _cycles_equal, c2+c1, c1+c2 - # miss-matched add lengths - assert_raises(ValueError, add, c1, c3) - assert_raises(ValueError, add, c3, c1) + _cycler_helper(c1 + c2, 3, ['c', 'lw'], [list('rgb'), range(3)]) + _cycler_helper(c2 + c1, 3, ['c', 'lw'], [list('rgb'), range(3)]) + _cycles_equal(c2 + c1, c1 + c2) + +def test_add_len_mismatch(): + # miss-matched add lengths + c1 = cycler(c='rgb') + c3 = cycler(lw=range(15)) + with pytest.raises(ValueError): + c1 + c3 + with pytest.raises(ValueError): + c3 + c1 + + +def test_prod(): + c1 = cycler(c='rgb') + c2 = cycler(lw=range(3)) + c3 = cycler(lw=range(15)) # multiplication target = zip(*product(list('rgb'), range(3))) - yield (_cycler_helper, c1 * c2, 9, ['c', 'lw'], target) + _cycler_helper(c1 * c2, 9, ['c', 'lw'], target) target = zip(*product(range(3), list('rgb'))) - yield (_cycler_helper, c2 * c1, 9, ['lw', 'c'], target) + _cycler_helper(c2 * c1, 9, ['lw', 'c'], target) target = zip(*product(range(15), list('rgb'))) - yield (_cycler_helper, c3 * c1, 45, ['lw', 'c'], target) + _cycler_helper(c3 * c1, 45, ['lw', 'c'], target) -def test_inplace(): - c1 = cycler('c', 'rgb') - c2 = cycler('lw', range(3)) +def test_inplace_add(): + c1 = cycler(c='rgb') + c2 = cycler(lw=range(3)) c2 += c1 - yield _cycler_helper, c2, 3, ['c', 'lw'], [list('rgb'), range(3)] + _cycler_helper(c2, 3, ['c', 'lw'], [list('rgb'), range(3)]) + + +def test_inplace_add_len_mismatch(): + # miss-matched add lengths + c1 = cycler(c='rgb') + c3 = cycler(lw=range(15)) + with pytest.raises(ValueError): + c1 += c3 - c3 = cycler('c', 'rgb') - c4 = cycler('lw', range(3)) + +def test_inplace_mul(): + c3 = cycler(c='rgb') + c4 = cycler(lw=range(3)) c3 *= c4 target = zip(*product(list('rgb'), range(3))) - yield (_cycler_helper, c3, 9, ['c', 'lw'], target) + _cycler_helper(c3, 9, ['c', 'lw'], target) def test_constructor(): - c1 = cycler('c', 'rgb') - c2 = cycler('ec', c1) - yield _cycler_helper, c1+c2, 3, ['c', 'ec'], [['r', 'g', 'b']]*2 - c3 = cycler('c', c1) - yield _cycler_helper, c3+c2, 3, ['c', 'ec'], [['r', 'g', 'b']]*2 + c1 = cycler(c='rgb') + c2 = cycler(ec=c1) + _cycler_helper(c1 + c2, 3, ['c', 'ec'], [['r', 'g', 'b']] * 2) + c3 = cycler(c=c1) + _cycler_helper(c3 + c2, 3, ['c', 'ec'], [['r', 'g', 'b']] * 2) + # Using a non-string hashable + c4 = cycler(1, range(3)) + _cycler_helper(c4 + c1, 3, [1, 'c'], [range(3), ['r', 'g', 'b']]) + + # addition using cycler() + _cycler_helper(cycler(c='rgb', lw=range(3)), + 3, ['c', 'lw'], [list('rgb'), range(3)]) + _cycler_helper(cycler(lw=range(3), c='rgb'), + 3, ['c', 'lw'], [list('rgb'), range(3)]) + # Purposely mixing them + _cycler_helper(cycler(c=range(3), lw=c1), + 3, ['c', 'lw'], [range(3), list('rgb')]) def test_failures(): - c1 = cycler('c', 'rgb') - c2 = cycler('c', c1) - assert_raises(ValueError, add, c1, c2) - assert_raises(ValueError, iadd, c1, c2) - assert_raises(ValueError, mul, c1, c2) - assert_raises(ValueError, imul, c1, c2) + c1 = cycler(c='rgb') + c2 = cycler(c=c1) + pytest.raises(ValueError, add, c1, c2) + pytest.raises(ValueError, iadd, c1, c2) + pytest.raises(ValueError, mul, c1, c2) + pytest.raises(ValueError, imul, c1, c2) + pytest.raises(TypeError, iadd, c2, 'aardvark') + pytest.raises(TypeError, imul, c2, 'aardvark') - c3 = cycler('ec', c1) + c3 = cycler(ec=c1) - assert_raises(ValueError, cycler, 'c', c2 + c3) + pytest.raises(ValueError, cycler, c=c2 + c3) def test_simplify(): - c1 = cycler('c', 'rgb') - c2 = cycler('ec', c1) + c1 = cycler(c='rgb') + c2 = cycler(ec=c1) for c in [c1 * c2, c2 * c1, c1 + c2]: - yield _cycles_equal, c, c.simplify() + _cycles_equal(c, c.simplify()) def test_multiply(): - c1 = cycler('c', 'rgb') - yield _cycler_helper, 2*c1, 6, ['c'], ['rgb'*2] + c1 = cycler(c='rgb') + _cycler_helper(2 * c1, 6, ['c'], ['rgb' * 2]) - c2 = cycler('ec', c1) + c2 = cycler(ec=c1) c3 = c1 * c2 - yield _cycles_equal, 2*c3, c3*2 + _cycles_equal(2 * c3, c3 * 2) def test_mul_fails(): - c1 = cycler('c', 'rgb') - assert_raises(TypeError, mul, c1, 2.0) - assert_raises(TypeError, mul, c1, 'a') - assert_raises(TypeError, mul, c1, []) + c1 = cycler(c='rgb') + pytest.raises(TypeError, mul, c1, 2.0) + pytest.raises(TypeError, mul, c1, 'a') + pytest.raises(TypeError, mul, c1, []) def test_getitem(): - c1 = cycler('lw', range(15)) + c1 = cycler(3, range(15)) widths = list(range(15)) for slc in (slice(None, None, None), slice(None, None, -1), slice(1, 5, None), slice(0, 5, 2)): - yield _cycles_equal, c1[slc], cycler('lw', widths[slc]) + _cycles_equal(c1[slc], cycler(3, widths[slc])) def test_fail_getime(): - c1 = cycler('lw', range(15)) - assert_raises(ValueError, Cycler.__getitem__, c1, 0) - assert_raises(ValueError, Cycler.__getitem__, c1, [0, 1]) + c1 = cycler(lw=range(15)) + pytest.raises(ValueError, Cycler.__getitem__, c1, 0) + pytest.raises(ValueError, Cycler.__getitem__, c1, [0, 1]) -def _repr_tester_helper(rpr_func, cyc, target_repr): - test_repr = getattr(cyc, rpr_func)() +def test_repr(): + c = cycler(c='rgb') + # Using an identifier that would be not valid as a kwarg + c2 = cycler('3rd', range(3)) + + assert repr(c + c2) == ( + "(cycler('c', ['r', 'g', 'b']) + cycler('3rd', [0, 1, 2]))") + assert repr(c * c2) == ( + "(cycler('c', ['r', 'g', 'b']) * cycler('3rd', [0, 1, 2]))") + + assert (c + c2)._repr_html_() == ( + "" + "" + "" + "" + "" + "
'3rd''c'
0'r'
1'g'
2'b'
") + assert (c * c2)._repr_html_() == ( + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "
'3rd''c'
0'r'
1'r'
2'r'
0'g'
1'g'
2'g'
0'b'
1'b'
2'b'
") + + +def test_call(): + c = cycler(c='rgb') + c_cycle = c() + assert isinstance(c_cycle, cycle) + j = 0 + for a, b in zip(2 * c, c_cycle): + j += 1 + assert a == b + + assert j == len(c) * 2 + + +def test_copying(): + # Just about everything results in copying the cycler and + # its contents (shallow). This set of tests is intended to make sure + # of that. Our iterables will be mutable for extra fun! + i1 = [1, 2, 3] + i2 = ['r', 'g', 'b'] + # For more mutation fun! + i3 = [['y', 'g'], ['b', 'k']] + + c1 = cycler('c', i1) + c2 = cycler('lw', i2) + c3 = cycler('foo', i3) + + c_before = (c1 + c2) * c3 + + i1.pop() + i2.append('cyan') + i3[0].append('blue') + + c_after = (c1 + c2) * c3 + + assert c1 == cycler('c', [1, 2, 3]) + assert c2 == cycler('lw', ['r', 'g', 'b']) + assert c3 == cycler('foo', [['y', 'g', 'blue'], ['b', 'k']]) + assert c_before == (cycler(c=[1, 2, 3], lw=['r', 'g', 'b']) * + cycler('foo', [['y', 'g', 'blue'], ['b', 'k']])) + assert c_after == (cycler(c=[1, 2, 3], lw=['r', 'g', 'b']) * + cycler('foo', [['y', 'g', 'blue'], ['b', 'k']])) + + # Make sure that changing the key for a specific cycler + # doesn't break things for a composed cycler + c = (c1 + c2) * c3 + c4 = cycler('bar', c3) + assert c == (cycler(c=[1, 2, 3], lw=['r', 'g', 'b']) * + cycler('foo', [['y', 'g', 'blue'], ['b', 'k']])) + assert c3 == cycler('foo', [['y', 'g', 'blue'], ['b', 'k']]) + assert c4 == cycler('bar', [['y', 'g', 'blue'], ['b', 'k']]) + + +def test_keychange(): + c1 = cycler('c', 'rgb') + c2 = cycler('lw', [1, 2, 3]) + c3 = cycler('ec', 'yk') + + c3.change_key('ec', 'edgecolor') + assert c3 == cycler('edgecolor', c3) + + c = c1 + c2 + c.change_key('lw', 'linewidth') + # Changing a key in one cycler should have no + # impact in the original cycler. + assert c2 == cycler('lw', [1, 2, 3]) + assert c == c1 + cycler('linewidth', c2) + + c = (c1 + c2) * c3 + c.change_key('c', 'color') + assert c1 == cycler('c', 'rgb') + assert c == (cycler('color', c1) + c2) * c3 + + # Perfectly fine, it is a no-op + c.change_key('color', 'color') + assert c == (cycler('color', c1) + c2) * c3 + + # Can't change a key to one that is already in there + pytest.raises(ValueError, Cycler.change_key, c, 'color', 'lw') + # Can't change a key you don't have + pytest.raises(KeyError, Cycler.change_key, c, 'c', 'foobar') + + +def test_eq(): + a = cycler(c='rgb') + b = cycler(c='rgb') + assert a == b + assert a != b[::-1] + c = cycler(lw=range(3)) + assert a + c == c + a + assert a + c == c + b + assert a * c != c * a + assert a != c + d = cycler(c='ymk') + assert b != d + e = cycler(c='orange') + assert b != e + + +def test_cycler_exceptions(): + pytest.raises(TypeError, cycler) + pytest.raises(TypeError, cycler, 'c', 'rgb', lw=range(3)) + pytest.raises(TypeError, cycler, 'c') + pytest.raises(TypeError, cycler, 'c', 'rgb', 'lw', range(3)) + + +def test_strange_init(): + c = cycler('r', 'rgb') + c2 = cycler('lw', range(3)) + cy = Cycler(list(c), c2, zip) + assert cy == c + c2 - assert_equal(six.text_type(test_repr), - six.text_type(target_repr)) +def test_concat(): + a = cycler('a', range(3)) + b = cycler('a', 'abc') + for con, chn in zip(a.concat(b), chain(a, b)): + assert con == chn + + for con, chn in zip(concat(a, b), chain(a, b)): + assert con == chn + + +def test_concat_fail(): + a = cycler('a', range(3)) + b = cycler('b', range(3)) + pytest.raises(ValueError, concat, a, b) + pytest.raises(ValueError, a.concat, b) + + +def _by_key_helper(cy): + res = cy.by_key() + target = defaultdict(list) + for sty in cy: + for k, v in sty.items(): + target[k].append(v) + + assert res == target + + +def test_by_key_add(): + input_dict = dict(c=list('rgb'), lw=[1, 2, 3]) + cy = cycler(c=input_dict['c']) + cycler(lw=input_dict['lw']) + res = cy.by_key() + assert res == input_dict + _by_key_helper(cy) + + +def test_by_key_mul(): + input_dict = dict(c=list('rg'), lw=[1, 2, 3]) + cy = cycler(c=input_dict['c']) * cycler(lw=input_dict['lw']) + res = cy.by_key() + assert input_dict['lw'] * len(input_dict['c']) == res['lw'] + _by_key_helper(cy) -def test_repr(): - c = cycler('c', 'rgb') - c2 = cycler('lw', range(3)) - c_sum_rpr = "(cycler('c', ['r', 'g', 'b']) + cycler('lw', [0, 1, 2]))" - c_prod_rpr = "(cycler('c', ['r', 'g', 'b']) * cycler('lw', [0, 1, 2]))" +def test_contains(): + a = cycler('a', range(3)) + b = cycler('b', range(3)) - yield _repr_tester_helper, '__repr__', c + c2, c_sum_rpr - yield _repr_tester_helper, '__repr__', c * c2, c_prod_rpr + assert 'a' in a + assert 'b' in b + assert 'a' not in b + assert 'b' not in a - sum_html = "
'c''lw'
'r'0
'g'1
'b'2
" - prod_html = "
'c''lw'
'r'0
'r'1
'r'2
'g'0
'g'1
'g'2
'b'0
'b'1
'b'2
" + ab = a + b - yield _repr_tester_helper, '_repr_html_', c + c2, sum_html - yield _repr_tester_helper, '_repr_html_', c * c2, prod_html + assert 'a' in ab + assert 'b' in ab