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 += "
{key!r}
".format(key=key)
- for d in iter(self):
- output += "
"
- for k in sorted_keys:
- output += "
{val!r}
".format(val=d[k])
- output += "
"
- output += "
"
- 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"
{key!r}
"
+ for d in iter(self):
+ output += "
"
+ for k in sorted_keys:
+ output += f"
{d[k]!r}
"
+ output += "
"
+ output += "
"
+ 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