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 c354030..0000000 --- a/.travis.yml +++ /dev/null @@ -1,21 +0,0 @@ -language: python - -matrix: - include: - - python: 2.7 - - python: 3.4 - - python: 3.5 - - 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/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index 815cfe5..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1,4 +0,0 @@ -include run_tests.py -include LICENSE -recursive-include conda-recipe * -recursive-include doc Makefile make.bat *.rst *.py \ No newline at end of file diff --git a/README.rst b/README.rst index 2fc7323..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://matplotlib.org/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 index 2cddd81..57ca08c 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -1,42 +1,16 @@ # AppVeyor.com is a Continuous Integration service to build and run tests under # Windows -environment: - global: - # SDK v7.0 MSVC Express 2008's SetEnv.cmd script will fail if the - # /E:ON and /V:ON options are not enabled in the batch script intepreter - # See: http://stackoverflow.com/a/13751649/163740 - CMD_IN_ENV: "cmd /E:ON /V:ON /C .\\ci\\appveyor\\run_with_env.cmd" +image: Visual Studio 2019 +environment: matrix: - - PYTHON: "C:\\Python27_32" - PYTHON_VERSION: "2.7" - PYTHON_ARCH: "32" - - - PYTHON: "C:\\Python27_64" - PYTHON_VERSION: "2.7" - PYTHON_ARCH: "64" - - - PYTHON: "C:\\Python34_32" - PYTHON_VERSION: "3.4.3" - PYTHON_ARCH: "32" - - - PYTHON: "C:\\Python34_64" - PYTHON_VERSION: "3.4.3" - PYTHON_ARCH: "64" - - - PYTHON: "C:\\Python35" - PYTHON_VERSION: "3.5.0" - PYTHON_ARCH: "32" - - - PYTHON: "C:\\Python35-x64" - PYTHON_VERSION: "3.5.0" - PYTHON_ARCH: "64" + - PYTHON: "C:\\Python38" + - PYTHON: "C:\\Python38-x64" + - PYTHON: "C:\\Python39" + - PYTHON: "C:\\Python39-x64" install: - # Install Python (from the official .msi of http://python.org) and pip when - # not already installed. - - "powershell ./ci/appveyor/install.ps1" - "SET PATH=%PYTHON%;%PYTHON%\\Scripts;%PATH%" # Check that we have the expected version and architecture for Python @@ -44,7 +18,7 @@ install: - "python -c \"import struct; print(struct.calcsize('P') * 8)\"" # Install the build and runtime dependencies of the project. - - "%CMD_IN_ENV% pip install -v six nose coveralls" + - "pip install -v pytest pytest-cov pytest-xdist" # Install the generated wheel package to test it - "python setup.py install" @@ -55,8 +29,8 @@ build: false test_script: - # Run unit tests with nose - - "python run_tests.py" + # Run unit tests with pytest + - "python -m pytest -raR -n auto" artifacts: # Archive the generated wheel package in the ci.appveyor.com build report. diff --git a/ci/appveyor/install.ps1 b/ci/appveyor/install.ps1 deleted file mode 100644 index 0f165d8..0000000 --- a/ci/appveyor/install.ps1 +++ /dev/null @@ -1,180 +0,0 @@ -# Sample script to install Python and pip under Windows -# Authors: Olivier Grisel, Jonathan Helmus and Kyle Kastner -# License: CC0 1.0 Universal: http://creativecommons.org/publicdomain/zero/1.0/ - -$MINICONDA_URL = "http://repo.continuum.io/miniconda/" -$BASE_URL = "https://www.python.org/ftp/python/" -$GET_PIP_URL = "https://bootstrap.pypa.io/get-pip.py" -$GET_PIP_PATH = "C:\get-pip.py" - - -function DownloadPython ($python_version, $platform_suffix) { - $webclient = New-Object System.Net.WebClient - $filename = "python-" + $python_version + $platform_suffix + ".msi" - $url = $BASE_URL + $python_version + "/" + $filename - - $basedir = $pwd.Path + "\" - $filepath = $basedir + $filename - if (Test-Path $filename) { - Write-Host "Reusing" $filepath - return $filepath - } - - # Download and retry up to 3 times in case of network transient errors. - Write-Host "Downloading" $filename "from" $url - $retry_attempts = 2 - for($i=0; $i -lt $retry_attempts; $i++){ - try { - $webclient.DownloadFile($url, $filepath) - break - } - Catch [Exception]{ - Start-Sleep 1 - } - } - if (Test-Path $filepath) { - Write-Host "File saved at" $filepath - } else { - # Retry once to get the error message if any at the last try - $webclient.DownloadFile($url, $filepath) - } - return $filepath -} - - -function InstallPython ($python_version, $architecture, $python_home) { - Write-Host "Installing Python" $python_version "for" $architecture "bit architecture to" $python_home - if (Test-Path $python_home) { - Write-Host $python_home "already exists, skipping." - return $false - } - if ($architecture -eq "32") { - $platform_suffix = "" - } else { - $platform_suffix = ".amd64" - } - $msipath = DownloadPython $python_version $platform_suffix - Write-Host "Installing" $msipath "to" $python_home - $install_log = $python_home + ".log" - $install_args = "/qn /log $install_log /i $msipath TARGETDIR=$python_home" - $uninstall_args = "/qn /x $msipath" - RunCommand "msiexec.exe" $install_args - if (-not(Test-Path $python_home)) { - Write-Host "Python seems to be installed else-where, reinstalling." - RunCommand "msiexec.exe" $uninstall_args - RunCommand "msiexec.exe" $install_args - } - if (Test-Path $python_home) { - Write-Host "Python $python_version ($architecture) installation complete" - } else { - Write-Host "Failed to install Python in $python_home" - Get-Content -Path $install_log - Exit 1 - } -} - -function RunCommand ($command, $command_args) { - Write-Host $command $command_args - Start-Process -FilePath $command -ArgumentList $command_args -Wait -Passthru -} - - -function InstallPip ($python_home) { - $pip_path = $python_home + "\Scripts\pip.exe" - $python_path = $python_home + "\python.exe" - if (-not(Test-Path $pip_path)) { - Write-Host "Installing pip..." - $webclient = New-Object System.Net.WebClient - $webclient.DownloadFile($GET_PIP_URL, $GET_PIP_PATH) - Write-Host "Executing:" $python_path $GET_PIP_PATH - Start-Process -FilePath "$python_path" -ArgumentList "$GET_PIP_PATH" -Wait -Passthru - } else { - Write-Host "pip already installed." - } -} - - -function DownloadMiniconda ($python_version, $platform_suffix) { - $webclient = New-Object System.Net.WebClient - if ($python_version -eq "3.4") { - $filename = "Miniconda3-3.5.5-Windows-" + $platform_suffix + ".exe" - } else { - $filename = "Miniconda-3.5.5-Windows-" + $platform_suffix + ".exe" - } - $url = $MINICONDA_URL + $filename - - $basedir = $pwd.Path + "\" - $filepath = $basedir + $filename - if (Test-Path $filename) { - Write-Host "Reusing" $filepath - return $filepath - } - - # Download and retry up to 3 times in case of network transient errors. - Write-Host "Downloading" $filename "from" $url - $retry_attempts = 2 - for($i=0; $i -lt $retry_attempts; $i++){ - try { - $webclient.DownloadFile($url, $filepath) - break - } - Catch [Exception]{ - Start-Sleep 1 - } - } - if (Test-Path $filepath) { - Write-Host "File saved at" $filepath - } else { - # Retry once to get the error message if any at the last try - $webclient.DownloadFile($url, $filepath) - } - return $filepath -} - - -function InstallMiniconda ($python_version, $architecture, $python_home) { - Write-Host "Installing Python" $python_version "for" $architecture "bit architecture to" $python_home - if (Test-Path $python_home) { - Write-Host $python_home "already exists, skipping." - return $false - } - if ($architecture -eq "32") { - $platform_suffix = "x86" - } else { - $platform_suffix = "x86_64" - } - $filepath = DownloadMiniconda $python_version $platform_suffix - Write-Host "Installing" $filepath "to" $python_home - $install_log = $python_home + ".log" - $args = "/S /D=$python_home" - Write-Host $filepath $args - Start-Process -FilePath $filepath -ArgumentList $args -Wait -Passthru - if (Test-Path $python_home) { - Write-Host "Python $python_version ($architecture) installation complete" - } else { - Write-Host "Failed to install Python in $python_home" - Get-Content -Path $install_log - Exit 1 - } -} - - -function InstallMinicondaPip ($python_home) { - $pip_path = $python_home + "\Scripts\pip.exe" - $conda_path = $python_home + "\Scripts\conda.exe" - if (-not(Test-Path $pip_path)) { - Write-Host "Installing pip..." - $args = "install --yes pip" - Write-Host $conda_path $args - Start-Process -FilePath "$conda_path" -ArgumentList $args -Wait -Passthru - } else { - Write-Host "pip already installed." - } -} - -function main () { - InstallPython $env:PYTHON_VERSION $env:PYTHON_ARCH $env:PYTHON - InstallPip $env:PYTHON -} - -main diff --git a/ci/appveyor/run_with_env.cmd b/ci/appveyor/run_with_env.cmd deleted file mode 100644 index 0c70d63..0000000 --- a/ci/appveyor/run_with_env.cmd +++ /dev/null @@ -1,47 +0,0 @@ -:: To build extensions for 64 bit Python 3, we need to configure environment -:: variables to use the MSVC 2010 C++ compilers from GRMSDKX_EN_DVD.iso of: -:: MS Windows SDK for Windows 7 and .NET Framework 4 (SDK v7.1) -:: -:: To build extensions for 64 bit Python 2, we need to configure environment -:: variables to use the MSVC 2008 C++ compilers from GRMSDKX_EN_DVD.iso of: -:: MS Windows SDK for Windows 7 and .NET Framework 3.5 (SDK v7.0) -:: -:: 32 bit builds do not require specific environment configurations. -:: -:: Note: this script needs to be run with the /E:ON and /V:ON flags for the -:: cmd interpreter, at least for (SDK v7.0) -:: -:: More details at: -:: https://github.com/cython/cython/wiki/64BitCythonExtensionsOnWindows -:: http://stackoverflow.com/a/13751649/163740 -:: -:: Author: Olivier Grisel -:: License: BSD 3 clause -@ECHO OFF - -SET COMMAND_TO_RUN=%* -SET WIN_SDK_ROOT=C:\Program Files\Microsoft SDKs\Windows - -SET MAJOR_PYTHON_VERSION="%PYTHON_VERSION:~0,1%" -IF %MAJOR_PYTHON_VERSION% == "2" ( - SET WINDOWS_SDK_VERSION="v7.0" -) ELSE IF %MAJOR_PYTHON_VERSION% == "3" ( - SET WINDOWS_SDK_VERSION="v7.1" -) ELSE ( - ECHO Unsupported Python version: "%MAJOR_PYTHON_VERSION%" - EXIT 1 -) - -IF "%PYTHON_ARCH%"=="64" ( - ECHO Configuring Windows SDK %WINDOWS_SDK_VERSION% for Python %MAJOR_PYTHON_VERSION% on a 64 bit architecture - SET DISTUTILS_USE_SDK=1 - SET MSSdk=1 - "%WIN_SDK_ROOT%\%WINDOWS_SDK_VERSION%\Setup\WindowsSdkVer.exe" -q -version:%WINDOWS_SDK_VERSION% - "%WIN_SDK_ROOT%\%WINDOWS_SDK_VERSION%\Bin\SetEnv.cmd" /x64 /release - ECHO Executing: %COMMAND_TO_RUN% - call %COMMAND_TO_RUN% || EXIT 1 -) ELSE ( - ECHO Using default MSVC build environment for 32 bit architecture - ECHO Executing: %COMMAND_TO_RUN% - call %COMMAND_TO_RUN% || EXIT 1 -) diff --git a/conda-recipe/bld.bat b/conda-recipe/bld.bat deleted file mode 100644 index 87b1481..0000000 --- a/conda-recipe/bld.bat +++ /dev/null @@ -1,8 +0,0 @@ -"%PYTHON%" setup.py install -if errorlevel 1 exit 1 - -:: Add more build steps here, if they are necessary. - -:: See -:: http://docs.continuum.io/conda/build.html -:: for a list of environment variables that are set during the build process. diff --git a/conda-recipe/build.sh b/conda-recipe/build.sh deleted file mode 100644 index 4d7fc03..0000000 --- a/conda-recipe/build.sh +++ /dev/null @@ -1,9 +0,0 @@ -#!/bin/bash - -$PYTHON setup.py install - -# Add more build steps here, if they are necessary. - -# See -# http://docs.continuum.io/conda/build.html -# for a list of environment variables that are set during the build process. diff --git a/conda-recipe/meta.yaml b/conda-recipe/meta.yaml deleted file mode 100644 index ea33deb..0000000 --- a/conda-recipe/meta.yaml +++ /dev/null @@ -1,63 +0,0 @@ -package: - name: cycler - version: {{ environ['GIT_DESCRIBE_TAG'] }}.post{{ environ['GIT_DESCRIBE_NUMBER'] }} - - -source: - git_url: ../ - -# patches: - # List any patch files here - # - fix.patch - -build: - string: {{ environ.get('GIT_BUILD_STR', '') }}_py{{ py }} - # preserve_egg_dir: True - # entry_points: - # Put any entry points (scripts to be generated automatically) here. The - # syntax is module:function. For example - # - # - cycler = cycler:main - # - # Would create an entry point called cycler that calls cycler.main() - - - # If this is a new build for the same version, increment the build - # number. If you do not include this key, it defaults to 0. - # number: 1 - -requirements: - build: - - python - - setuptools - - six - - run: - - python - - six - -test: - # Python imports - imports: - - cycler - - # commands: - # You can put test commands to be run here. Use this to test that the - # entry points work. - - - # You can also put a file called run_test.py in the recipe that will be run - # at test time. - - # requires: - # Put any additional test requirements here. For example - # - nose - -about: - home: http://github.com/matplotlib/cycler - license: BSD - summary: 'Composable style cycles' - -# See -# http://docs.continuum.io/conda/build.html for -# more information about meta.yaml diff --git a/cycler.py b/cycler/__init__.py similarity index 58% rename from cycler.py rename to cycler/__init__.py index 3c3eb2d..db476b8 100644 --- a/cycler.py +++ b/cycler/__init__.py @@ -40,43 +40,89 @@ {'color': 'b', 'linestyle': '-.'} """ -from __future__ import (absolute_import, division, print_function, - unicode_literals) -import six +from __future__ import annotations + +from collections.abc import Hashable, Iterable, Generator +import copy +from functools import reduce from itertools import product, cycle -from six.moves import zip, reduce from operator import mul, add -import copy +# Dict, List, Union required for runtime cast calls +from typing import TypeVar, Generic, Callable, Union, Dict, List, Any, overload, cast -__version__ = '0.10.0' +__version__ = "0.13.0.dev0" -def _process_keys(left, right): +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 + Helper function to compose cycler keys. Parameters ---------- left, right : iterable of dictionaries or None - The cyclers to be composed + The cyclers to be composed. + Returns ------- keys : set - The keys in the composition of the two cyclers + The keys in the composition of the two cyclers. """ - l_peek = next(iter(left)) if left is not None else {} - r_peek = next(iter(right)) if right is not None else {} - l_key = set(l_peek.keys()) - r_key = set(r_peek.keys()) - if l_key & r_key: - raise ValueError("Can not compose overlapping cycles") + 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 -class Cycler(object): +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 + Composable cycles. This class has compositions methods: @@ -87,62 +133,65 @@ class Cycler(object): in-place ``+`` ``*`` - for outer products (itertools.product) and integer multiplication + for outer products (`itertools.product`) and integer multiplication ``*=`` in-place ``*`` - and supports basic slicing via ``[]`` + and supports basic slicing via ``[]``. Parameters ---------- - left : Cycler or None - The 'left' cycler - - right : Cycler or None - The 'right' cycler - + 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, right=None, op=None): - """Semi-private init + 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(left._left, left._right, left._op) + 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 = None + self._left = [] if isinstance(right, Cycler): - self._right = Cycler(right._left, right._right, right._op) - elif right is not None: - # Need to copy the dictionary or else that will be a residual - # mutable that could lead to strange errors - self._right = [copy.copy(v) for v in right] + self._right: Cycler[K, V] | None = Cycler( + right._left, right._right, right._op + ) else: self._right = None - self._keys = _process_keys(self._left, self._right) - self._op = op + 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): - """ - The keys this Cycler knows about - """ + def keys(self) -> set[K]: + """The keys this Cycler knows about.""" return set(self._keys) - def change_key(self, old, new): + def change_key(self, old: K, new: K) -> None: """ Change a key in this cycler to a new name. Modification is performed in-place. @@ -150,16 +199,17 @@ def change_key(self, old, new): 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("Can't replace %s with %s, %s is already a key" % - (old, new, new)) + raise ValueError( + f"Can't replace {old} with {new}, {new} is already a key" + ) if old not in self._keys: - raise KeyError("Can't replace %s with %s, %s is not a key" % - (old, new, old)) + raise KeyError( + f"Can't replace {old} with {new}, {old} is not a key" + ) self._keys.remove(old) self._keys.add(new) @@ -177,19 +227,8 @@ def change_key(self, old, new): # iteration. self._left = [{new: entry[old]} for entry in self._left] - 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): + 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 @@ -197,7 +236,7 @@ def _from_iter(cls, label, itr): Parameters ---------- - label : str + label : hashable The property key. itr : iterable @@ -205,84 +244,121 @@ def _from_iter(cls, label, itr): Returns ------- - cycler : Cycler - New 'base' `Cycler` + `Cycler` + New 'base' cycler. """ - ret = cls(None) + ret: Cycler[K, V] = cls(None) ret._left = list({label: v} for v in itr) - ret._keys = set([label]) + ret._keys = {label} return ret - def __getitem__(self, key): + 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 six.iteritems(trans))) + 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): + def __iter__(self) -> Generator[dict[K, V], None, None]: if self._right is None: - return iter(dict(l) for l in self._left) - - return self._compose() - - def __add__(self, other): + 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 cycles (zip) + Pair-wise combine two equal length cyclers (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) + 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 cycles (`itertools.product`) or integer + Outer product of two cyclers (`itertools.product`) or integer multiplication. Parameters ---------- other : Cycler or int - The second Cycler or integer """ if isinstance(other, Cycler): - return Cycler(self, other, product) + 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 six.iteritems(trans))) + 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): - op_dict = {zip: min, product: mul} + 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) - def __iadd__(self, other): + # 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 cycles (zip) + In-place pair-wise combine two equal length cyclers (zip). Parameters ---------- other : Cycler - The second 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) @@ -291,14 +367,13 @@ def __iadd__(self, other): self._right = Cycler(other._left, other._right, other._op) return self - def __imul__(self, other): + def __imul__(self, other: Cycler[K, V] | int) -> Cycler[K, V]: # type: ignore[misc] """ - In-place outer product of two cycles (`itertools.product`) + In-place outer product of two cyclers (`itertools.product`). Parameters ---------- other : Cycler - The second Cycler """ if not isinstance(other, Cycler): raise TypeError("Cannot *= with a non-Cycler object") @@ -310,44 +385,45 @@ def __imul__(self, other): self._right = Cycler(other._left, other._right, other._op) return self - def __eq__(self, other): - """ - Check equality - """ + 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)) - def __repr__(self): - op_map = {zip: '+', product: '*'} + __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 "cycler({lab!r}, {itr!r})".format(lab=lab, itr=itr) + return f"cycler({lab!r}, {itr!r})" else: - op = op_map.get(self._op, '?') + 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): + 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 += "".format(key=key) + output += f"" for d in iter(self): output += "" for k in sorted_keys: - output += "".format(val=d[k]) + output += f"" output += "" output += "
{key!r}{key!r}
{val!r}{d[k]!r}
" return output - def by_key(self): - """Values by key + 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, @@ -367,8 +443,7 @@ def by_key(self): # and if we care. keys = self.keys - # change this to dict comprehension when drop 2.6 - out = dict((k, list()) for k in keys) + out: dict[K, list[V]] = {k: list() for k in keys} for d in self: for k in keys: @@ -378,91 +453,38 @@ def by_key(self): # for back compatibility _transpose = by_key - def simplify(self): - """Simplify the Cycler - - Returned as a composition using only sums (no multiplications) + def simplify(self) -> Cycler[K, V]: + """ + Simplify the cycler into a sum (but no products) of cyclers. 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.by_key() - return reduce(add, (_cycler(k, v) for k, v in six.iteritems(trans))) + return reduce(add, (_cycler(k, v) for k, v in trans.items())) - def concat(self, other): - """Concatenate this cycler and an other. + concat = concat - The keys must match exactly. - This returns a single Cycler which is equivalent to - `itertools.chain(self, other)` +@overload +def cycler(arg: Cycler[K, V]) -> Cycler[K, V]: + ... - Examples - -------- - >>> num = cycler('a', range(3)) - >>> let = cycler('a', 'abc') - >>> num.concat(let) - cycler('a', [0, 1, 2, 'a', 'b', 'c']) +@overload +def cycler(**kwargs: Iterable[V]) -> Cycler[str, V]: + ... - Parameters - ---------- - other : `Cycler` - The `Cycler` to concatenate to this one. - - Returns - ------- - ret : `Cycler` - The concatenated `Cycler` - """ - return concat(self, other) - -def concat(left, right): - """Concatenate two cyclers. - - The keys must match exactly. - - This returns a single Cycler which is equivalent to - `itertools.chain(left, right)` - - Examples - -------- - - >>> num = cycler('a', range(3)) - >>> let = cycler('a', 'abc') - >>> num.concat(let) - cycler('a', [0, 1, 2, 'a', 'b', 'c']) - - Parameters - ---------- - left, right : `Cycler` - The two `Cycler` instances to concatenate - - Returns - ------- - ret : `Cycler` - The concatenated `Cycler` - """ - if left.keys != right.keys: - msg = '\n\t'.join(["Keys do not match:", - "Intersection: {both!r}", - "Disjoint: {just_one!r}"]).format( - both=left.keys & right.keys, - just_one=left.keys ^ right.keys) - - raise ValueError(msg) - - _l = left.by_key() - _r = right.by_key() - return reduce(add, (_cycler(k, _l[k] + _r[k]) for k in left.keys)) +@overload +def cycler(label: K, itr: Iterable[V]) -> Cycler[K, V]: + ... def cycler(*args, **kwargs): @@ -488,12 +510,10 @@ def cycler(*args, **kwargs): ---------- 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 @@ -506,36 +526,39 @@ def cycler(*args, **kwargs): """ if args and kwargs: - raise TypeError("cyl() can only accept positional OR keyword " - "arguments -- not both.") + 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.") + 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.") + 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 six.iteritems(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, itr): +def _cycler(label: K, itr: Iterable[V]) -> Cycler[K, V]: """ - Create a new `Cycler` object from a property name and - iterable of values. + 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. 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 fb48a87..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.10.0' # The full version, including alpha/beta/rc tags. -release = '0.10.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 911c403..126de4b 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -4,16 +4,16 @@ Composable cycles =================== -.. htmlonly:: +.. only:: html :Version: |version| :Date: |today| ====== ==================================== -docs http://matplotlib.org/cycler -pypi https://pypi.python.org/pypi/Cycler -github https://github.com/matplotlib/cycler +Docs https://matplotlib.org/cycler +PyPI https://pypi.python.org/pypi/Cycler +GitHub https://github.com/matplotlib/cycler ====== ==================================== @@ -25,12 +25,13 @@ github https://github.com/matplotlib/cycler 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 @@ -39,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 -The `Cycler` knows it's length and keys: +The `Cycler` knows its length and keys: .. ipython:: python @@ -97,7 +97,7 @@ 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 @@ -155,7 +155,7 @@ Any pair of `Cycler` can be multiplied 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 @@ -180,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 @@ -331,8 +331,7 @@ 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: @@ -401,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 d70d29d..0000000 --- a/setup.py +++ /dev/null @@ -1,23 +0,0 @@ -from setuptools import setup - -setup(name='cycler', - version='0.10.0', - author='Thomas A Caswell', - author_email='matplotlib-users@python.org', - 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 f65a4cd..1eccac3 100644 --- a/test_cycler.py +++ b/test_cycler.py @@ -1,131 +1,150 @@ -from __future__ import (absolute_import, division, print_function) +from collections import defaultdict +from operator import add, iadd, mul, imul +from itertools import product, cycle, chain + +import pytest # type: ignore -import six -from six.moves import zip, range from cycler import cycler, Cycler, concat -from nose.tools import (assert_equal, assert_not_equal, - assert_raises, assert_true) -from itertools import product, cycle, chain -from operator import add, iadd, mul, imul -from collections import defaultdict 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)) - + 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']] - c = cycler(cycler(c='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(): +def test_add(): c1 = cycler(c='rgb') c2 = cycler(lw=range(3)) - c3 = cycler(lw=range(15)) # 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 + _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 - assert_raises(ValueError, add, c1, c3) - assert_raises(ValueError, add, c3, c1) + 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(): +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 + + +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 + _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 + _cycler_helper(c3 + c2, 3, ['c', 'ec'], [['r', 'g', 'b']] * 2) # Using a non-string hashable c4 = cycler(1, range(3)) - yield _cycler_helper, c4+c1, 3, [1, 'c'], [range(3), ['r', 'g', 'b']] + _cycler_helper(c4 + c1, 3, [1, 'c'], [range(3), ['r', 'g', 'b']]) # addition using cycler() - yield (_cycler_helper, cycler(c='rgb', lw=range(3)), - 3, ['c', 'lw'], [list('rgb'), range(3)]) - yield (_cycler_helper, cycler(lw=range(3), c='rgb'), - 3, ['c', 'lw'], [list('rgb'), range(3)]) + _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 - yield (_cycler_helper, cycler(c=range(3), lw=c1), - 3, ['c', 'lw'], [range(3), list('rgb')]) + _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) - assert_raises(TypeError, iadd, c2, 'aardvark') - assert_raises(TypeError, imul, c2, 'aardvark') + 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) - 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) 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] + _cycler_helper(2 * c1, 6, ['c'], ['rgb' * 2]) 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, []) + pytest.raises(TypeError, mul, c1, 2.0) + pytest.raises(TypeError, mul, c1, 'a') + pytest.raises(TypeError, mul, c1, []) def test_getitem(): @@ -135,20 +154,13 @@ def test_getitem(): slice(None, None, -1), slice(1, 5, None), slice(0, 5, 2)): - yield _cycles_equal, c1[slc], cycler(3, 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]) - - -def _repr_tester_helper(rpr_func, cyc, target_repr): - test_repr = getattr(cyc, rpr_func)() - - assert_equal(six.text_type(test_repr), - six.text_type(target_repr)) + pytest.raises(ValueError, Cycler.__getitem__, c1, 0) + pytest.raises(ValueError, Cycler.__getitem__, c1, [0, 1]) def test_repr(): @@ -156,29 +168,43 @@ def test_repr(): # Using an identifier that would be not valid as a kwarg c2 = cycler('3rd', range(3)) - c_sum_rpr = "(cycler('c', ['r', 'g', 'b']) + cycler('3rd', [0, 1, 2]))" - c_prod_rpr = "(cycler('c', ['r', 'g', 'b']) * cycler('3rd', [0, 1, 2]))" - - yield _repr_tester_helper, '__repr__', c + c2, c_sum_rpr - yield _repr_tester_helper, '__repr__', c * c2, c_prod_rpr - - sum_html = "
'3rd''c'
0'r'
1'g'
2'b'
" - prod_html = "
'3rd''c'
0'r'
1'r'
2'r'
0'g'
1'g'
2'g'
0'b'
1'b'
2'b'
" - - yield _repr_tester_helper, '_repr_html_', c + c2, sum_html - yield _repr_tester_helper, '_repr_html_', c * c2, prod_html + 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_true(isinstance(c_cycle, cycle)) + assert isinstance(c_cycle, cycle) j = 0 - for a, b in zip(2*c, c_cycle): + for a, b in zip(2 * c, c_cycle): j += 1 - assert_equal(a, b) + assert a == b - assert_equal(j, len(c) * 2) + assert j == len(c) * 2 def test_copying(): @@ -202,21 +228,22 @@ def test_copying(): c_after = (c1 + c2) * c3 - assert_equal(c1, cycler('c', [1, 2, 3])) - assert_equal(c2, cycler('lw', ['r', 'g', 'b'])) - assert_equal(c3, cycler('foo', [['y', 'g', 'blue'], ['b', 'k']])) - assert_equal(c_before, (cycler(c=[1, 2, 3], lw=['r', 'g', 'b']) * - cycler('foo', [['y', 'g', 'blue'], ['b', 'k']]))) - assert_equal(c_after, (cycler(c=[1, 2, 3], lw=['r', 'g', 'b']) * - cycler('foo', [['y', 'g', 'blue'], ['b', 'k']]))) + 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_equal(c, (cycler(c=[1, 2, 3], lw=['r', 'g', 'b']) * - cycler('foo', [['y', 'g', 'blue'], ['b', 'k']]))) - assert_equal(c3, cycler('foo', [['y', 'g', 'blue'], ['b', 'k']])) + 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(): @@ -225,82 +252,75 @@ def test_keychange(): c3 = cycler('ec', 'yk') c3.change_key('ec', 'edgecolor') - assert_equal(c3, cycler('edgecolor', c3)) + 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_equal(c2, cycler('lw', [1, 2, 3])) - assert_equal(c, c1 + cycler('linewidth', c2)) + assert c2 == cycler('lw', [1, 2, 3]) + assert c == c1 + cycler('linewidth', c2) c = (c1 + c2) * c3 c.change_key('c', 'color') - assert_equal(c1, cycler('c', 'rgb')) - assert_equal(c, (cycler('color', c1) + c2) * c3) + 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_equal(c, (cycler('color', c1) + c2) * c3) + assert c == (cycler('color', c1) + c2) * c3 # Can't change a key to one that is already in there - assert_raises(ValueError, Cycler.change_key, c, 'color', 'lw') + pytest.raises(ValueError, Cycler.change_key, c, 'color', 'lw') # Can't change a key you don't have - assert_raises(KeyError, Cycler.change_key, c, 'c', 'foobar') - - -def _eq_test_helper(a, b, res): - if res: - assert_equal(a, b) - else: - assert_not_equal(a, b) + pytest.raises(KeyError, Cycler.change_key, c, 'c', 'foobar') def test_eq(): a = cycler(c='rgb') b = cycler(c='rgb') - yield _eq_test_helper, a, b, True - yield _eq_test_helper, a, b[::-1], False + assert a == b + assert a != b[::-1] c = cycler(lw=range(3)) - yield _eq_test_helper, a+c, c+a, True - yield _eq_test_helper, a+c, c+b, True - yield _eq_test_helper, a*c, c*a, False - yield _eq_test_helper, a, c, False + assert a + c == c + a + assert a + c == c + b + assert a * c != c * a + assert a != c d = cycler(c='ymk') - yield _eq_test_helper, b, d, False + assert b != d e = cycler(c='orange') - yield _eq_test_helper, b, e, False + assert b != e def test_cycler_exceptions(): - assert_raises(TypeError, cycler) - assert_raises(TypeError, cycler, 'c', 'rgb', lw=range(3)) - assert_raises(TypeError, cycler, 'c') - assert_raises(TypeError, cycler, 'c', 'rgb', 'lw', range(3)) + 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_starange_init(): +def test_strange_init(): c = cycler('r', 'rgb') c2 = cycler('lw', range(3)) - cy = Cycler(list(c), list(c2), zip) - assert_equal(cy, c + c2) + cy = Cycler(list(c), c2, zip) + assert cy == c + c2 def test_concat(): a = cycler('a', range(3)) b = cycler('a', 'abc') for con, chn in zip(a.concat(b), chain(a, b)): - assert_equal(con, chn) + assert con == chn for con, chn in zip(concat(a, b), chain(a, b)): - assert_equal(con, chn) + assert con == chn def test_concat_fail(): a = cycler('a', range(3)) b = cycler('b', range(3)) - assert_raises(ValueError, concat, a, b) - assert_raises(ValueError, a.concat, b) + pytest.raises(ValueError, concat, a, b) + pytest.raises(ValueError, a.concat, b) def _by_key_helper(cy): @@ -310,21 +330,35 @@ def _by_key_helper(cy): for k, v in sty.items(): target[k].append(v) - assert_equal(res, target) + 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_equal(res, input_dict) - yield _by_key_helper, cy + 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_equal(input_dict['lw'] * len(input_dict['c']), - res['lw']) - yield _by_key_helper, cy + assert input_dict['lw'] * len(input_dict['c']) == res['lw'] + _by_key_helper(cy) + + +def test_contains(): + a = cycler('a', range(3)) + b = cycler('b', range(3)) + + assert 'a' in a + assert 'b' in b + assert 'a' not in b + assert 'b' not in a + + ab = a + b + + assert 'a' in ab + assert 'b' in ab