├── .github └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE.txt ├── README.md ├── examples ├── example_client.py └── example_server.py ├── poetry.lock ├── pymidi ├── __init__.py ├── client.py ├── packets.py ├── protocol.py ├── server.py ├── tests │ ├── __init__.py │ ├── packets_test.py │ ├── server_tests.py │ └── utils_test.py └── utils.py ├── pyproject.toml ├── setup.cfg ├── setup.py └── tox.ini /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release to PyPI 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | publish: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - name: Set up Python 3.11 14 | uses: actions/setup-python@v4 15 | with: 16 | python-version: 3.11 17 | - name: Build package 18 | run: | 19 | python -m pip install --upgrade pip wheel 20 | python setup.py sdist bdist_wheel 21 | - name: Create release 22 | id: create_release 23 | uses: actions/create-release@v1 24 | env: 25 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 26 | with: 27 | tag_name: ${{ github.ref }} 28 | release_name: ${{ github.ref }} 29 | - name: Publish package to PyPI 30 | uses: pypa/gh-action-pypi-publish@master 31 | with: 32 | user: __token__ 33 | password: ${{ secrets.PYPI_PASSWORD }} 34 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Run Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | test: 13 | runs-on: ${{ matrix.os }} 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | os: [ubuntu-latest, macos-latest] 18 | python-version: 19 | - "3.8" 20 | - "3.9" 21 | - "3.10" 22 | - "3.11" 23 | 24 | steps: 25 | - name: Set up Python ${{ matrix.python-version }} 26 | uses: actions/setup-python@v4 27 | with: 28 | python-version: ${{ matrix.python-version }} 29 | 30 | - uses: actions/checkout@v3 31 | 32 | - name: Install dependencies 33 | run: | 34 | python -m pip install --upgrade pip 35 | python -m pip install poetry tox tox-gh-actions 36 | 37 | - name: Test with tox 38 | run: tox 39 | env: 40 | PLATFORM: ${{ matrix.platform }} 41 | 42 | lint: 43 | runs-on: ubuntu-latest 44 | strategy: 45 | fail-fast: false 46 | 47 | steps: 48 | - name: Set up Python 3.11 49 | uses: actions/setup-python@v4 50 | with: 51 | python-version: 3.11 52 | 53 | - uses: actions/checkout@v3 54 | 55 | - name: Install dependencies 56 | run: | 57 | python -m pip install --upgrade pip 58 | python -m pip install poetry tox tox-gh-actions 59 | 60 | - name: Check formatting with `black` 61 | run: tox -e black -- . 62 | 63 | - name: Check imports with `isort` 64 | run: tox -e isort -- . 65 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.egg-info/ 3 | .eggs/ 4 | dist/ 5 | .idea 6 | .DS_Store 7 | .tox/ 8 | .vscode/ 9 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## Current version (in development) 4 | 5 | * Bugfix: Fix crash when sender name contains non-ascii characters (#18). 6 | * Internal: Switched from `pipenv` to `poetry`. 7 | * Internal: Added `black` for code formatting. 8 | 9 | ## v0.5.0 (2020-01-12) 10 | 11 | * Python 2 support removed. 12 | 13 | ## v0.4.0 (2018-12-26) 14 | 15 | * Improvement: Python 3 support (#9). 16 | * Bugfix: Demo server: Fix IPv4/IPv6 support in dualstack environments (#8). 17 | 18 | ## v0.3.0 (2018-10-20) 19 | 20 | * Improvement: Server instances can bind to ipv6 addresses. 21 | 22 | ## v0.2.1 (2018-09-16) 23 | 24 | * Repackaged release, no functional changes. 25 | 26 | ## v0.2.0 (2018-09-16) 27 | 28 | * `note_on` and `note_off` messages now report a string note name. 29 | * Cleaned up some logs. 30 | 31 | ## v0.1.0 (2018-09-16) 32 | 33 | * First release. 34 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 mike wakerly 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pymidi 2 | 3 | A python RTP-MIDI / AppleMIDI implementation. You can use this library to build a network attached virtual MIDI device. 4 | 5 | [![Build Status](https://travis-ci.org/mik3y/pymidi.svg?branch=main)](https://travis-ci.org/mik3y/pymidi) 6 | 7 | **Latest release:** v0.5.0 (2020-01-12) ([changelog](https://github.com/mik3y/pymidi/blob/main/CHANGELOG.md)) 8 | 9 | 10 | 11 | **Table of Contents** 12 | 13 | - [Quickstart](#quickstart) 14 | - [Developer Setup](#developer-setup) 15 | - [Compatibility](#compatibility) 16 | - [Running tests](#running-tests) 17 | - [Developing against something else](#developing-against-something-else) 18 | - [Demo Server](#demo-server) 19 | - [Using in Another Project](#using-in-another-project) 20 | - [Project Status](#project-status) 21 | - [References and Reading](#references-and-reading) 22 | 23 | 24 | 25 | ## Quickstart 26 | 27 | ``` 28 | $ pip install pymidi 29 | ``` 30 | or 31 | 32 | ``` 33 | poetry install pymidi 34 | ``` 35 | 36 | See [Using in Another Project](#using-in-another-project) and the [Developer Setup wiki](wiki/Developer-MIDI-Setup) for more information. 37 | 38 | ## Developer Setup 39 | 40 | Set up your workspace with the very excellent [Poetry](https://python-poetry.org/): 41 | 42 | ``` 43 | $ poetry install 44 | ``` 45 | 46 | Once installed, you'll probably find it useful to work in a `poetry shell`, for ease of testing and running things: 47 | 48 | ``` 49 | $ poetry shell 50 | (pymidi-tFFCbXNj) 51 | $ python pymidi/server.py 52 | ``` 53 | 54 | ### Compatibility 55 | 56 | `pymidi` requires Python 3. It has been tested against Python 3.6 and Python 3.7. 57 | 58 | ### Running tests 59 | 60 | Tests are run with pytest: 61 | 62 | ``` 63 | $ pytest 64 | ``` 65 | 66 | ### Developing against something else 67 | 68 | If you're working on a project that uses `pymidi` and want to develop both concurrently, leverage the setuptools `develop` command: 69 | 70 | ``` 71 | $ cd ~/git/otherproject 72 | $ poetry shell 73 | $ pushd ~/git/pymidi && python setup.py develop && popd 74 | ``` 75 | 76 | This creates a link to `~/git/pymidi` within the environment of `~/git/otherproject`. 77 | 78 | ## Demo Server and Examples 79 | 80 | The library includes a simple demo server which prints stuff. 81 | 82 | ``` 83 | $ python pymidi/examples/example_server.py 84 | ``` 85 | 86 | See `--help` for usage. See the `examples/` directory for other examples. 87 | 88 | ## Using in Another Project 89 | 90 | Most likely you will want to embed a server in another project, and respond to MIDI commands in some application specific way. The demo serve is an example of what you need to do. 91 | 92 | First, create a subclass of `server.Handler` to implement your policy: 93 | 94 | ```py 95 | from pymidi import server 96 | 97 | class MyHandler(server.Handler): 98 | def on_peer_connected(self, peer): 99 | print('Peer connected: {}'.format(peer)) 100 | 101 | def on_peer_disconnected(self, peer): 102 | print('Peer disconnected: {}'.format(peer)) 103 | 104 | def on_midi_commands(self, peer, command_list): 105 | for command in command_list: 106 | if command.command == 'note_on': 107 | key = command.params.key 108 | velocity = command.params.velocity 109 | print('Someone hit the key {} with velocity {}'.format(key, velocity)) 110 | ``` 111 | 112 | Then install it in a server and start serving: 113 | 114 | ``` 115 | myServer = server.Server([('0.0.0.0', 5051)]) 116 | myServer.add_handler(MyHandler()) 117 | myServer.serve_forever() 118 | ``` 119 | 120 | See the [Developer Setup wiki](https://github.com/mik3y/pymidi/wiki/Developer-MIDI-Setup) for ways to test with real devices. 121 | 122 | ## Project Status 123 | 124 | What works: 125 | * Exchange packet parsing 126 | * Timestamp sync packet parsing 127 | * Exchange & timestamp sync protocol support 128 | * MIDI message parsing 129 | 130 | Not (yet) implemented: 131 | * Journal contents parsing 132 | * Verification of peers on the data channel 133 | * Auto-disconnect peers that stop synchronizing clocks 134 | 135 | ## References and Reading 136 | 137 | * Official docs 138 | - [RFC 6295: RTP Payload Format for MIDI](https://tools.ietf.org/html/rfc6295) 139 | - [AppleMIDI Reference Documentation from Apple](https://developer.apple.com/library/archive/documentation/Audio/Conceptual/MIDINetworkDriverProtocol/MIDI/MIDI.html) 140 | - [RTP-MIDI on Wikipedia](https://en.wikipedia.org/wiki/RTP-MIDI) 141 | * Other helpful docs/sites 142 | - [The MIDI Specification](http://midi.teragonaudio.com/tech/midispec.htm) 143 | -------------------------------------------------------------------------------- /examples/example_client.py: -------------------------------------------------------------------------------- 1 | from builtins import bytes 2 | 3 | from optparse import OptionParser 4 | import logging 5 | import select 6 | import socket 7 | import sys 8 | import time 9 | 10 | import pymidi.client 11 | from pymidi.protocol import DataProtocol 12 | from pymidi.protocol import ControlProtocol 13 | from pymidi import utils 14 | 15 | try: 16 | import coloredlogs 17 | except ImportError: 18 | coloredlogs = None 19 | 20 | logger = logging.getLogger('pymidi.examples.server') 21 | 22 | DEFAULT_BIND_ADDR = '0.0.0.0:5051' 23 | 24 | parser = OptionParser() 25 | parser.add_option( 26 | '-b', 27 | '--bind_addr', 28 | dest='bind_addrs', 29 | action='append', 30 | default=None, 31 | help=': for listening; may give multiple times; default {}'.format(DEFAULT_BIND_ADDR), 32 | ) 33 | parser.add_option( 34 | '-v', '--verbose', action='store_true', dest='verbose', default=False, help='show verbose logs' 35 | ) 36 | 37 | 38 | def main(): 39 | options, args = parser.parse_args() 40 | 41 | log_level = logging.DEBUG if options.verbose else logging.INFO 42 | if coloredlogs: 43 | coloredlogs.install(level=log_level) 44 | else: 45 | logging.basicConfig(level=log_level) 46 | 47 | client = pymidi.client.Client() 48 | host = '0.0.0.0' 49 | port = 5004 50 | logger.info(f'Connecting to RTP-MIDI server @ {host}:{port} ...') 51 | client.connect('0.0.0.0', port) 52 | logger.info('Connecting!') 53 | while True: 54 | logger.info('Striking key...') 55 | client.send_note_on('B6') 56 | time.sleep(0.5) 57 | client.send_note_off('B6') 58 | time.sleep(0.5) 59 | 60 | 61 | main() 62 | -------------------------------------------------------------------------------- /examples/example_server.py: -------------------------------------------------------------------------------- 1 | from builtins import bytes 2 | 3 | from optparse import OptionParser 4 | import logging 5 | import select 6 | import socket 7 | import sys 8 | 9 | import pymidi.server 10 | from pymidi.protocol import DataProtocol 11 | from pymidi.protocol import ControlProtocol 12 | from pymidi import utils 13 | 14 | try: 15 | import coloredlogs 16 | except ImportError: 17 | coloredlogs = None 18 | 19 | logger = logging.getLogger('pymidi.examples.server') 20 | 21 | DEFAULT_BIND_ADDR = '0.0.0.0:5051' 22 | 23 | parser = OptionParser() 24 | parser.add_option( 25 | '-b', 26 | '--bind_addr', 27 | dest='bind_addrs', 28 | action='append', 29 | default=None, 30 | help=': for listening; may give multiple times; default {}'.format(DEFAULT_BIND_ADDR), 31 | ) 32 | parser.add_option( 33 | '-v', '--verbose', action='store_true', dest='verbose', default=False, help='show verbose logs' 34 | ) 35 | 36 | 37 | def main(): 38 | options, args = parser.parse_args() 39 | 40 | log_level = logging.DEBUG if options.verbose else logging.INFO 41 | if coloredlogs: 42 | coloredlogs.install(level=log_level) 43 | else: 44 | logging.basicConfig(level=log_level) 45 | 46 | class ExampleHandler(pymidi.server.Handler): 47 | """Example handler. 48 | 49 | This handler doesn't do all that much; we're just using one here to 50 | illustrate the handler interface, so you can write a much cooler one. 51 | """ 52 | 53 | def __init__(self): 54 | self.logger = logging.getLogger('ExampleHandler') 55 | 56 | def on_peer_connected(self, peer): 57 | self.logger.info('Peer connected: {}'.format(peer)) 58 | 59 | def on_peer_disconnected(self, peer): 60 | self.logger.info('Peer disconnected: {}'.format(peer)) 61 | 62 | def on_midi_commands(self, peer, command_list): 63 | for command in command_list: 64 | if command.command == 'note_on': 65 | key = command.params.key 66 | velocity = command.params.velocity 67 | print('Someone hit the key {} with velocity {}'.format(key, velocity)) 68 | 69 | bind_addrs = options.bind_addrs 70 | if not bind_addrs: 71 | bind_addrs = [DEFAULT_BIND_ADDR] 72 | 73 | server = pymidi.server.Server.from_bind_addrs(bind_addrs) 74 | server.add_handler(ExampleHandler()) 75 | 76 | try: 77 | server.serve_forever() 78 | except KeyboardInterrupt: 79 | logger.info('Got CTRL-C, quitting') 80 | sys.exit(0) 81 | 82 | 83 | main() 84 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 1.5.1 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "atomicwrites" 5 | version = "1.4.1" 6 | description = "Atomic file writes." 7 | optional = false 8 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 9 | files = [ 10 | {file = "atomicwrites-1.4.1.tar.gz", hash = "sha256:81b2c9071a49367a7f770170e5eec8cb66567cfbbc8c73d20ce5ca4a8d71cf11"}, 11 | ] 12 | 13 | [[package]] 14 | name = "attrs" 15 | version = "23.1.0" 16 | description = "Classes Without Boilerplate" 17 | optional = false 18 | python-versions = ">=3.7" 19 | files = [ 20 | {file = "attrs-23.1.0-py3-none-any.whl", hash = "sha256:1f28b4522cdc2fb4256ac1a020c78acf9cba2c6b461ccd2c126f3aa8e8335d04"}, 21 | {file = "attrs-23.1.0.tar.gz", hash = "sha256:6279836d581513a26f1bf235f9acd333bc9115683f14f7e8fae46c98fc50e015"}, 22 | ] 23 | 24 | [package.extras] 25 | cov = ["attrs[tests]", "coverage[toml] (>=5.3)"] 26 | dev = ["attrs[docs,tests]", "pre-commit"] 27 | docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope-interface"] 28 | tests = ["attrs[tests-no-zope]", "zope-interface"] 29 | tests-no-zope = ["cloudpickle", "hypothesis", "mypy (>=1.1.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] 30 | 31 | [[package]] 32 | name = "bleach" 33 | version = "6.0.0" 34 | description = "An easy safelist-based HTML-sanitizing tool." 35 | optional = false 36 | python-versions = ">=3.7" 37 | files = [ 38 | {file = "bleach-6.0.0-py3-none-any.whl", hash = "sha256:33c16e3353dbd13028ab4799a0f89a83f113405c766e9c122df8a06f5b85b3f4"}, 39 | {file = "bleach-6.0.0.tar.gz", hash = "sha256:1a1a85c1595e07d8db14c5f09f09e6433502c51c595970edc090551f0db99414"}, 40 | ] 41 | 42 | [package.dependencies] 43 | six = ">=1.9.0" 44 | webencodings = "*" 45 | 46 | [package.extras] 47 | css = ["tinycss2 (>=1.1.0,<1.2)"] 48 | 49 | [[package]] 50 | name = "certifi" 51 | version = "2023.7.22" 52 | description = "Python package for providing Mozilla's CA Bundle." 53 | optional = false 54 | python-versions = ">=3.6" 55 | files = [ 56 | {file = "certifi-2023.7.22-py3-none-any.whl", hash = "sha256:92d6037539857d8206b8f6ae472e8b77db8058fec5937a1ef3f54304089edbb9"}, 57 | {file = "certifi-2023.7.22.tar.gz", hash = "sha256:539cc1d13202e33ca466e88b2807e29f4c13049d6d87031a3c110744495cb082"}, 58 | ] 59 | 60 | [[package]] 61 | name = "cffi" 62 | version = "1.15.1" 63 | description = "Foreign Function Interface for Python calling C code." 64 | optional = false 65 | python-versions = "*" 66 | files = [ 67 | {file = "cffi-1.15.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:a66d3508133af6e8548451b25058d5812812ec3798c886bf38ed24a98216fab2"}, 68 | {file = "cffi-1.15.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:470c103ae716238bbe698d67ad020e1db9d9dba34fa5a899b5e21577e6d52ed2"}, 69 | {file = "cffi-1.15.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:9ad5db27f9cabae298d151c85cf2bad1d359a1b9c686a275df03385758e2f914"}, 70 | {file = "cffi-1.15.1-cp27-cp27m-win32.whl", hash = "sha256:b3bbeb01c2b273cca1e1e0c5df57f12dce9a4dd331b4fa1635b8bec26350bde3"}, 71 | {file = "cffi-1.15.1-cp27-cp27m-win_amd64.whl", hash = "sha256:e00b098126fd45523dd056d2efba6c5a63b71ffe9f2bbe1a4fe1716e1d0c331e"}, 72 | {file = "cffi-1.15.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:d61f4695e6c866a23a21acab0509af1cdfd2c013cf256bbf5b6b5e2695827162"}, 73 | {file = "cffi-1.15.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:ed9cb427ba5504c1dc15ede7d516b84757c3e3d7868ccc85121d9310d27eed0b"}, 74 | {file = "cffi-1.15.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:39d39875251ca8f612b6f33e6b1195af86d1b3e60086068be9cc053aa4376e21"}, 75 | {file = "cffi-1.15.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:285d29981935eb726a4399badae8f0ffdff4f5050eaa6d0cfc3f64b857b77185"}, 76 | {file = "cffi-1.15.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3eb6971dcff08619f8d91607cfc726518b6fa2a9eba42856be181c6d0d9515fd"}, 77 | {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:21157295583fe8943475029ed5abdcf71eb3911894724e360acff1d61c1d54bc"}, 78 | {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5635bd9cb9731e6d4a1132a498dd34f764034a8ce60cef4f5319c0541159392f"}, 79 | {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2012c72d854c2d03e45d06ae57f40d78e5770d252f195b93f581acf3ba44496e"}, 80 | {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd86c085fae2efd48ac91dd7ccffcfc0571387fe1193d33b6394db7ef31fe2a4"}, 81 | {file = "cffi-1.15.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:fa6693661a4c91757f4412306191b6dc88c1703f780c8234035eac011922bc01"}, 82 | {file = "cffi-1.15.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:59c0b02d0a6c384d453fece7566d1c7e6b7bae4fc5874ef2ef46d56776d61c9e"}, 83 | {file = "cffi-1.15.1-cp310-cp310-win32.whl", hash = "sha256:cba9d6b9a7d64d4bd46167096fc9d2f835e25d7e4c121fb2ddfc6528fb0413b2"}, 84 | {file = "cffi-1.15.1-cp310-cp310-win_amd64.whl", hash = "sha256:ce4bcc037df4fc5e3d184794f27bdaab018943698f4ca31630bc7f84a7b69c6d"}, 85 | {file = "cffi-1.15.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3d08afd128ddaa624a48cf2b859afef385b720bb4b43df214f85616922e6a5ac"}, 86 | {file = "cffi-1.15.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3799aecf2e17cf585d977b780ce79ff0dc9b78d799fc694221ce814c2c19db83"}, 87 | {file = "cffi-1.15.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a591fe9e525846e4d154205572a029f653ada1a78b93697f3b5a8f1f2bc055b9"}, 88 | {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3548db281cd7d2561c9ad9984681c95f7b0e38881201e157833a2342c30d5e8c"}, 89 | {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91fc98adde3d7881af9b59ed0294046f3806221863722ba7d8d120c575314325"}, 90 | {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94411f22c3985acaec6f83c6df553f2dbe17b698cc7f8ae751ff2237d96b9e3c"}, 91 | {file = "cffi-1.15.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:03425bdae262c76aad70202debd780501fabeaca237cdfddc008987c0e0f59ef"}, 92 | {file = "cffi-1.15.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cc4d65aeeaa04136a12677d3dd0b1c0c94dc43abac5860ab33cceb42b801c1e8"}, 93 | {file = "cffi-1.15.1-cp311-cp311-win32.whl", hash = "sha256:a0f100c8912c114ff53e1202d0078b425bee3649ae34d7b070e9697f93c5d52d"}, 94 | {file = "cffi-1.15.1-cp311-cp311-win_amd64.whl", hash = "sha256:04ed324bda3cda42b9b695d51bb7d54b680b9719cfab04227cdd1e04e5de3104"}, 95 | {file = "cffi-1.15.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50a74364d85fd319352182ef59c5c790484a336f6db772c1a9231f1c3ed0cbd7"}, 96 | {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e263d77ee3dd201c3a142934a086a4450861778baaeeb45db4591ef65550b0a6"}, 97 | {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cec7d9412a9102bdc577382c3929b337320c4c4c4849f2c5cdd14d7368c5562d"}, 98 | {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4289fc34b2f5316fbb762d75362931e351941fa95fa18789191b33fc4cf9504a"}, 99 | {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:173379135477dc8cac4bc58f45db08ab45d228b3363adb7af79436135d028405"}, 100 | {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:6975a3fac6bc83c4a65c9f9fcab9e47019a11d3d2cf7f3c0d03431bf145a941e"}, 101 | {file = "cffi-1.15.1-cp36-cp36m-win32.whl", hash = "sha256:2470043b93ff09bf8fb1d46d1cb756ce6132c54826661a32d4e4d132e1977adf"}, 102 | {file = "cffi-1.15.1-cp36-cp36m-win_amd64.whl", hash = "sha256:30d78fbc8ebf9c92c9b7823ee18eb92f2e6ef79b45ac84db507f52fbe3ec4497"}, 103 | {file = "cffi-1.15.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:198caafb44239b60e252492445da556afafc7d1e3ab7a1fb3f0584ef6d742375"}, 104 | {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5ef34d190326c3b1f822a5b7a45f6c4535e2f47ed06fec77d3d799c450b2651e"}, 105 | {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8102eaf27e1e448db915d08afa8b41d6c7ca7a04b7d73af6514df10a3e74bd82"}, 106 | {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5df2768244d19ab7f60546d0c7c63ce1581f7af8b5de3eb3004b9b6fc8a9f84b"}, 107 | {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8c4917bd7ad33e8eb21e9a5bbba979b49d9a97acb3a803092cbc1133e20343c"}, 108 | {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2642fe3142e4cc4af0799748233ad6da94c62a8bec3a6648bf8ee68b1c7426"}, 109 | {file = "cffi-1.15.1-cp37-cp37m-win32.whl", hash = "sha256:e229a521186c75c8ad9490854fd8bbdd9a0c9aa3a524326b55be83b54d4e0ad9"}, 110 | {file = "cffi-1.15.1-cp37-cp37m-win_amd64.whl", hash = "sha256:a0b71b1b8fbf2b96e41c4d990244165e2c9be83d54962a9a1d118fd8657d2045"}, 111 | {file = "cffi-1.15.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:320dab6e7cb2eacdf0e658569d2575c4dad258c0fcc794f46215e1e39f90f2c3"}, 112 | {file = "cffi-1.15.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e74c6b51a9ed6589199c787bf5f9875612ca4a8a0785fb2d4a84429badaf22a"}, 113 | {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5c84c68147988265e60416b57fc83425a78058853509c1b0629c180094904a5"}, 114 | {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b926aa83d1edb5aa5b427b4053dc420ec295a08e40911296b9eb1b6170f6cca"}, 115 | {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87c450779d0914f2861b8526e035c5e6da0a3199d8f1add1a665e1cbc6fc6d02"}, 116 | {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f2c9f67e9821cad2e5f480bc8d83b8742896f1242dba247911072d4fa94c192"}, 117 | {file = "cffi-1.15.1-cp38-cp38-win32.whl", hash = "sha256:8b7ee99e510d7b66cdb6c593f21c043c248537a32e0bedf02e01e9553a172314"}, 118 | {file = "cffi-1.15.1-cp38-cp38-win_amd64.whl", hash = "sha256:00a9ed42e88df81ffae7a8ab6d9356b371399b91dbdf0c3cb1e84c03a13aceb5"}, 119 | {file = "cffi-1.15.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:54a2db7b78338edd780e7ef7f9f6c442500fb0d41a5a4ea24fff1c929d5af585"}, 120 | {file = "cffi-1.15.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fcd131dd944808b5bdb38e6f5b53013c5aa4f334c5cad0c72742f6eba4b73db0"}, 121 | {file = "cffi-1.15.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7473e861101c9e72452f9bf8acb984947aa1661a7704553a9f6e4baa5ba64415"}, 122 | {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c9a799e985904922a4d207a94eae35c78ebae90e128f0c4e521ce339396be9d"}, 123 | {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3bcde07039e586f91b45c88f8583ea7cf7a0770df3a1649627bf598332cb6984"}, 124 | {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33ab79603146aace82c2427da5ca6e58f2b3f2fb5da893ceac0c42218a40be35"}, 125 | {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d598b938678ebf3c67377cdd45e09d431369c3b1a5b331058c338e201f12b27"}, 126 | {file = "cffi-1.15.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:db0fbb9c62743ce59a9ff687eb5f4afbe77e5e8403d6697f7446e5f609976f76"}, 127 | {file = "cffi-1.15.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:98d85c6a2bef81588d9227dde12db8a7f47f639f4a17c9ae08e773aa9c697bf3"}, 128 | {file = "cffi-1.15.1-cp39-cp39-win32.whl", hash = "sha256:40f4774f5a9d4f5e344f31a32b5096977b5d48560c5592e2f3d2c4374bd543ee"}, 129 | {file = "cffi-1.15.1-cp39-cp39-win_amd64.whl", hash = "sha256:70df4e3b545a17496c9b3f41f5115e69a4f2e77e94e1d2a8e1070bc0c38c8a3c"}, 130 | {file = "cffi-1.15.1.tar.gz", hash = "sha256:d400bfb9a37b1351253cb402671cea7e89bdecc294e8016a707f6d1d8ac934f9"}, 131 | ] 132 | 133 | [package.dependencies] 134 | pycparser = "*" 135 | 136 | [[package]] 137 | name = "charset-normalizer" 138 | version = "3.2.0" 139 | description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." 140 | optional = false 141 | python-versions = ">=3.7.0" 142 | files = [ 143 | {file = "charset-normalizer-3.2.0.tar.gz", hash = "sha256:3bb3d25a8e6c0aedd251753a79ae98a093c7e7b471faa3aa9a93a81431987ace"}, 144 | {file = "charset_normalizer-3.2.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:0b87549028f680ca955556e3bd57013ab47474c3124dc069faa0b6545b6c9710"}, 145 | {file = "charset_normalizer-3.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7c70087bfee18a42b4040bb9ec1ca15a08242cf5867c58726530bdf3945672ed"}, 146 | {file = "charset_normalizer-3.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a103b3a7069b62f5d4890ae1b8f0597618f628b286b03d4bc9195230b154bfa9"}, 147 | {file = "charset_normalizer-3.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94aea8eff76ee6d1cdacb07dd2123a68283cb5569e0250feab1240058f53b623"}, 148 | {file = "charset_normalizer-3.2.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:db901e2ac34c931d73054d9797383d0f8009991e723dab15109740a63e7f902a"}, 149 | {file = "charset_normalizer-3.2.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b0dac0ff919ba34d4df1b6131f59ce95b08b9065233446be7e459f95554c0dc8"}, 150 | {file = "charset_normalizer-3.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:193cbc708ea3aca45e7221ae58f0fd63f933753a9bfb498a3b474878f12caaad"}, 151 | {file = "charset_normalizer-3.2.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09393e1b2a9461950b1c9a45d5fd251dc7c6f228acab64da1c9c0165d9c7765c"}, 152 | {file = "charset_normalizer-3.2.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:baacc6aee0b2ef6f3d308e197b5d7a81c0e70b06beae1f1fcacffdbd124fe0e3"}, 153 | {file = "charset_normalizer-3.2.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:bf420121d4c8dce6b889f0e8e4ec0ca34b7f40186203f06a946fa0276ba54029"}, 154 | {file = "charset_normalizer-3.2.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:c04a46716adde8d927adb9457bbe39cf473e1e2c2f5d0a16ceb837e5d841ad4f"}, 155 | {file = "charset_normalizer-3.2.0-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:aaf63899c94de41fe3cf934601b0f7ccb6b428c6e4eeb80da72c58eab077b19a"}, 156 | {file = "charset_normalizer-3.2.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d62e51710986674142526ab9f78663ca2b0726066ae26b78b22e0f5e571238dd"}, 157 | {file = "charset_normalizer-3.2.0-cp310-cp310-win32.whl", hash = "sha256:04e57ab9fbf9607b77f7d057974694b4f6b142da9ed4a199859d9d4d5c63fe96"}, 158 | {file = "charset_normalizer-3.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:48021783bdf96e3d6de03a6e39a1171ed5bd7e8bb93fc84cc649d11490f87cea"}, 159 | {file = "charset_normalizer-3.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4957669ef390f0e6719db3613ab3a7631e68424604a7b448f079bee145da6e09"}, 160 | {file = "charset_normalizer-3.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:46fb8c61d794b78ec7134a715a3e564aafc8f6b5e338417cb19fe9f57a5a9bf2"}, 161 | {file = "charset_normalizer-3.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f779d3ad205f108d14e99bb3859aa7dd8e9c68874617c72354d7ecaec2a054ac"}, 162 | {file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f25c229a6ba38a35ae6e25ca1264621cc25d4d38dca2942a7fce0b67a4efe918"}, 163 | {file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2efb1bd13885392adfda4614c33d3b68dee4921fd0ac1d3988f8cbb7d589e72a"}, 164 | {file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f30b48dd7fa1474554b0b0f3fdfdd4c13b5c737a3c6284d3cdc424ec0ffff3a"}, 165 | {file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:246de67b99b6851627d945db38147d1b209a899311b1305dd84916f2b88526c6"}, 166 | {file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9bd9b3b31adcb054116447ea22caa61a285d92e94d710aa5ec97992ff5eb7cf3"}, 167 | {file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:8c2f5e83493748286002f9369f3e6607c565a6a90425a3a1fef5ae32a36d749d"}, 168 | {file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:3170c9399da12c9dc66366e9d14da8bf7147e1e9d9ea566067bbce7bb74bd9c2"}, 169 | {file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:7a4826ad2bd6b07ca615c74ab91f32f6c96d08f6fcc3902ceeedaec8cdc3bcd6"}, 170 | {file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:3b1613dd5aee995ec6d4c69f00378bbd07614702a315a2cf6c1d21461fe17c23"}, 171 | {file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9e608aafdb55eb9f255034709e20d5a83b6d60c054df0802fa9c9883d0a937aa"}, 172 | {file = "charset_normalizer-3.2.0-cp311-cp311-win32.whl", hash = "sha256:f2a1d0fd4242bd8643ce6f98927cf9c04540af6efa92323e9d3124f57727bfc1"}, 173 | {file = "charset_normalizer-3.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:681eb3d7e02e3c3655d1b16059fbfb605ac464c834a0c629048a30fad2b27489"}, 174 | {file = "charset_normalizer-3.2.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c57921cda3a80d0f2b8aec7e25c8aa14479ea92b5b51b6876d975d925a2ea346"}, 175 | {file = "charset_normalizer-3.2.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:41b25eaa7d15909cf3ac4c96088c1f266a9a93ec44f87f1d13d4a0e86c81b982"}, 176 | {file = "charset_normalizer-3.2.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f058f6963fd82eb143c692cecdc89e075fa0828db2e5b291070485390b2f1c9c"}, 177 | {file = "charset_normalizer-3.2.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a7647ebdfb9682b7bb97e2a5e7cb6ae735b1c25008a70b906aecca294ee96cf4"}, 178 | {file = "charset_normalizer-3.2.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eef9df1eefada2c09a5e7a40991b9fc6ac6ef20b1372abd48d2794a316dc0449"}, 179 | {file = "charset_normalizer-3.2.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e03b8895a6990c9ab2cdcd0f2fe44088ca1c65ae592b8f795c3294af00a461c3"}, 180 | {file = "charset_normalizer-3.2.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:ee4006268ed33370957f55bf2e6f4d263eaf4dc3cfc473d1d90baff6ed36ce4a"}, 181 | {file = "charset_normalizer-3.2.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:c4983bf937209c57240cff65906b18bb35e64ae872da6a0db937d7b4af845dd7"}, 182 | {file = "charset_normalizer-3.2.0-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:3bb7fda7260735efe66d5107fb7e6af6a7c04c7fce9b2514e04b7a74b06bf5dd"}, 183 | {file = "charset_normalizer-3.2.0-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:72814c01533f51d68702802d74f77ea026b5ec52793c791e2da806a3844a46c3"}, 184 | {file = "charset_normalizer-3.2.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:70c610f6cbe4b9fce272c407dd9d07e33e6bf7b4aa1b7ffb6f6ded8e634e3592"}, 185 | {file = "charset_normalizer-3.2.0-cp37-cp37m-win32.whl", hash = "sha256:a401b4598e5d3f4a9a811f3daf42ee2291790c7f9d74b18d75d6e21dda98a1a1"}, 186 | {file = "charset_normalizer-3.2.0-cp37-cp37m-win_amd64.whl", hash = "sha256:c0b21078a4b56965e2b12f247467b234734491897e99c1d51cee628da9786959"}, 187 | {file = "charset_normalizer-3.2.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:95eb302ff792e12aba9a8b8f8474ab229a83c103d74a750ec0bd1c1eea32e669"}, 188 | {file = "charset_normalizer-3.2.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1a100c6d595a7f316f1b6f01d20815d916e75ff98c27a01ae817439ea7726329"}, 189 | {file = "charset_normalizer-3.2.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:6339d047dab2780cc6220f46306628e04d9750f02f983ddb37439ca47ced7149"}, 190 | {file = "charset_normalizer-3.2.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4b749b9cc6ee664a3300bb3a273c1ca8068c46be705b6c31cf5d276f8628a94"}, 191 | {file = "charset_normalizer-3.2.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a38856a971c602f98472050165cea2cdc97709240373041b69030be15047691f"}, 192 | {file = "charset_normalizer-3.2.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f87f746ee241d30d6ed93969de31e5ffd09a2961a051e60ae6bddde9ec3583aa"}, 193 | {file = "charset_normalizer-3.2.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:89f1b185a01fe560bc8ae5f619e924407efca2191b56ce749ec84982fc59a32a"}, 194 | {file = "charset_normalizer-3.2.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1c8a2f4c69e08e89632defbfabec2feb8a8d99edc9f89ce33c4b9e36ab63037"}, 195 | {file = "charset_normalizer-3.2.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2f4ac36d8e2b4cc1aa71df3dd84ff8efbe3bfb97ac41242fbcfc053c67434f46"}, 196 | {file = "charset_normalizer-3.2.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a386ebe437176aab38c041de1260cd3ea459c6ce5263594399880bbc398225b2"}, 197 | {file = "charset_normalizer-3.2.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:ccd16eb18a849fd8dcb23e23380e2f0a354e8daa0c984b8a732d9cfaba3a776d"}, 198 | {file = "charset_normalizer-3.2.0-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:e6a5bf2cba5ae1bb80b154ed68a3cfa2fa00fde979a7f50d6598d3e17d9ac20c"}, 199 | {file = "charset_normalizer-3.2.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:45de3f87179c1823e6d9e32156fb14c1927fcc9aba21433f088fdfb555b77c10"}, 200 | {file = "charset_normalizer-3.2.0-cp38-cp38-win32.whl", hash = "sha256:1000fba1057b92a65daec275aec30586c3de2401ccdcd41f8a5c1e2c87078706"}, 201 | {file = "charset_normalizer-3.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:8b2c760cfc7042b27ebdb4a43a4453bd829a5742503599144d54a032c5dc7e9e"}, 202 | {file = "charset_normalizer-3.2.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:855eafa5d5a2034b4621c74925d89c5efef61418570e5ef9b37717d9c796419c"}, 203 | {file = "charset_normalizer-3.2.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:203f0c8871d5a7987be20c72442488a0b8cfd0f43b7973771640fc593f56321f"}, 204 | {file = "charset_normalizer-3.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e857a2232ba53ae940d3456f7533ce6ca98b81917d47adc3c7fd55dad8fab858"}, 205 | {file = "charset_normalizer-3.2.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5e86d77b090dbddbe78867a0275cb4df08ea195e660f1f7f13435a4649e954e5"}, 206 | {file = "charset_normalizer-3.2.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c4fb39a81950ec280984b3a44f5bd12819953dc5fa3a7e6fa7a80db5ee853952"}, 207 | {file = "charset_normalizer-3.2.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2dee8e57f052ef5353cf608e0b4c871aee320dd1b87d351c28764fc0ca55f9f4"}, 208 | {file = "charset_normalizer-3.2.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8700f06d0ce6f128de3ccdbc1acaea1ee264d2caa9ca05daaf492fde7c2a7200"}, 209 | {file = "charset_normalizer-3.2.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1920d4ff15ce893210c1f0c0e9d19bfbecb7983c76b33f046c13a8ffbd570252"}, 210 | {file = "charset_normalizer-3.2.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:c1c76a1743432b4b60ab3358c937a3fe1341c828ae6194108a94c69028247f22"}, 211 | {file = "charset_normalizer-3.2.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f7560358a6811e52e9c4d142d497f1a6e10103d3a6881f18d04dbce3729c0e2c"}, 212 | {file = "charset_normalizer-3.2.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:c8063cf17b19661471ecbdb3df1c84f24ad2e389e326ccaf89e3fb2484d8dd7e"}, 213 | {file = "charset_normalizer-3.2.0-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:cd6dbe0238f7743d0efe563ab46294f54f9bc8f4b9bcf57c3c666cc5bc9d1299"}, 214 | {file = "charset_normalizer-3.2.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:1249cbbf3d3b04902ff081ffbb33ce3377fa6e4c7356f759f3cd076cc138d020"}, 215 | {file = "charset_normalizer-3.2.0-cp39-cp39-win32.whl", hash = "sha256:6c409c0deba34f147f77efaa67b8e4bb83d2f11c8806405f76397ae5b8c0d1c9"}, 216 | {file = "charset_normalizer-3.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:7095f6fbfaa55defb6b733cfeb14efaae7a29f0b59d8cf213be4e7ca0b857b80"}, 217 | {file = "charset_normalizer-3.2.0-py3-none-any.whl", hash = "sha256:8e098148dd37b4ce3baca71fb394c81dc5d9c7728c95df695d2dca218edf40e6"}, 218 | ] 219 | 220 | [[package]] 221 | name = "colorama" 222 | version = "0.4.6" 223 | description = "Cross-platform colored terminal text." 224 | optional = false 225 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 226 | files = [ 227 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 228 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 229 | ] 230 | 231 | [[package]] 232 | name = "coloredlogs" 233 | version = "15.0.1" 234 | description = "Colored terminal output for Python's logging module" 235 | optional = false 236 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 237 | files = [ 238 | {file = "coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934"}, 239 | {file = "coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0"}, 240 | ] 241 | 242 | [package.dependencies] 243 | humanfriendly = ">=9.1" 244 | 245 | [package.extras] 246 | cron = ["capturer (>=2.4)"] 247 | 248 | [[package]] 249 | name = "construct" 250 | version = "2.10.68" 251 | description = "A powerful declarative symmetric parser/builder for binary data" 252 | optional = false 253 | python-versions = ">=3.6" 254 | files = [ 255 | {file = "construct-2.10.68.tar.gz", hash = "sha256:7b2a3fd8e5f597a5aa1d614c3bd516fa065db01704c72a1efaaeec6ef23d8b45"}, 256 | ] 257 | 258 | [package.extras] 259 | extras = ["arrow", "cloudpickle", "enum34", "lz4", "numpy", "ruamel.yaml"] 260 | 261 | [[package]] 262 | name = "cryptography" 263 | version = "41.0.3" 264 | description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." 265 | optional = false 266 | python-versions = ">=3.7" 267 | files = [ 268 | {file = "cryptography-41.0.3-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:652627a055cb52a84f8c448185922241dd5217443ca194d5739b44612c5e6507"}, 269 | {file = "cryptography-41.0.3-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:8f09daa483aedea50d249ef98ed500569841d6498aa9c9f4b0531b9964658922"}, 270 | {file = "cryptography-41.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fd871184321100fb400d759ad0cddddf284c4b696568204d281c902fc7b0d81"}, 271 | {file = "cryptography-41.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:84537453d57f55a50a5b6835622ee405816999a7113267739a1b4581f83535bd"}, 272 | {file = "cryptography-41.0.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:3fb248989b6363906827284cd20cca63bb1a757e0a2864d4c1682a985e3dca47"}, 273 | {file = "cryptography-41.0.3-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:42cb413e01a5d36da9929baa9d70ca90d90b969269e5a12d39c1e0d475010116"}, 274 | {file = "cryptography-41.0.3-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:aeb57c421b34af8f9fe830e1955bf493a86a7996cc1338fe41b30047d16e962c"}, 275 | {file = "cryptography-41.0.3-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:6af1c6387c531cd364b72c28daa29232162010d952ceb7e5ca8e2827526aceae"}, 276 | {file = "cryptography-41.0.3-cp37-abi3-win32.whl", hash = "sha256:0d09fb5356f975974dbcb595ad2d178305e5050656affb7890a1583f5e02a306"}, 277 | {file = "cryptography-41.0.3-cp37-abi3-win_amd64.whl", hash = "sha256:a983e441a00a9d57a4d7c91b3116a37ae602907a7618b882c8013b5762e80574"}, 278 | {file = "cryptography-41.0.3-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5259cb659aa43005eb55a0e4ff2c825ca111a0da1814202c64d28a985d33b087"}, 279 | {file = "cryptography-41.0.3-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:67e120e9a577c64fe1f611e53b30b3e69744e5910ff3b6e97e935aeb96005858"}, 280 | {file = "cryptography-41.0.3-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:7efe8041897fe7a50863e51b77789b657a133c75c3b094e51b5e4b5cec7bf906"}, 281 | {file = "cryptography-41.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ce785cf81a7bdade534297ef9e490ddff800d956625020ab2ec2780a556c313e"}, 282 | {file = "cryptography-41.0.3-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:57a51b89f954f216a81c9d057bf1a24e2f36e764a1ca9a501a6964eb4a6800dd"}, 283 | {file = "cryptography-41.0.3-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:4c2f0d35703d61002a2bbdcf15548ebb701cfdd83cdc12471d2bae80878a4207"}, 284 | {file = "cryptography-41.0.3-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:23c2d778cf829f7d0ae180600b17e9fceea3c2ef8b31a99e3c694cbbf3a24b84"}, 285 | {file = "cryptography-41.0.3-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:95dd7f261bb76948b52a5330ba5202b91a26fbac13ad0e9fc8a3ac04752058c7"}, 286 | {file = "cryptography-41.0.3-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:41d7aa7cdfded09b3d73a47f429c298e80796c8e825ddfadc84c8a7f12df212d"}, 287 | {file = "cryptography-41.0.3-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:d0d651aa754ef58d75cec6edfbd21259d93810b73f6ec246436a21b7841908de"}, 288 | {file = "cryptography-41.0.3-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:ab8de0d091acbf778f74286f4989cf3d1528336af1b59f3e5d2ebca8b5fe49e1"}, 289 | {file = "cryptography-41.0.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a74fbcdb2a0d46fe00504f571a2a540532f4c188e6ccf26f1f178480117b33c4"}, 290 | {file = "cryptography-41.0.3.tar.gz", hash = "sha256:6d192741113ef5e30d89dcb5b956ef4e1578f304708701b8b73d38e3e1461f34"}, 291 | ] 292 | 293 | [package.dependencies] 294 | cffi = ">=1.12" 295 | 296 | [package.extras] 297 | docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"] 298 | docstest = ["pyenchant (>=1.6.11)", "sphinxcontrib-spelling (>=4.0.1)", "twine (>=1.12.0)"] 299 | nox = ["nox"] 300 | pep8test = ["black", "check-sdist", "mypy", "ruff"] 301 | sdist = ["build"] 302 | ssh = ["bcrypt (>=3.1.5)"] 303 | test = ["pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] 304 | test-randomorder = ["pytest-randomly"] 305 | 306 | [[package]] 307 | name = "docutils" 308 | version = "0.20.1" 309 | description = "Docutils -- Python Documentation Utilities" 310 | optional = false 311 | python-versions = ">=3.7" 312 | files = [ 313 | {file = "docutils-0.20.1-py3-none-any.whl", hash = "sha256:96f387a2c5562db4476f09f13bbab2192e764cac08ebbf3a34a95d9b1e4a59d6"}, 314 | {file = "docutils-0.20.1.tar.gz", hash = "sha256:f08a4e276c3a1583a86dce3e34aba3fe04d02bba2dd51ed16106244e8a923e3b"}, 315 | ] 316 | 317 | [[package]] 318 | name = "flake8" 319 | version = "3.9.2" 320 | description = "the modular source code checker: pep8 pyflakes and co" 321 | optional = false 322 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" 323 | files = [ 324 | {file = "flake8-3.9.2-py2.py3-none-any.whl", hash = "sha256:bf8fd333346d844f616e8d47905ef3a3384edae6b4e9beb0c5101e25e3110907"}, 325 | {file = "flake8-3.9.2.tar.gz", hash = "sha256:07528381786f2a6237b061f6e96610a4167b226cb926e2aa2b6b1d78057c576b"}, 326 | ] 327 | 328 | [package.dependencies] 329 | mccabe = ">=0.6.0,<0.7.0" 330 | pycodestyle = ">=2.7.0,<2.8.0" 331 | pyflakes = ">=2.3.0,<2.4.0" 332 | 333 | [[package]] 334 | name = "humanfriendly" 335 | version = "10.0" 336 | description = "Human friendly output for text interfaces using Python" 337 | optional = false 338 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 339 | files = [ 340 | {file = "humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477"}, 341 | {file = "humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc"}, 342 | ] 343 | 344 | [package.dependencies] 345 | pyreadline3 = {version = "*", markers = "sys_platform == \"win32\" and python_version >= \"3.8\""} 346 | 347 | [[package]] 348 | name = "idna" 349 | version = "3.4" 350 | description = "Internationalized Domain Names in Applications (IDNA)" 351 | optional = false 352 | python-versions = ">=3.5" 353 | files = [ 354 | {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, 355 | {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, 356 | ] 357 | 358 | [[package]] 359 | name = "importlib-metadata" 360 | version = "6.8.0" 361 | description = "Read metadata from Python packages" 362 | optional = false 363 | python-versions = ">=3.8" 364 | files = [ 365 | {file = "importlib_metadata-6.8.0-py3-none-any.whl", hash = "sha256:3ebb78df84a805d7698245025b975d9d67053cd94c79245ba4b3eb694abe68bb"}, 366 | {file = "importlib_metadata-6.8.0.tar.gz", hash = "sha256:dbace7892d8c0c4ac1ad096662232f831d4e64f4c4545bd53016a3e9d4654743"}, 367 | ] 368 | 369 | [package.dependencies] 370 | zipp = ">=0.5" 371 | 372 | [package.extras] 373 | docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] 374 | perf = ["ipython"] 375 | testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)", "pytest-ruff"] 376 | 377 | [[package]] 378 | name = "importlib-resources" 379 | version = "6.0.1" 380 | description = "Read resources from Python packages" 381 | optional = false 382 | python-versions = ">=3.8" 383 | files = [ 384 | {file = "importlib_resources-6.0.1-py3-none-any.whl", hash = "sha256:134832a506243891221b88b4ae1213327eea96ceb4e407a00d790bb0626f45cf"}, 385 | {file = "importlib_resources-6.0.1.tar.gz", hash = "sha256:4359457e42708462b9626a04657c6208ad799ceb41e5c58c57ffa0e6a098a5d4"}, 386 | ] 387 | 388 | [package.dependencies] 389 | zipp = {version = ">=3.1.0", markers = "python_version < \"3.10\""} 390 | 391 | [package.extras] 392 | docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] 393 | testing = ["pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-ruff"] 394 | 395 | [[package]] 396 | name = "iniconfig" 397 | version = "2.0.0" 398 | description = "brain-dead simple config-ini parsing" 399 | optional = false 400 | python-versions = ">=3.7" 401 | files = [ 402 | {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, 403 | {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, 404 | ] 405 | 406 | [[package]] 407 | name = "jaraco-classes" 408 | version = "3.3.0" 409 | description = "Utility functions for Python class constructs" 410 | optional = false 411 | python-versions = ">=3.8" 412 | files = [ 413 | {file = "jaraco.classes-3.3.0-py3-none-any.whl", hash = "sha256:10afa92b6743f25c0cf5f37c6bb6e18e2c5bb84a16527ccfc0040ea377e7aaeb"}, 414 | {file = "jaraco.classes-3.3.0.tar.gz", hash = "sha256:c063dd08e89217cee02c8d5e5ec560f2c8ce6cdc2fcdc2e68f7b2e5547ed3621"}, 415 | ] 416 | 417 | [package.dependencies] 418 | more-itertools = "*" 419 | 420 | [package.extras] 421 | docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] 422 | testing = ["pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-ruff"] 423 | 424 | [[package]] 425 | name = "jeepney" 426 | version = "0.8.0" 427 | description = "Low-level, pure Python DBus protocol wrapper." 428 | optional = false 429 | python-versions = ">=3.7" 430 | files = [ 431 | {file = "jeepney-0.8.0-py3-none-any.whl", hash = "sha256:c0a454ad016ca575060802ee4d590dd912e35c122fa04e70306de3d076cce755"}, 432 | {file = "jeepney-0.8.0.tar.gz", hash = "sha256:5efe48d255973902f6badc3ce55e2aa6c5c3b3bc642059ef3a91247bcfcc5806"}, 433 | ] 434 | 435 | [package.extras] 436 | test = ["async-timeout", "pytest", "pytest-asyncio (>=0.17)", "pytest-trio", "testpath", "trio"] 437 | trio = ["async_generator", "trio"] 438 | 439 | [[package]] 440 | name = "keyring" 441 | version = "24.2.0" 442 | description = "Store and access your passwords safely." 443 | optional = false 444 | python-versions = ">=3.8" 445 | files = [ 446 | {file = "keyring-24.2.0-py3-none-any.whl", hash = "sha256:4901caaf597bfd3bbd78c9a0c7c4c29fcd8310dab2cffefe749e916b6527acd6"}, 447 | {file = "keyring-24.2.0.tar.gz", hash = "sha256:ca0746a19ec421219f4d713f848fa297a661a8a8c1504867e55bfb5e09091509"}, 448 | ] 449 | 450 | [package.dependencies] 451 | importlib-metadata = {version = ">=4.11.4", markers = "python_version < \"3.12\""} 452 | importlib-resources = {version = "*", markers = "python_version < \"3.9\""} 453 | "jaraco.classes" = "*" 454 | jeepney = {version = ">=0.4.2", markers = "sys_platform == \"linux\""} 455 | pywin32-ctypes = {version = ">=0.2.0", markers = "sys_platform == \"win32\""} 456 | SecretStorage = {version = ">=3.2", markers = "sys_platform == \"linux\""} 457 | 458 | [package.extras] 459 | completion = ["shtab"] 460 | docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] 461 | testing = ["pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-mypy (>=0.9.1)", "pytest-ruff"] 462 | 463 | [[package]] 464 | name = "mccabe" 465 | version = "0.6.1" 466 | description = "McCabe checker, plugin for flake8" 467 | optional = false 468 | python-versions = "*" 469 | files = [ 470 | {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, 471 | {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, 472 | ] 473 | 474 | [[package]] 475 | name = "mock" 476 | version = "4.0.3" 477 | description = "Rolling backport of unittest.mock for all Pythons" 478 | optional = false 479 | python-versions = ">=3.6" 480 | files = [ 481 | {file = "mock-4.0.3-py3-none-any.whl", hash = "sha256:122fcb64ee37cfad5b3f48d7a7d51875d7031aaf3d8be7c42e2bee25044eee62"}, 482 | {file = "mock-4.0.3.tar.gz", hash = "sha256:7d3fbbde18228f4ff2f1f119a45cdffa458b4c0dee32eb4d2bb2f82554bac7bc"}, 483 | ] 484 | 485 | [package.extras] 486 | build = ["blurb", "twine", "wheel"] 487 | docs = ["sphinx"] 488 | test = ["pytest (<5.4)", "pytest-cov"] 489 | 490 | [[package]] 491 | name = "more-itertools" 492 | version = "10.1.0" 493 | description = "More routines for operating on iterables, beyond itertools" 494 | optional = false 495 | python-versions = ">=3.8" 496 | files = [ 497 | {file = "more-itertools-10.1.0.tar.gz", hash = "sha256:626c369fa0eb37bac0291bce8259b332fd59ac792fa5497b59837309cd5b114a"}, 498 | {file = "more_itertools-10.1.0-py3-none-any.whl", hash = "sha256:64e0735fcfdc6f3464ea133afe8ea4483b1c5fe3a3d69852e6503b43a0b222e6"}, 499 | ] 500 | 501 | [[package]] 502 | name = "packaging" 503 | version = "23.1" 504 | description = "Core utilities for Python packages" 505 | optional = false 506 | python-versions = ">=3.7" 507 | files = [ 508 | {file = "packaging-23.1-py3-none-any.whl", hash = "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61"}, 509 | {file = "packaging-23.1.tar.gz", hash = "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f"}, 510 | ] 511 | 512 | [[package]] 513 | name = "pkginfo" 514 | version = "1.9.6" 515 | description = "Query metadata from sdists / bdists / installed packages." 516 | optional = false 517 | python-versions = ">=3.6" 518 | files = [ 519 | {file = "pkginfo-1.9.6-py3-none-any.whl", hash = "sha256:4b7a555a6d5a22169fcc9cf7bfd78d296b0361adad412a346c1226849af5e546"}, 520 | {file = "pkginfo-1.9.6.tar.gz", hash = "sha256:8fd5896e8718a4372f0ea9cc9d96f6417c9b986e23a4d116dda26b62cc29d046"}, 521 | ] 522 | 523 | [package.extras] 524 | testing = ["pytest", "pytest-cov"] 525 | 526 | [[package]] 527 | name = "pluggy" 528 | version = "1.3.0" 529 | description = "plugin and hook calling mechanisms for python" 530 | optional = false 531 | python-versions = ">=3.8" 532 | files = [ 533 | {file = "pluggy-1.3.0-py3-none-any.whl", hash = "sha256:d89c696a773f8bd377d18e5ecda92b7a3793cbe66c87060a6fb58c7b6e1061f7"}, 534 | {file = "pluggy-1.3.0.tar.gz", hash = "sha256:cf61ae8f126ac6f7c451172cf30e3e43d3ca77615509771b3a984a0730651e12"}, 535 | ] 536 | 537 | [package.extras] 538 | dev = ["pre-commit", "tox"] 539 | testing = ["pytest", "pytest-benchmark"] 540 | 541 | [[package]] 542 | name = "py" 543 | version = "1.11.0" 544 | description = "library with cross-python path, ini-parsing, io, code, log facilities" 545 | optional = false 546 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 547 | files = [ 548 | {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, 549 | {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, 550 | ] 551 | 552 | [[package]] 553 | name = "pycodestyle" 554 | version = "2.7.0" 555 | description = "Python style guide checker" 556 | optional = false 557 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 558 | files = [ 559 | {file = "pycodestyle-2.7.0-py2.py3-none-any.whl", hash = "sha256:514f76d918fcc0b55c6680472f0a37970994e07bbb80725808c17089be302068"}, 560 | {file = "pycodestyle-2.7.0.tar.gz", hash = "sha256:c389c1d06bf7904078ca03399a4816f974a1d590090fecea0c63ec26ebaf1cef"}, 561 | ] 562 | 563 | [[package]] 564 | name = "pycparser" 565 | version = "2.21" 566 | description = "C parser in Python" 567 | optional = false 568 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 569 | files = [ 570 | {file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"}, 571 | {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, 572 | ] 573 | 574 | [[package]] 575 | name = "pyflakes" 576 | version = "2.3.1" 577 | description = "passive checker of Python programs" 578 | optional = false 579 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 580 | files = [ 581 | {file = "pyflakes-2.3.1-py2.py3-none-any.whl", hash = "sha256:7893783d01b8a89811dd72d7dfd4d84ff098e5eed95cfa8905b22bbffe52efc3"}, 582 | {file = "pyflakes-2.3.1.tar.gz", hash = "sha256:f5bc8ecabc05bb9d291eb5203d6810b49040f6ff446a756326104746cc00c1db"}, 583 | ] 584 | 585 | [[package]] 586 | name = "pygments" 587 | version = "2.16.1" 588 | description = "Pygments is a syntax highlighting package written in Python." 589 | optional = false 590 | python-versions = ">=3.7" 591 | files = [ 592 | {file = "Pygments-2.16.1-py3-none-any.whl", hash = "sha256:13fc09fa63bc8d8671a6d247e1eb303c4b343eaee81d861f3404db2935653692"}, 593 | {file = "Pygments-2.16.1.tar.gz", hash = "sha256:1daff0494820c69bc8941e407aa20f577374ee88364ee10a98fdbe0aece96e29"}, 594 | ] 595 | 596 | [package.extras] 597 | plugins = ["importlib-metadata"] 598 | 599 | [[package]] 600 | name = "pyreadline3" 601 | version = "3.4.1" 602 | description = "A python implementation of GNU readline." 603 | optional = false 604 | python-versions = "*" 605 | files = [ 606 | {file = "pyreadline3-3.4.1-py3-none-any.whl", hash = "sha256:b0efb6516fd4fb07b45949053826a62fa4cb353db5be2bbb4a7aa1fdd1e345fb"}, 607 | {file = "pyreadline3-3.4.1.tar.gz", hash = "sha256:6f3d1f7b8a31ba32b73917cefc1f28cc660562f39aea8646d30bd6eff21f7bae"}, 608 | ] 609 | 610 | [[package]] 611 | name = "pytest" 612 | version = "6.2.5" 613 | description = "pytest: simple powerful testing with Python" 614 | optional = false 615 | python-versions = ">=3.6" 616 | files = [ 617 | {file = "pytest-6.2.5-py3-none-any.whl", hash = "sha256:7310f8d27bc79ced999e760ca304d69f6ba6c6649c0b60fb0e04a4a77cacc134"}, 618 | {file = "pytest-6.2.5.tar.gz", hash = "sha256:131b36680866a76e6781d13f101efb86cf674ebb9762eb70d3082b6f29889e89"}, 619 | ] 620 | 621 | [package.dependencies] 622 | atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} 623 | attrs = ">=19.2.0" 624 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 625 | iniconfig = "*" 626 | packaging = "*" 627 | pluggy = ">=0.12,<2.0" 628 | py = ">=1.8.2" 629 | toml = "*" 630 | 631 | [package.extras] 632 | testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] 633 | 634 | [[package]] 635 | name = "pywin32-ctypes" 636 | version = "0.2.2" 637 | description = "A (partial) reimplementation of pywin32 using ctypes/cffi" 638 | optional = false 639 | python-versions = ">=3.6" 640 | files = [ 641 | {file = "pywin32-ctypes-0.2.2.tar.gz", hash = "sha256:3426e063bdd5fd4df74a14fa3cf80a0b42845a87e1d1e81f6549f9daec593a60"}, 642 | {file = "pywin32_ctypes-0.2.2-py3-none-any.whl", hash = "sha256:bf490a1a709baf35d688fe0ecf980ed4de11d2b3e37b51e5442587a75d9957e7"}, 643 | ] 644 | 645 | [[package]] 646 | name = "readme-renderer" 647 | version = "41.0" 648 | description = "readme_renderer is a library for rendering \"readme\" descriptions for Warehouse" 649 | optional = false 650 | python-versions = ">=3.8" 651 | files = [ 652 | {file = "readme_renderer-41.0-py3-none-any.whl", hash = "sha256:a38243d5b6741b700a850026e62da4bd739edc7422071e95fd5c4bb60171df86"}, 653 | {file = "readme_renderer-41.0.tar.gz", hash = "sha256:4f4b11e5893f5a5d725f592c5a343e0dc74f5f273cb3dcf8c42d9703a27073f7"}, 654 | ] 655 | 656 | [package.dependencies] 657 | bleach = ">=2.1.0" 658 | docutils = ">=0.13.1" 659 | Pygments = ">=2.5.1" 660 | 661 | [package.extras] 662 | md = ["cmarkgfm (>=0.8.0)"] 663 | 664 | [[package]] 665 | name = "requests" 666 | version = "2.31.0" 667 | description = "Python HTTP for Humans." 668 | optional = false 669 | python-versions = ">=3.7" 670 | files = [ 671 | {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"}, 672 | {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"}, 673 | ] 674 | 675 | [package.dependencies] 676 | certifi = ">=2017.4.17" 677 | charset-normalizer = ">=2,<4" 678 | idna = ">=2.5,<4" 679 | urllib3 = ">=1.21.1,<3" 680 | 681 | [package.extras] 682 | socks = ["PySocks (>=1.5.6,!=1.5.7)"] 683 | use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] 684 | 685 | [[package]] 686 | name = "requests-toolbelt" 687 | version = "1.0.0" 688 | description = "A utility belt for advanced users of python-requests" 689 | optional = false 690 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 691 | files = [ 692 | {file = "requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6"}, 693 | {file = "requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06"}, 694 | ] 695 | 696 | [package.dependencies] 697 | requests = ">=2.0.1,<3.0.0" 698 | 699 | [[package]] 700 | name = "rfc3986" 701 | version = "2.0.0" 702 | description = "Validating URI References per RFC 3986" 703 | optional = false 704 | python-versions = ">=3.7" 705 | files = [ 706 | {file = "rfc3986-2.0.0-py2.py3-none-any.whl", hash = "sha256:50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd"}, 707 | {file = "rfc3986-2.0.0.tar.gz", hash = "sha256:97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c"}, 708 | ] 709 | 710 | [package.extras] 711 | idna2008 = ["idna"] 712 | 713 | [[package]] 714 | name = "secretstorage" 715 | version = "3.3.3" 716 | description = "Python bindings to FreeDesktop.org Secret Service API" 717 | optional = false 718 | python-versions = ">=3.6" 719 | files = [ 720 | {file = "SecretStorage-3.3.3-py3-none-any.whl", hash = "sha256:f356e6628222568e3af06f2eba8df495efa13b3b63081dafd4f7d9a7b7bc9f99"}, 721 | {file = "SecretStorage-3.3.3.tar.gz", hash = "sha256:2403533ef369eca6d2ba81718576c5e0f564d5cca1b58f73a8b23e7d4eeebd77"}, 722 | ] 723 | 724 | [package.dependencies] 725 | cryptography = ">=2.0" 726 | jeepney = ">=0.6" 727 | 728 | [[package]] 729 | name = "six" 730 | version = "1.16.0" 731 | description = "Python 2 and 3 compatibility utilities" 732 | optional = false 733 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" 734 | files = [ 735 | {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, 736 | {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, 737 | ] 738 | 739 | [[package]] 740 | name = "toml" 741 | version = "0.10.2" 742 | description = "Python Library for Tom's Obvious, Minimal Language" 743 | optional = false 744 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" 745 | files = [ 746 | {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, 747 | {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, 748 | ] 749 | 750 | [[package]] 751 | name = "tqdm" 752 | version = "4.66.1" 753 | description = "Fast, Extensible Progress Meter" 754 | optional = false 755 | python-versions = ">=3.7" 756 | files = [ 757 | {file = "tqdm-4.66.1-py3-none-any.whl", hash = "sha256:d302b3c5b53d47bce91fea46679d9c3c6508cf6332229aa1e7d8653723793386"}, 758 | {file = "tqdm-4.66.1.tar.gz", hash = "sha256:d88e651f9db8d8551a62556d3cff9e3034274ca5d66e93197cf2490e2dcb69c7"}, 759 | ] 760 | 761 | [package.dependencies] 762 | colorama = {version = "*", markers = "platform_system == \"Windows\""} 763 | 764 | [package.extras] 765 | dev = ["pytest (>=6)", "pytest-cov", "pytest-timeout", "pytest-xdist"] 766 | notebook = ["ipywidgets (>=6)"] 767 | slack = ["slack-sdk"] 768 | telegram = ["requests"] 769 | 770 | [[package]] 771 | name = "twine" 772 | version = "3.8.0" 773 | description = "Collection of utilities for publishing packages on PyPI" 774 | optional = false 775 | python-versions = ">=3.6" 776 | files = [ 777 | {file = "twine-3.8.0-py3-none-any.whl", hash = "sha256:d0550fca9dc19f3d5e8eadfce0c227294df0a2a951251a4385797c8a6198b7c8"}, 778 | {file = "twine-3.8.0.tar.gz", hash = "sha256:8efa52658e0ae770686a13b675569328f1fba9837e5de1867bfe5f46a9aefe19"}, 779 | ] 780 | 781 | [package.dependencies] 782 | colorama = ">=0.4.3" 783 | importlib-metadata = ">=3.6" 784 | keyring = ">=15.1" 785 | pkginfo = ">=1.8.1" 786 | readme-renderer = ">=21.0" 787 | requests = ">=2.20" 788 | requests-toolbelt = ">=0.8.0,<0.9.0 || >0.9.0" 789 | rfc3986 = ">=1.4.0" 790 | tqdm = ">=4.14" 791 | urllib3 = ">=1.26.0" 792 | 793 | [[package]] 794 | name = "urllib3" 795 | version = "2.0.4" 796 | description = "HTTP library with thread-safe connection pooling, file post, and more." 797 | optional = false 798 | python-versions = ">=3.7" 799 | files = [ 800 | {file = "urllib3-2.0.4-py3-none-any.whl", hash = "sha256:de7df1803967d2c2a98e4b11bb7d6bd9210474c46e8a0401514e3a42a75ebde4"}, 801 | {file = "urllib3-2.0.4.tar.gz", hash = "sha256:8d22f86aae8ef5e410d4f539fde9ce6b2113a001bb4d189e0aed70642d602b11"}, 802 | ] 803 | 804 | [package.extras] 805 | brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] 806 | secure = ["certifi", "cryptography (>=1.9)", "idna (>=2.0.0)", "pyopenssl (>=17.1.0)", "urllib3-secure-extra"] 807 | socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] 808 | zstd = ["zstandard (>=0.18.0)"] 809 | 810 | [[package]] 811 | name = "webencodings" 812 | version = "0.5.1" 813 | description = "Character encoding aliases for legacy web content" 814 | optional = false 815 | python-versions = "*" 816 | files = [ 817 | {file = "webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78"}, 818 | {file = "webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923"}, 819 | ] 820 | 821 | [[package]] 822 | name = "zipp" 823 | version = "3.16.2" 824 | description = "Backport of pathlib-compatible object wrapper for zip files" 825 | optional = false 826 | python-versions = ">=3.8" 827 | files = [ 828 | {file = "zipp-3.16.2-py3-none-any.whl", hash = "sha256:679e51dd4403591b2d6838a48de3d283f3d188412a9782faadf845f298736ba0"}, 829 | {file = "zipp-3.16.2.tar.gz", hash = "sha256:ebc15946aa78bd63458992fc81ec3b6f7b1e92d51c35e6de1c3804e73b799147"}, 830 | ] 831 | 832 | [package.extras] 833 | docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] 834 | testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy (>=0.9.1)", "pytest-ruff"] 835 | 836 | [metadata] 837 | lock-version = "2.0" 838 | python-versions = "^3.8" 839 | content-hash = "5f5b7f9681e501a49be1f4b9ad467654c537894a6f33bfeca104683c43dbd88e" 840 | -------------------------------------------------------------------------------- /pymidi/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mik3y/pymidi/e564b929d9a96c03802d35ffed1af04e1cd17445/pymidi/__init__.py -------------------------------------------------------------------------------- /pymidi/client.py: -------------------------------------------------------------------------------- 1 | from builtins import bytes 2 | 3 | from optparse import OptionParser 4 | import logging 5 | import select 6 | import socket 7 | import sys 8 | import random 9 | import time 10 | 11 | from pymidi import packets 12 | from pymidi import protocol 13 | from pymidi import utils 14 | from pymidi.utils import b2h 15 | from construct import ConstructError 16 | 17 | try: 18 | import coloredlogs 19 | except ImportError: 20 | coloredlogs = None 21 | 22 | logger = logging.getLogger('pymidi.client') 23 | 24 | 25 | class ClientError(Exception): 26 | """General client error.""" 27 | 28 | 29 | class AlreadyConnected(ClientError): 30 | """Client is already connected.""" 31 | 32 | 33 | class Client(object): 34 | def __init__(self, name='PyMidi', ssrc=None): 35 | """Creates a new Client instance.""" 36 | self.ssrc = ssrc or random.randint(0, 2 ** 32 - 1) 37 | self.socket = None 38 | self.host = None 39 | self.port = None 40 | 41 | def connect(self, host, port): 42 | if self.host and self.port: 43 | raise ClientError(f'Already connected to {self.host}:{self.port}') 44 | 45 | self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 46 | pkt = packets.AppleMIDIExchangePacket.create( 47 | protocol_version=2, 48 | command=protocol.APPLEMIDI_COMMAND_INVITATION, 49 | initiator_token=random.randint(0, 2 ** 32 - 1), 50 | ssrc=self.ssrc, 51 | ) 52 | for target_port in (port, port + 1): 53 | logger.info(f'Sending exchange packet to port {target_port}...') 54 | self.socket.sendto(pkt, (host, target_port)) 55 | packet = self.get_next_packet() 56 | if not packet: 57 | raise Exception('No packet received') 58 | if packet._name != 'AppleMIDIExchangePacket': 59 | raise Exception('Expected exchange packet') 60 | logger.info(f'Exchange successful.') 61 | 62 | self.host = host 63 | self.port = port 64 | 65 | def sync_timestamps(self, port): 66 | ts1 = int(time.time() * 1000) 67 | packet = packets.AppleMIDITimestampPacket.create( 68 | command=protocol.APPLEMIDI_COMMAND_TIMESTAMP_SYNC, 69 | ssrc=self.ssrc, 70 | count=count, 71 | padding=0, 72 | timestamp_1=ts1, 73 | timestamp_2=0, 74 | timestamp_3=0, 75 | ) 76 | 77 | def send_note_on(self, notestr, velocity=80, channel=1): 78 | self._send_note(notestr, packets.COMMAND_NOTE_ON, velocity, channel) 79 | 80 | def send_note_off(self, notestr, velocity=80, channel=1): 81 | self._send_note(notestr, packets.COMMAND_NOTE_OFF, velocity, channel) 82 | 83 | def _send_note(self, notestr, command, velocity=80, channel=1): 84 | # key = packets.MIDINote.build(notestr) 85 | command = { 86 | 'flags': { 87 | 'b': 0, 88 | 'j': 0, 89 | 'z': 0, 90 | 'p': 0, 91 | 'len': 3, 92 | }, 93 | 'midi_list': [ 94 | { 95 | 'delta_time': 0, 96 | '__next': 0x80, # TODO(mikey): This shouldn't be needed. 97 | 'command': 'note_on' if command == packets.COMMAND_NOTE_ON else 'note_off', 98 | 'command_byte': command | (channel & 0xF), 99 | 'channel': channel, 100 | 'params': { 101 | 'key': notestr, 102 | 'velocity': velocity, 103 | }, 104 | } 105 | ], 106 | } 107 | self._send_rtp_command(command) 108 | 109 | def _send_rtp_command(self, command): 110 | header = packets.MIDIPacketHeader.create( 111 | rtp_header={ 112 | 'flags': { 113 | 'v': 0x2, 114 | 'p': 0, 115 | 'x': 0, 116 | 'cc': 0, 117 | 'm': 0x1, 118 | 'pt': 0x61, 119 | }, 120 | 'sequence_number': ord('K'), 121 | }, 122 | timestamp=int(time.time()), 123 | ssrc=self.ssrc, 124 | ) 125 | 126 | packet = packets.MIDIPacket.create( 127 | header={ 128 | 'rtp_header': { 129 | 'flags': { 130 | 'v': 0x2, 131 | 'p': 0, 132 | 'x': 0, 133 | 'cc': 0, 134 | 'm': 0x1, 135 | 'pt': 0x61, 136 | }, 137 | 'sequence_number': ord('K'), 138 | }, 139 | 'timestamp': int(time.time()), 140 | 'ssrc': self.ssrc, 141 | }, 142 | command=command, 143 | journal='', 144 | ) 145 | 146 | self.socket.sendto(packet, (self.host, self.port + 1)) 147 | 148 | def get_next_packet(self): 149 | data, addr = self.socket.recvfrom(1024) 150 | command = data[2:4] 151 | try: 152 | if data[0:2] == protocol.APPLEMIDI_PREAMBLE: 153 | command = data[2:4] 154 | logger.debug('Command: {}'.format(b2h(command))) 155 | return self.handle_command_message(command, data, addr) 156 | except ConstructError: 157 | logger.exception('Bug or malformed packet, ignoring') 158 | return None 159 | 160 | def handle_command_message(self, command, data, addr): 161 | if command == protocol.APPLEMIDI_COMMAND_INVITATION_ACCEPTED: 162 | return packets.AppleMIDIExchangePacket.parse(data) 163 | else: 164 | logger.warning('Ignoring unrecognized command: {}'.format(command)) 165 | return None 166 | -------------------------------------------------------------------------------- /pymidi/packets.py: -------------------------------------------------------------------------------- 1 | from construct import Struct as BaseStruct 2 | from construct import Const, CString, Padding, Int8ub, Int16ub, Int32ub 3 | from construct import Int64ub, Bitwise, BitStruct, BitsInteger, Nibble, Flag, Optional, Bytes 4 | from construct import If, IfThenElse, GreedyBytes, GreedyRange, VarInt, FixedSized, Byte, Computed 5 | from construct import Switch, Enum, Peek 6 | from construct import this as _this 7 | 8 | 9 | COMMAND_NOTE_OFF = 0x80 10 | COMMAND_NOTE_ON = 0x90 11 | COMMAND_AFTERTOUCH = 0xA0 12 | COMMAND_CONTROL_MODE_CHANGE = 0xB0 13 | 14 | 15 | def to_string(pkt): 16 | """Pretty-prints a packet.""" 17 | name = pkt._name 18 | detail = '' 19 | 20 | if name == 'AppleMIDIExchangePacket': 21 | detail = '[command={} ssrc={} name={}]'.format( 22 | pkt.command.decode('utf-8'), pkt.ssrc, pkt.name 23 | ) 24 | elif name == 'MIDIPacket': 25 | items = [] 26 | for entry in pkt.command.midi_list: 27 | command = entry.command 28 | if command in ('note_on', 'note_off'): 29 | items.append('{} {} {}'.format(command, entry.params.key, entry.params.velocity)) 30 | elif command == 'control_mode_change': 31 | items.append( 32 | '{} {} {}'.format(command, entry.params.controller, entry.params.value) 33 | ) 34 | else: 35 | items.append(command) 36 | detail = ' '.join(('[{}]'.format(i) for i in items)) 37 | 38 | return '{} {}'.format(name, detail) 39 | 40 | 41 | def remember_last(obj, ctx): 42 | """Stores the last-seen command byte in the parsing context. 43 | 44 | Bit of a hack to make running status support work. 45 | """ 46 | setattr(ctx._root, '_last_command_byte', obj) 47 | 48 | 49 | class Struct(BaseStruct): 50 | """Adds `create()`, a friendlier `build()` method.""" 51 | 52 | def create(self, **kwargs): 53 | return self.build(kwargs) 54 | 55 | 56 | AppleMIDIExchangePacket = Struct( 57 | '_name' / Computed('AppleMIDIExchangePacket'), 58 | 'preamble' / Const(b'\xff\xff'), 59 | 'command' / Bytes(2), 60 | 'protocol_version' / Int32ub, 61 | 'initiator_token' / Int32ub, 62 | 'ssrc' / Int32ub, 63 | 'name' / Optional(CString('utf8')), 64 | ) 65 | 66 | AppleMIDITimestampPacket = Struct( 67 | '_name' / Computed('AppleMIDITimestampPacket'), 68 | 'preamble' / Const(b'\xff\xff'), 69 | 'command' / Bytes(2), 70 | 'ssrc' / Int32ub, 71 | 'count' / Int8ub, 72 | 'padding' / Padding(3), 73 | 'timestamp_1' / Int64ub, 74 | 'timestamp_2' / Int64ub, 75 | 'timestamp_3' / Int64ub, 76 | ) 77 | 78 | MIDIPacketHeaderFlags = Bitwise( 79 | Struct( 80 | 'v' / BitsInteger(2), # always 0x2 81 | 'p' / Flag, # always 0 82 | 'x' / Flag, # always 0 83 | 'cc' / Nibble, # always 0 84 | 'm' / Flag, # always 0x1 85 | 'pt' / BitsInteger(7), # always 0x61 86 | ) 87 | ) 88 | 89 | RTPHeader = Struct( 90 | 'flags' / MIDIPacketHeaderFlags, 91 | 'sequence_number' / Int16ub, # always 'K' 92 | ) 93 | 94 | MIDIPacketHeader = Struct( 95 | '_name' / Computed('MIDIPacketHeader'), 96 | 'rtp_header' / RTPHeader, 97 | 'timestamp' / Int32ub, 98 | 'ssrc' / Int32ub, 99 | ) 100 | 101 | MIDINote = Enum( 102 | Byte, 103 | Cn1=0, 104 | Csn1=1, 105 | Dn1=2, 106 | Dsn1=3, 107 | En1=4, 108 | Fn1=5, 109 | Fsn1=6, 110 | Gn1=7, 111 | Gsn1=8, 112 | An1=9, 113 | Asn1=10, 114 | Bn1=11, 115 | C0=12, 116 | Cs0=13, 117 | D0=14, 118 | Ds0=15, 119 | E0=16, 120 | F0=17, 121 | Fs0=18, 122 | G0=19, 123 | Gs0=20, 124 | A0=21, 125 | As0=22, 126 | B0=23, 127 | C1=24, 128 | Cs1=25, 129 | D1=26, 130 | Ds1=27, 131 | E1=28, 132 | F1=29, 133 | Fs1=30, 134 | G1=31, 135 | Gs1=32, 136 | A1=33, 137 | As1=34, 138 | B1=35, 139 | C2=36, 140 | Cs2=37, 141 | D2=38, 142 | Ds2=39, 143 | E2=40, 144 | F2=41, 145 | Fs2=42, 146 | G2=43, 147 | Gs2=44, 148 | A2=45, 149 | As2=46, 150 | B2=47, 151 | C3=48, 152 | Cs3=49, 153 | D3=50, 154 | Ds3=51, 155 | E3=52, 156 | F3=53, 157 | Fs3=54, 158 | G3=55, 159 | Gs3=56, 160 | A3=57, 161 | As3=58, 162 | B3=59, 163 | C4=60, 164 | Cs4=61, 165 | D4=62, 166 | Ds4=63, 167 | E4=64, 168 | F4=65, 169 | Fs4=66, 170 | G4=67, 171 | Gs4=68, 172 | A4=69, 173 | As4=70, 174 | B4=71, 175 | C5=72, 176 | Cs5=73, 177 | D5=74, 178 | Ds5=75, 179 | E5=76, 180 | F5=77, 181 | Fs5=78, 182 | G5=79, 183 | Gs5=80, 184 | A5=81, 185 | As5=82, 186 | B5=83, 187 | C6=84, 188 | Cs6=85, 189 | D6=86, 190 | Ds6=87, 191 | E6=88, 192 | F6=89, 193 | Fs6=90, 194 | G6=91, 195 | Gs6=92, 196 | A6=93, 197 | As6=94, 198 | B6=95, 199 | C7=96, 200 | Cs7=97, 201 | D7=98, 202 | Ds7=99, 203 | E7=100, 204 | F7=101, 205 | Fs7=102, 206 | G7=103, 207 | Gs7=104, 208 | A7=105, 209 | As7=106, 210 | B7=107, 211 | C8=108, 212 | Cs8=109, 213 | D8=110, 214 | Ds8=111, 215 | E8=112, 216 | F8=113, 217 | Fs8=114, 218 | G8=115, 219 | Gs8=116, 220 | A8=117, 221 | As8=118, 222 | B8=119, 223 | C9=120, 224 | Cs9=121, 225 | D9=122, 226 | Ds9=123, 227 | E9=124, 228 | F9=125, 229 | Fs9=126, 230 | G9=127, 231 | ) 232 | 233 | MIDIPacketCommand = Struct( 234 | '_name' / Computed('MIDIPacketCommand'), 235 | 'flags' 236 | / BitStruct( 237 | 'b' / Flag, 238 | 'j' / Flag, 239 | 'z' / Flag, 240 | 'p' / Flag, 241 | 'len' / IfThenElse(_this.b == 0, BitsInteger(4), BitsInteger(12)), 242 | ), 243 | # 'midi_list' / Bytes(_this.flags.len), 244 | 'midi_list' 245 | / FixedSized( 246 | _this.flags.len, 247 | GreedyRange( 248 | Struct( 249 | 'delta_time' / If(_this._index > 0, VarInt), 250 | # The "running status" technique means multiple commands may be sent under 251 | # the same status. This condition occurs when, after parsing the current 252 | # commands, we see the next byte is NOT a status byte (MSB is low). 253 | # 254 | # Below, this is accomplished by storing the most recent status byte 255 | # on the global context with the `* remember_last` macro; then using it 256 | # on the `else` branch of the `command_byte` selection. 257 | '__next' / Peek(Int8ub), 258 | 'command_byte' 259 | / IfThenElse( 260 | _this.__next & 0x80, 261 | Byte * remember_last, 262 | Computed(lambda ctx: ctx._root._last_command_byte), 263 | ), 264 | 'command' 265 | / If( 266 | _this.command_byte, 267 | Enum( 268 | Computed(_this.command_byte & 0xF0), 269 | note_on=COMMAND_NOTE_ON, 270 | note_off=COMMAND_NOTE_OFF, 271 | aftertouch=COMMAND_AFTERTOUCH, 272 | control_mode_change=COMMAND_CONTROL_MODE_CHANGE, 273 | ), 274 | ), 275 | 'channel' / If(_this.command_byte, Computed(_this.command_byte & 0x0F)), 276 | 'params' 277 | / Switch( 278 | _this.command, 279 | { 280 | 'note_on': Struct( 281 | 'key' / MIDINote, 282 | 'velocity' / Int8ub, 283 | ), 284 | 'note_off': Struct( 285 | 'key' / MIDINote, 286 | 'velocity' / Int8ub, 287 | ), 288 | 'aftertouch': Struct( 289 | 'key' / MIDINote, 290 | 'touch' / Int8ub, 291 | ), 292 | 'control_mode_change': Struct( 293 | 'controller' / Int8ub, 294 | 'value' / Int8ub, 295 | ), 296 | }, 297 | default=Struct( 298 | 'unknown' / GreedyBytes, 299 | ), 300 | ), 301 | ), 302 | ), 303 | ), 304 | ) 305 | 306 | MIDISystemJournal = Struct( 307 | '_name' / Computed('MIDISystemJournal'), 308 | 'header' 309 | / BitStruct( 310 | 's' / Flag, 311 | 'd' / Flag, 312 | 'v' / Flag, 313 | 'q' / Flag, 314 | 'f' / Flag, 315 | 'x' / Flag, 316 | 'length' / BitsInteger(10), 317 | ), 318 | # Note from RFC 6295 appendix A1: The "length" field includes 319 | # the header bytes. 320 | 'journal' / Bytes(_this.header.length - 2), 321 | ) 322 | 323 | MIDIChapterJournal = Struct( 324 | '_name' / Computed('MIDIChapterJournal'), 325 | 'header' 326 | / BitStruct( 327 | 's' / Flag, 328 | 'chan' / BitsInteger(4), 329 | 'h' / Flag, 330 | 'length' / BitsInteger(10), 331 | 'p' / Flag, 332 | 'c' / Flag, 333 | 'm' / Flag, 334 | 'w' / Flag, 335 | 'n' / Flag, 336 | 'e' / Flag, 337 | 't' / Flag, 338 | 'a' / Flag, 339 | ), 340 | # Note from RFC 6295 appendix A1: The "length" field includes 341 | # the header bytes. 342 | 'journal' / Bytes(_this.header.length - 3), 343 | ) 344 | 345 | MIDIPacketJournal = Struct( 346 | '_name' / Computed('MIDIPacketJournal'), 347 | 'header' 348 | / BitStruct( 349 | 's' / Flag, 350 | 'y' / Flag, 351 | 'a' / Flag, 352 | 'h' / Flag, 353 | 'totchan' / BitsInteger(4), 354 | ), 355 | 'checkpoint_seqnum' / Int16ub, 356 | 'system_journal' / If(_this.header.s, MIDISystemJournal), 357 | 'channel_journal' / If(_this.header.a, MIDIChapterJournal), 358 | ) 359 | 360 | MIDIPacket = Struct( 361 | '_name' / Computed('MIDIPacket'), 362 | 'header' / MIDIPacketHeader, 363 | 'command' / MIDIPacketCommand, 364 | 'journal' / If(_this.command.flags.j, MIDIPacketJournal), 365 | ) 366 | -------------------------------------------------------------------------------- /pymidi/protocol.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import random 3 | import time 4 | 5 | from pymidi import packets 6 | from pymidi.utils import b2h 7 | from construct import ConstructError 8 | 9 | # Command messages are preceded with this sequence. 10 | APPLEMIDI_PREAMBLE = b'\xff\xff' 11 | 12 | # Two-byte RTP-MIDI control commands 13 | APPLEMIDI_COMMAND_INVITATION = b'IN' 14 | APPLEMIDI_COMMAND_INVITATION_ACCEPTED = b'OK' 15 | APPLEMIDI_COMMAND_INVITATION_REJECTED = b'NO' 16 | APPLEMIDI_COMMAND_TIMESTAMP_SYNC = b'CK' 17 | APPLEMIDI_COMMAND_EXIT = b'BY' 18 | 19 | 20 | class Peer(object): 21 | """Holds state about a midi peer.""" 22 | 23 | def __init__(self, name, addr, ssrc): 24 | self.name = name 25 | self.addr = addr 26 | self.ssrc = ssrc 27 | 28 | def __str__(self): 29 | return '{} (ssrc={}, addr={})'.format(self.name, self.ssrc, self.addr) 30 | 31 | 32 | class ProtocolError(Exception): 33 | pass 34 | 35 | 36 | class BaseProtocol(object): 37 | def __init__(self, socket, name='pymidi', ssrc=None, connect_cb=None, disconnect_cb=None): 38 | self.socket = socket 39 | self.name = name 40 | self.peers_by_ssrc = {} 41 | self.ssrc = ssrc or random.randint(0, 2 ** 32 - 1) 42 | self.connect_cb = connect_cb 43 | self.disconnect_cb = disconnect_cb 44 | self.logger = logging.getLogger('pymidi.{}'.format(self.__class__.__name__)) 45 | 46 | def _connect_peer(self, name, addr, ssrc): 47 | peer = Peer(name=name, addr=addr, ssrc=ssrc) 48 | self.peers_by_ssrc[ssrc] = peer 49 | if self.connect_cb: 50 | self.connect_cb(peer) 51 | return peer 52 | 53 | def _disconnect_peer(self, ssrc): 54 | peer = self.peers_by_ssrc.pop(ssrc, None) 55 | if peer and self.disconnect_cb: 56 | self.disconnect_cb(peer) 57 | return peer 58 | 59 | def sendto(self, message, addr): 60 | if self.logger.isEnabledFor(logging.DEBUG): 61 | self.logger.debug('tx: {}'.format(b2h(message))) 62 | self.socket.sendto(message, addr) 63 | 64 | def handle_message(self, data, addr): 65 | if self.logger.isEnabledFor(logging.DEBUG): 66 | self.logger.debug('rx: {}'.format(b2h(data))) 67 | 68 | try: 69 | if data[0:2] == APPLEMIDI_PREAMBLE: 70 | command = data[2:4] 71 | self.logger.debug('Command: {}'.format(b2h(command))) 72 | self.handle_command_message(command, data, addr) 73 | else: 74 | self.handle_data_message(data, addr) 75 | except ConstructError: 76 | self.logger.exception('Bug or malformed packet, ignoring') 77 | 78 | def handle_data_message(self, data, addr): 79 | pass 80 | 81 | def handle_command_message(self, command, data, addr): 82 | if command == APPLEMIDI_COMMAND_INVITATION: 83 | packet = packets.AppleMIDIExchangePacket.parse(data) 84 | ssrc = packet.ssrc 85 | if ssrc in self.peers_by_ssrc: 86 | self.logger.warning('Ignoring duplicate connection from ssrc {}'.format(ssrc)) 87 | return 88 | peer = self._connect_peer(name=packet.name, addr=addr, ssrc=ssrc) 89 | response = packets.AppleMIDIExchangePacket.build( 90 | dict( 91 | command=APPLEMIDI_COMMAND_INVITATION_ACCEPTED, 92 | protocol_version=2, 93 | initiator_token=packet.initiator_token, 94 | ssrc=self.ssrc, 95 | name=self.name, 96 | ) 97 | ) 98 | self.sendto(response, addr) 99 | self.logger.info('Accepted connection from {}'.format(peer)) 100 | elif command == APPLEMIDI_COMMAND_EXIT: 101 | packet = packets.AppleMIDIExchangePacket.parse(data) 102 | ssrc = packet.ssrc 103 | if ssrc not in self.peers_by_ssrc: 104 | self.logger.warning('Ignoring exit from unknown ssrc {}'.format(ssrc)) 105 | return 106 | peer = self._disconnect_peer(ssrc) 107 | self.logger.info('Peer {} exited'.format(peer)) 108 | else: 109 | self.logger.warning('Ignoring unrecognized command: {}'.format(command)) 110 | 111 | 112 | class ControlProtocol(BaseProtocol): 113 | def __init__(self, data_protocol=None, *args, **kwargs): 114 | super(ControlProtocol, self).__init__(*args, **kwargs) 115 | self.data_protocol = data_protocol 116 | 117 | def associate_data_protocol(self, data_protocol): 118 | self.data_protocol = data_protocol 119 | 120 | def _disconnect_peer(self, ssrc): 121 | """Disconnect from data protocol when disconnecting locally.""" 122 | peer = super(ControlProtocol, self)._disconnect_peer(ssrc) 123 | if peer: 124 | self.data_protocol._disconnect_peer(ssrc) 125 | return peer 126 | 127 | 128 | class DataProtocol(BaseProtocol): 129 | def __init__(self, *args, **kwargs): 130 | self.midi_command_cb = kwargs.pop('midi_command_cb', None) 131 | super(DataProtocol, self).__init__(*args, **kwargs) 132 | 133 | def handle_command_message(self, command, data, addr): 134 | if command == APPLEMIDI_COMMAND_TIMESTAMP_SYNC: 135 | self.handle_timestamp(data, addr) 136 | else: 137 | super(DataProtocol, self).handle_command_message(command, data, addr) 138 | 139 | def handle_data_message(self, data, addr): 140 | packet = packets.MIDIPacket.parse(data) 141 | if self.logger.isEnabledFor(logging.DEBUG): 142 | self.logger.debug(packet) 143 | peer = self.peers_by_ssrc.get(packet.header.ssrc) 144 | if not peer: 145 | self.logger.debug('Ignoring message from unknown ssrc={}'.format(packet.header.ssrc)) 146 | return 147 | if self.midi_command_cb: 148 | self.midi_command_cb(peer, packet) 149 | 150 | def handle_timestamp(self, data, addr): 151 | packet = packets.AppleMIDITimestampPacket.parse(data) 152 | response = None 153 | if self.logger.isEnabledFor(logging.DEBUG): 154 | self.logger.debug(packet) 155 | 156 | now = int(time.time() * 10000) # units of 100 microseconds 157 | if packet.count == 0: 158 | response = packets.AppleMIDITimestampPacket.build( 159 | dict( 160 | command=APPLEMIDI_COMMAND_TIMESTAMP_SYNC, 161 | count=1, 162 | ssrc=self.ssrc, 163 | timestamp_1=packet.timestamp_1, 164 | timestamp_2=now, 165 | timestamp_3=0, 166 | ) 167 | ) 168 | self.sendto(response, addr) 169 | elif packet.count == 2: 170 | offset_estimate = ((packet.timestamp_3 + packet.timestamp_1) / 2) - packet.timestamp_2 171 | self.logger.debug('offset estimate: {}'.format(offset_estimate)) 172 | -------------------------------------------------------------------------------- /pymidi/server.py: -------------------------------------------------------------------------------- 1 | from builtins import bytes 2 | 3 | from optparse import OptionParser 4 | import logging 5 | import select 6 | import socket 7 | import sys 8 | 9 | from pymidi.protocol import DataProtocol 10 | from pymidi.protocol import ControlProtocol 11 | from pymidi import utils 12 | 13 | try: 14 | import coloredlogs 15 | except ImportError: 16 | coloredlogs = None 17 | 18 | logger = logging.getLogger('pymidi.server') 19 | 20 | 21 | class Handler(object): 22 | def on_peer_connected(self, peer): 23 | pass 24 | 25 | def on_peer_disconnected(self, peer): 26 | pass 27 | 28 | def on_midi_commands(self, peer, command_list): 29 | pass 30 | 31 | 32 | class Server(object): 33 | def __init__(self, bind_addrs): 34 | """Creates a new Server instance. 35 | 36 | `bind_addrs` should be an iterable of 1 or more addresses to bind to, 37 | each a 2-tuple of (ip, port). Socket family will be automatically 38 | detected from the IP address. 39 | """ 40 | if not bind_addrs: 41 | raise ValueError('Must provide at least one bind address.') 42 | map(utils.validate_addr, bind_addrs) 43 | self.bind_addrs = bind_addrs 44 | self.handlers = set() 45 | 46 | # Maps sockets to their protocol handlers. 47 | self.socket_map = {} 48 | 49 | @classmethod 50 | def from_bind_addrs(cls, hosts): 51 | """Convenience method to construct an instance from a string.""" 52 | bind_addrs = set() 53 | for host in hosts: 54 | parts = host.split(':') 55 | name = ':'.join(parts[:-1]) 56 | port = int(parts[-1]) 57 | addr = (name, port) 58 | bind_addrs.add(addr) 59 | return cls(bind_addrs) 60 | 61 | def add_handler(self, handler): 62 | assert isinstance(handler, Handler) 63 | self.handlers.add(handler) 64 | 65 | def remove_handler(self, handler): 66 | assert isinstance(handler, Handler) 67 | self.handlers.discard(handler) 68 | 69 | def _peer_connected_cb(self, peer): 70 | for handler in self.handlers: 71 | handler.on_peer_connected(peer) 72 | 73 | def _peer_disconnected_cb(self, peer): 74 | for handler in self.handlers: 75 | handler.on_peer_disconnected(peer) 76 | 77 | def _midi_command_cb(self, peer, midi_packet): 78 | commands = midi_packet.command.midi_list 79 | for handler in self.handlers: 80 | handler.on_midi_commands(peer, commands) 81 | 82 | def _build_control_protocol(self, host, port, family): 83 | logger.info('Control socket on {}:{}'.format(host, port)) 84 | control_socket = socket.socket(family, socket.SOCK_DGRAM) 85 | control_socket.bind((host, port)) 86 | return ControlProtocol( 87 | socket=control_socket, 88 | connect_cb=self._peer_connected_cb, 89 | disconnect_cb=self._peer_disconnected_cb, 90 | ) 91 | 92 | def _build_data_protocol(self, host, family, ctrl_protocol): 93 | ctrl_port = ctrl_protocol.socket.getsockname()[1] 94 | logger.info('Data socket on {}:{}'.format(host, ctrl_port + 1)) 95 | data_socket = socket.socket(family, socket.SOCK_DGRAM) 96 | data_socket.bind((host, ctrl_port + 1)) 97 | data_protocol = DataProtocol(data_socket, midi_command_cb=self._midi_command_cb) 98 | ctrl_protocol.associate_data_protocol(data_protocol) 99 | return data_protocol 100 | 101 | def _init_protocols(self): 102 | for host, port in self.bind_addrs: 103 | if utils.is_ipv4_address(host): 104 | family = socket.AF_INET 105 | elif utils.is_ipv6_address(host): 106 | family = socket.AF_INET6 107 | else: 108 | raise ValueError('Invalid bind host: "{}"'.format(host)) 109 | 110 | ctrl_protocol = self._build_control_protocol(host, port, family) 111 | data_protocol = self._build_data_protocol(host, family, ctrl_protocol) 112 | 113 | self.socket_map[data_protocol.socket] = data_protocol 114 | self.socket_map[ctrl_protocol.socket] = ctrl_protocol 115 | 116 | protos = (ctrl_protocol, data_protocol) 117 | if family == socket.AF_INET: 118 | self.ipv4_protocols = protos 119 | elif family == socket.AF_INET6: 120 | self.ipv6_protocols = protos 121 | 122 | def _loop_once(self, timeout=None): 123 | sockets = self.socket_map.keys() 124 | rr, _, _ = select.select(sockets, [], [], timeout) 125 | for s in rr: 126 | buffer, addr = s.recvfrom(1024) 127 | buffer = bytes(buffer) 128 | proto = self.socket_map[s] 129 | proto.handle_message(buffer, addr) 130 | 131 | def serve_forever(self): 132 | self._init_protocols() 133 | while True: 134 | self._loop_once() 135 | -------------------------------------------------------------------------------- /pymidi/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mik3y/pymidi/e564b929d9a96c03802d35ffed1af04e1cd17445/pymidi/tests/__init__.py -------------------------------------------------------------------------------- /pymidi/tests/packets_test.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | from pymidi import packets 3 | from pymidi.utils import h2b 4 | 5 | EXCHANGE_PACKET = h2b('ffff494e000000026633487347d810964d696b65e2809973204d616300') 6 | TIMESTAMP_PACKET = h2b('ffff434b47d8109602000000000000004400227e00000dfaad1e5c820000000044002288') 7 | 8 | # A midi packet with a Note On command 9 | SINGLE_MIDI_PACKET = h2b('8061427a4b9f303647d8109643903026204276000608006685') 10 | 11 | # A midi packet with two commands, utilizing running status. 12 | # 13 | # Commands 14 | # 0x90 0x3e 0x41 - NOTE_ON D3 velocity 49 15 | # 0x40 0x3b - NOTE_ON E3 velocity 59 16 | # 17 | # Journal 18 | # NOTE_ON C3 velocity 37 19 | MULTI_MIDI_PACKET = h2b( 20 | '8061429a51d2dc8747d8109646903e310a403b21427c00090881673c250d50c8060880440e' 21 | ) 22 | 23 | # A controller/mode change packet 24 | CONTROL_MODE_CHANGE_PACKET = h2b('80614ba55944067e47d8109643b06c00204ba0000948006c7f807708') 25 | 26 | # An ApplieMIDI invitation packet 27 | APPLEMIDI_INVITATION_PACKET = h2b('ffff494e000000020507236747d810966d626f6f6b2d73657373696f6e00') 28 | 29 | # An AppleMIDI exit packet 30 | APPLEMIDI_EXIT_PACKET = h2b('ffff4259000000020000000047d81096') 31 | 32 | 33 | class TestPackets(TestCase): 34 | def test_exchange_packet(self): 35 | pkt = packets.AppleMIDIExchangePacket.parse(EXCHANGE_PACKET) 36 | self.assertEqual(b'\xff\xff', pkt.preamble) 37 | self.assertEqual(b'IN', pkt.command) 38 | self.assertEqual(2, pkt.protocol_version) 39 | self.assertEqual(1714636915, pkt.initiator_token) 40 | self.assertEqual(1205342358, pkt.ssrc) 41 | self.assertEqual('Mike’s Mac', pkt.name) 42 | 43 | def test_timestamp_packet(self): 44 | pkt = packets.AppleMIDITimestampPacket.parse(TIMESTAMP_PACKET) 45 | self.assertEqual(b'\xff\xff', pkt.preamble) 46 | self.assertEqual(b'CK', pkt.command) 47 | self.assertEqual(1205342358, pkt.ssrc) 48 | self.assertEqual(2, pkt.count) 49 | self.assertEqual(1140859518, pkt.timestamp_1) 50 | self.assertEqual(15370297433218, pkt.timestamp_2) 51 | self.assertEqual(1140859528, pkt.timestamp_3) 52 | 53 | def test_single_midi_packet(self): 54 | pkt = packets.MIDIPacket.parse(SINGLE_MIDI_PACKET) 55 | print(pkt) 56 | self.assertTrue(pkt.header, 'Expected a header') 57 | self.assertEqual(2, pkt.header.rtp_header.flags.v) 58 | self.assertEqual(False, pkt.header.rtp_header.flags.p) 59 | self.assertEqual(False, pkt.header.rtp_header.flags.x) 60 | self.assertEqual(0, pkt.header.rtp_header.flags.cc) 61 | self.assertEqual(False, pkt.header.rtp_header.flags.m) 62 | self.assertEqual(0x61, pkt.header.rtp_header.flags.pt) 63 | self.assertEqual(17018, pkt.header.rtp_header.sequence_number) 64 | 65 | self.assertTrue(pkt.command, 'Expected a command') 66 | self.assertEqual(False, pkt.command.flags.b) 67 | self.assertEqual(True, pkt.command.flags.j) 68 | self.assertEqual(False, pkt.command.flags.z) 69 | self.assertEqual(False, pkt.command.flags.p) 70 | self.assertEqual(3, pkt.command.flags.len) 71 | print(pkt.command) 72 | self.assertEqual(1, len(pkt.command.midi_list)) 73 | 74 | command = pkt.command.midi_list[0] 75 | self.assertEqual(0x90, command.command_byte) 76 | self.assertEqual('note_on', command.command) 77 | self.assertEqual('C3', command.params.key) 78 | self.assertTrue(38, command.params.velocity) 79 | 80 | self.assertTrue(pkt.journal, 'Expected journal') 81 | 82 | def test_multi_midi_packet(self): 83 | pkt = packets.MIDIPacket.parse(MULTI_MIDI_PACKET) 84 | self.assertTrue(pkt.header, 'Expected a header') 85 | self.assertEqual(2, pkt.header.rtp_header.flags.v) 86 | self.assertEqual(False, pkt.header.rtp_header.flags.p) 87 | self.assertEqual(False, pkt.header.rtp_header.flags.x) 88 | self.assertEqual(0, pkt.header.rtp_header.flags.cc) 89 | self.assertEqual(False, pkt.header.rtp_header.flags.m) 90 | self.assertEqual(0x61, pkt.header.rtp_header.flags.pt) 91 | self.assertEqual(17050, pkt.header.rtp_header.sequence_number) 92 | 93 | self.assertTrue(pkt.command, 'Expected a command') 94 | self.assertEqual(False, pkt.command.flags.b) 95 | self.assertEqual(True, pkt.command.flags.j) 96 | self.assertEqual(False, pkt.command.flags.z) 97 | self.assertEqual(False, pkt.command.flags.p) 98 | self.assertEqual(6, pkt.command.flags.len) 99 | self.assertEqual(2, len(pkt.command.midi_list)) 100 | 101 | command = pkt.command.midi_list[0] 102 | self.assertEqual(0x90, command.command_byte) 103 | self.assertEqual('note_on', command.command) 104 | self.assertEqual('D4', command.params.key) 105 | self.assertTrue(38, command.params.velocity) 106 | 107 | command = pkt.command.midi_list[1] 108 | self.assertEqual(0x90, command.command_byte) 109 | self.assertEqual('note_on', command.command) 110 | self.assertEqual('E4', command.params.key) 111 | self.assertTrue(38, command.params.velocity) 112 | 113 | self.assertTrue(pkt.journal, 'Expected journal') 114 | 115 | def test_packet_with_no_journal(self): 116 | pkt = packets.MIDIPacket.parse(h2b('806142a0550d8a5a47d8109603903446')) 117 | self.assertEqual(False, pkt.command.flags.j, 'Expected J bit to be clear') 118 | self.assertTrue(not pkt.journal, 'Expected no journal') 119 | 120 | def test_to_string(self): 121 | pkt = packets.MIDIPacket.parse(SINGLE_MIDI_PACKET) 122 | strval = packets.to_string(pkt) 123 | self.assertEqual('MIDIPacket [note_on C3 38]', strval) 124 | 125 | pkt = packets.MIDIPacket.parse(MULTI_MIDI_PACKET) 126 | strval = packets.to_string(pkt) 127 | self.assertEqual('MIDIPacket [note_on D4 49] [note_on E4 59]', strval) 128 | 129 | pkt = packets.MIDIPacket.parse(CONTROL_MODE_CHANGE_PACKET) 130 | strval = packets.to_string(pkt) 131 | self.assertEqual('MIDIPacket [control_mode_change 108 0]', strval) 132 | 133 | pkt = packets.AppleMIDIExchangePacket.parse(APPLEMIDI_INVITATION_PACKET) 134 | strval = packets.to_string(pkt) 135 | self.assertEqual( 136 | 'AppleMIDIExchangePacket [command=IN ssrc=1205342358 name=mbook-session]', strval 137 | ) 138 | 139 | pkt = packets.AppleMIDIExchangePacket.parse(APPLEMIDI_EXIT_PACKET) 140 | strval = packets.to_string(pkt) 141 | self.assertEqual('AppleMIDIExchangePacket [command=BY ssrc=1205342358 name=None]', strval) 142 | -------------------------------------------------------------------------------- /pymidi/tests/server_tests.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | from pymidi.server import Server, Handler 3 | import mock 4 | 5 | 6 | class FakeHandler(mock.Mock, Handler): 7 | pass 8 | 9 | 10 | class ServerTests(TestCase): 11 | def setUp(self): 12 | self.server = Server([('127.0.0.1', 0)]) 13 | self.server._init_protocols() 14 | self.handler = FakeHandler() 15 | self.server.add_handler(self.handler) 16 | 17 | def test_server_bind(self): 18 | protos = self.server.socket_map 19 | self.assertEqual(2, len(protos)) 20 | 21 | def test_loop_once_no_data(self): 22 | """Confirms a single read loop succeeds with no data.""" 23 | self.server._loop_once(timeout=0) 24 | self.assertFalse(self.handler.called) 25 | self.assertEqual(0, self.handler.call_count) 26 | -------------------------------------------------------------------------------- /pymidi/tests/utils_test.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | from pymidi import utils 3 | 4 | 5 | class UtilsTests(TestCase): 6 | def test_is_ipv4_address(self): 7 | bad_ips = ['hello', '', None, object] 8 | for ip in bad_ips: 9 | self.assertEqual( 10 | False, utils.is_ipv4_address(ip), 'Expected {} to return False'.format(repr(ip)) 11 | ) 12 | 13 | good_ips = ['127.0.0.1', '8.8.8.8'] 14 | for ip in good_ips: 15 | self.assertEqual( 16 | True, utils.is_ipv4_address(ip), 'Expected {} to return True'.format(repr(ip)) 17 | ) 18 | 19 | def test_is_ipv6_address(self): 20 | bad_ips = ['hello', '', None, object] 21 | for ip in bad_ips: 22 | self.assertEqual( 23 | False, utils.is_ipv6_address(ip), 'Expected {} to return False'.format(repr(ip)) 24 | ) 25 | 26 | good_ips = ['::', '2001:0db8:85a3:0000:0000:8a2e:0370:7334'] 27 | for ip in good_ips: 28 | self.assertEqual( 29 | True, utils.is_ipv6_address(ip), 'Expected {} to return True'.format(repr(ip)) 30 | ) 31 | 32 | def test_validate_addr(self): 33 | bad_addrs = [None, ('boom', 'dip'), ('', 80), ('localhost', 80)] 34 | for addr in bad_addrs: 35 | with self.assertRaises(ValueError): 36 | utils.validate_addr(addr) 37 | 38 | good_addrs = [('127.0.0.1', 80), ('8.8.8.8', 2048), ('::', 5051)] 39 | for addr in good_addrs: 40 | utils.validate_addr(addr) 41 | 42 | def test_b2h(self): 43 | mybytes = b'yo' 44 | self.assertEqual('796f', utils.b2h(mybytes)) 45 | mybytes = b'\xfe\xed\xfa\xce' 46 | self.assertEqual('feedface', utils.b2h(mybytes)) 47 | -------------------------------------------------------------------------------- /pymidi/utils.py: -------------------------------------------------------------------------------- 1 | import codecs 2 | import socket 3 | from six import string_types 4 | from builtins import bytes 5 | 6 | 7 | def h2b(s): 8 | """Converts a hex string to bytes; Python 2/3 compatible.""" 9 | return bytes.fromhex(s) 10 | 11 | 12 | def b2h(b): 13 | """Converts a `bytes` object to a hex string.""" 14 | if not isinstance(b, bytes): 15 | raise ValueError('Argument must be a `bytes`') 16 | result = codecs.getencoder('hex_codec')(b)[0] 17 | if isinstance(result, bytes): 18 | result = result.decode('ascii') 19 | return result 20 | 21 | 22 | def is_ipv4_address(ipstr): 23 | try: 24 | socket.inet_aton(ipstr) 25 | return True 26 | except (socket.error, TypeError): 27 | return False 28 | 29 | 30 | def is_ipv6_address(ipstr): 31 | try: 32 | socket.inet_pton(socket.AF_INET6, ipstr) 33 | return True 34 | except (socket.error, TypeError): 35 | return False 36 | 37 | 38 | def is_ipv4_or_ipv6_address(ipstr): 39 | return is_ipv4_address(ipstr) or is_ipv6_address(ipstr) 40 | 41 | 42 | def validate_addr(addr): 43 | """Raises `ValueError` if `addr` is not a well-formed (ip, port) pair.""" 44 | if not isinstance(addr, tuple): 45 | raise ValueError('Address {} is not a tuple'.format(repr(addr))) 46 | if len(addr) != 2: 47 | raise ValueError('Address {} is not a 2-tuple'.format(repr(addr))) 48 | if not isinstance(addr[0], string_types): 49 | raise ValueError('First param of address {} is not a string'.format(repr(addr))) 50 | if not is_ipv4_or_ipv6_address(addr[0]): 51 | raise ValueError('First param of address {} is not a valid ip'.format(repr(addr))) 52 | if not isinstance(addr[1], int): 53 | raise ValueError('Second param of address {} is not an int'.format(repr(addr))) 54 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 100 3 | target_version = ['py311'] 4 | include = '.*\.pyi?$' 5 | 6 | [tool.isort] 7 | profile = "black" 8 | skip_gitignore = true 9 | 10 | [tool.poetry] 11 | name = "pymidi" 12 | version = "0.6.0-pre1" 13 | description = "Python library for building RTP-MIDI clients and servers" 14 | authors = ["mike wakerly "] 15 | license = "MIT" 16 | 17 | [tool.poetry.dependencies] 18 | python = "^3.8" 19 | six = "^1.16.0" 20 | construct = "^2.10.68" 21 | 22 | [tool.poetry.dev-dependencies] 23 | flake8 = "^3.9.2" 24 | coloredlogs = "^15.0.1" 25 | twine = "^3.8.0" 26 | mock = "^4.0.3" 27 | pytest = "^6.2.5" 28 | 29 | [build-system] 30 | requires = ["poetry-core>=1.0.0"] 31 | build-backend = "poetry.core.masonry.api" 32 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length=100 3 | ignore=E128 4 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | with open('README.md') as f: 4 | long_description = f.read() 5 | 6 | setup( 7 | name='pymidi', 8 | version='0.5.0', 9 | license='MIT', 10 | url='https://github.com/mik3y/pymidi', 11 | author='mike wakerly', 12 | author_email='opensource@hoho.com', 13 | description='Python RTP-MIDI / AppleMIDI driver', 14 | long_description=long_description, 15 | long_description_content_type='text/markdown', 16 | packages=find_packages(), 17 | install_requires=[ 18 | 'construct >= 2.9', 19 | 'future >= 0.17.0', 20 | 'six >= 1.10.0', 21 | ], 22 | tests_require=[ 23 | 'pytest', 24 | 'flake8', 25 | ], 26 | ) 27 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | isolated_build = true 3 | envlist = 4 | py{38,39,310,311}, flake8, isort, dist 5 | 6 | [gh-actions] 7 | python = 8 | 3.8: py38 9 | 3.9: py39 10 | 3.10: py310 11 | 3.11: py311 12 | 13 | [base] 14 | deps = 15 | poetry 16 | 17 | [testenv] 18 | extras = test 19 | 20 | allowlist_externals = 21 | poetry 22 | 23 | # NOTE: We should do poetry install with `--sync` to remove any random 24 | # extra libraries. 25 | commands_pre = 26 | poetry install --no-interaction --no-root 27 | 28 | commands = 29 | poetry run pytest 30 | 31 | envdir = {toxworkdir}/v/{envname} 32 | 33 | passenv = 34 | PYTHONPATH 35 | GITHUB_* 36 | 37 | usedevelop = True 38 | 39 | [testenv:black] 40 | commands = 41 | black -l 100 -t py311 --check --diff {posargs} 42 | deps = 43 | black 44 | 45 | [testenv:black-fix] 46 | commands = 47 | black -l 100 -t py311 . 48 | deps = 49 | black 50 | 51 | [testenv:flake8] 52 | commands = 53 | flake8 {posargs} 54 | deps = 55 | flake8 56 | 57 | [testenv:isort] 58 | commands = 59 | isort --check-only --diff . 60 | deps = 61 | isort 62 | 63 | [testenv:dist] 64 | commands = 65 | twine check .tox/dist/* 66 | deps = 67 | twine 68 | usedevelop = False 69 | --------------------------------------------------------------------------------