Compare commits
71 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1a38d1966 | ||
|
|
3fe87f2917 | ||
|
|
dc48c11e1a | ||
|
|
97126391c4 | ||
|
|
6a286a4c23 | ||
|
|
4bc0edcca9 | ||
|
|
4f96ee402b | ||
|
|
2ba13f5e07 | ||
|
|
a4d8be683b | ||
|
|
9501c34f60 | ||
|
|
290da707ea | ||
|
|
64ae5709d3 | ||
|
|
5c1b0e89ef | ||
|
|
0c85abb2d4 | ||
|
|
a0fa559255 | ||
|
|
3e1ccaf5ba | ||
|
|
17384a8908 | ||
|
|
7bb9ebf8f7 | ||
|
|
e36411cfaf | ||
|
|
d744ed4c90 | ||
|
|
c7ec596031 | ||
|
|
3536caf5f9 | ||
|
|
58186de464 | ||
|
|
999900278f | ||
|
|
82d99d50d0 | ||
|
|
3afcf9c01c | ||
|
|
3a15c1050a | ||
|
|
71a43a069d | ||
|
|
0bfbbdccc3 | ||
|
|
d1974ad1fb | ||
|
|
7078759cdf | ||
|
|
1cedba7e49 | ||
|
|
b5ac0f45a2 | ||
|
|
8f7cacb10a | ||
|
|
676110c01e | ||
|
|
a3102ded18 | ||
|
|
d9d8074f73 | ||
|
|
fc9a290482 | ||
|
|
f63b94c31d | ||
|
|
ac469383b8 | ||
|
|
b081d66ca2 | ||
|
|
aaf2362634 | ||
|
|
683c3360a5 | ||
|
|
93cdc7f44e | ||
|
|
943968f2c7 | ||
|
|
657f9357f2 | ||
|
|
7cc40e802f | ||
|
|
d62b718f6d | ||
|
|
442a5c9fd6 | ||
|
|
d72607b080 | ||
|
|
60bb779c59 | ||
|
|
e1532b1451 | ||
|
|
e1951d20d0 | ||
|
|
35abd2962f | ||
|
|
b262e115d3 | ||
|
|
95982725c3 | ||
|
|
70e79825b3 | ||
|
|
f2174dfa72 | ||
|
|
fe21bfe88c | ||
|
|
93f70f73c2 | ||
|
|
1442c945cc | ||
|
|
a729648a34 | ||
|
|
3d6ddb8dcd | ||
|
|
b41f09bee4 | ||
|
|
db80776ac0 | ||
|
|
02ca1b00c9 | ||
|
|
7b06a3c053 | ||
|
|
14126c67b1 | ||
|
|
5e93d6321d | ||
|
|
1f389dbab9 | ||
|
|
ac4c8affb0 |
17
.deepsource.toml
Normal file
17
.deepsource.toml
Normal file
@@ -0,0 +1,17 @@
|
||||
version = 1
|
||||
|
||||
exclude_patterns = [
|
||||
"pywidevine/license_protocol_pb2.py"
|
||||
]
|
||||
|
||||
[[analyzers]]
|
||||
name = "python"
|
||||
enabled = true
|
||||
|
||||
[analyzers.meta]
|
||||
runtime_version = "3.x.x"
|
||||
max_line_length = 120
|
||||
|
||||
[[analyzers]]
|
||||
name = "secrets"
|
||||
enabled = false
|
||||
120
CHANGELOG.md
120
CHANGELOG.md
@@ -5,7 +5,117 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.0.1] - 2021-07-21
|
||||
## [1.2.1] - 2022-08-02
|
||||
|
||||
This release is primarily a maintenance release for `serve` functionality but some Cdm fixes are also present.
|
||||
|
||||
### Added
|
||||
|
||||
- You can now return all License Keys from Serve's `/keys` endpoint by supplying `ALL` as the key type.
|
||||
This adds support for Exchange Systems like Netflix's WidevineExchange MSL scheme. I recommend using `ALL` unless
|
||||
you only want `CONTENT` keys and will not be using any other type of keys including `SIGNING` and `OPERATOR_SESSION`.
|
||||
- Serve now has a `/close` endpoint to close a session. The Cdm has a limit of 50 sessions per user.
|
||||
- Serve now responds with a `Server` header denoting that pywidevine serve is being used, also specifying the version.
|
||||
This allows Clients to selectively support APIs based on version, and also verify the API as being supported at all.
|
||||
- Serve now verifies that all Devices in config actually exist before letting you start serving.
|
||||
|
||||
### Changed
|
||||
|
||||
- Downgraded lxml to >=4.8.0 to support projects using pycaption, which is likely considering the project's topic.
|
||||
- All of Serve's endpoints now have a `/{device}` prefix. E.g., instead of `/challenge/STREAMING`, it's now
|
||||
`/device_name/challenge/STREAMING`. This is to support a multi-device per-user Cdm setup, see Fixed below regarding
|
||||
Serve's Cdm objects.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed support for Raw PSSH values, e.g., Netflix's WidevineExchange MSL Scheme arbitrary init_data value.
|
||||
- The Service Certificate is now saved to the Session in full SignedMessage form instead of just the underlying
|
||||
DrmCertificate. This is so any class inheriting the Cdm (e.g., for Remote capabilities) can sufficiently use
|
||||
and supply the service certificate while being signed.
|
||||
- Serve's /open endpoint will now return a 400 error if there's too many sessions opened.
|
||||
- Serve's Cdm objects with Device initialized are now stored per-user and device name. This fixes the issue where the
|
||||
entire user base has only 50 sessions available to be used. Effectively rate limiting to only 50 users at a time.
|
||||
Since /close endpoint was not implemented yet, there was no way to even close effectively meaning only 50 uses could
|
||||
be done.
|
||||
|
||||
## [1.2.0] - 2022-07-30
|
||||
|
||||
### Added
|
||||
|
||||
- New CLI command `serve` to serve local WVD devices and CDM sessions remotely as a JSON API.
|
||||
- The CLI command `migrate` can now accept a folder path to batch migrate WVD files.
|
||||
- The Cdm now uses custom exceptions where the use case is justified. All custom exceptions are under a parent custom
|
||||
exception to allow catching of any Pywidevine exception.
|
||||
|
||||
### Changed
|
||||
|
||||
- The Cdm has been reworked as a session-based Cdm. You now initialize the Cdm with just the device you wish to use,
|
||||
and now you open sessions with `Cdm.open()` to get a session ID. For usage example see `license` CLI command in
|
||||
`main.py`.
|
||||
- The Cdm no longer requires you to specify `raw` bool parameter. It now supports arbitrary and valid Widevine Cenc
|
||||
Header Data without needing to explicitly specify which it is.
|
||||
- The Cdm `pssh` param has been renamed as `init_data`. Doc-strings have been changed to prioritize explanation of it
|
||||
referring to Widevine Cenc Header rather than PSSH Boxes. This is to show that the Cdm more-so wants Init Data than
|
||||
a PSSH box. The full PSSH is never kept nor ever used, only it's init data is. It still supports PSSH box data.
|
||||
- Cdm `set_service_certificate()` now returns the provider ID string rather than the underlying (and now verified)
|
||||
DrmCertificate. This is because the DrmCertificate is not likely useful and would still be possible to obtain in full
|
||||
but quick access to the Provider ID may be more useful.
|
||||
- License responses can now be only be parsed once by `Cdm.parse_license()`. Any further attempts will raise an
|
||||
InvalidContext exception. This is because context data is now cleared for it's respective License Request once it's
|
||||
parsed to reduce data lingering in memory.
|
||||
- Trove Classifier for Development Status is now 5 (Production/Stable).
|
||||
|
||||
### Removed
|
||||
|
||||
- You can no longer provide a direct `DrmCertificate` to `Cdm.set_service_certificate()` for security reasons.
|
||||
You must provide either a `SignedDrmCertificate` or a `SignedMessage` containing a `SignedDrmCertificate`.
|
||||
- PSSH `from_init_data()` has been removed. It was unused and is unnecessary with improvements to `get_as_box()`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Cdm `set_service_certificate()` now verifies the signature of the provided Certificate. This patches a trivial
|
||||
exploit/workaround that allows an attacker to recover the plaintext Client ID from an encrypted Client ID.
|
||||
- Cdm `parse_license()` now verifies the input message type as a `LICENSE` message.
|
||||
- Cdm `parse_license()` now clears context for the License Request once it's License Response message has been parsed.
|
||||
This reduces data lingering in the `context` dictionary when it may only be needed once.
|
||||
- The Context Availability error handler in Cdm `parse_license()` has been fixed.
|
||||
- Typing of `type_` param of `Cdm.get_license_challenge()` has been fixed.
|
||||
|
||||
## [1.1.1] - 2022-07-22
|
||||
|
||||
### Fixed
|
||||
|
||||
- The --vmp argument of the create-device command is now optional.
|
||||
|
||||
## [1.1.0] - 2022-07-21
|
||||
|
||||
### Added
|
||||
|
||||
- Added support for setting a Service Certificate in SignedDrmCertificate form as well as raw DrmCertificate form.
|
||||
However, It's unlikely for the service to provide the certificate in raw DrmCertificate form without a signature.
|
||||
- Added a CLI command `create-device` to create Widevine Device (`.wvd`) files from RSA PEM/DER Private Keys and
|
||||
Client ID blobs. You can also provide VMP (FileHashes) data which will be merged into the Client ID blob.
|
||||
- Added a CLI command `migrate` that uses `Device.migrate()` and `dump()` to migrate older v1 Widevine Device files
|
||||
to v2.
|
||||
- Added the v1 Structure of Widevine Devices for migration use.
|
||||
- Added `Device.migrate()` class method that effectively loads older format WVD data. You can then use `dumps()` to
|
||||
get back the WVD data in the latest supported format.
|
||||
- Added ability to use Privacy mode on the test command.
|
||||
|
||||
### Changed
|
||||
|
||||
- Set Service Certificates are now stored as the raw underlying DrmCertificate as the signature data is unused by
|
||||
the CDM.
|
||||
- Moved all Widevine Device structures under a Structures class.
|
||||
- I removed the `send_key_control_nonce` flag from all Structures even though it was technically used.
|
||||
This is because the flag was never used as of this project, and I do not want to take up the flag slot.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Devices `dump()` function now uses the correct `type_` parameter when building the struct.
|
||||
- Fixed release date year of v1.0.0 and v1.0.1 in the changelog.
|
||||
|
||||
## [1.0.1] - 2022-07-21
|
||||
|
||||
### Added
|
||||
|
||||
@@ -24,14 +134,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Fixed
|
||||
|
||||
- Cdm's `set_service_certificate()` now correctly raises a DecodeError on Decode Error instead of a ValueError.
|
||||
- CDMs `set_service_certificate()` now correctly raises a DecodeError on Decode Error instead of a ValueError.
|
||||
- Context Data will now always match to their corresponding License Responses. This fixes an issue where creating
|
||||
a second challenge would overwrite the context data of the first challenge. Parsing the first challenge after
|
||||
would result in either a key decrypt error, or garbage key data.
|
||||
|
||||
## [1.0.0] - 2021-07-20
|
||||
## [1.0.0] - 2022-07-20
|
||||
|
||||
Initial Release.
|
||||
|
||||
[1.2.1]: https://github.com/rlaphoenix/pywidevine/releases/tag/v1.2.1
|
||||
[1.2.0]: https://github.com/rlaphoenix/pywidevine/releases/tag/v1.2.0
|
||||
[1.1.1]: https://github.com/rlaphoenix/pywidevine/releases/tag/v1.1.1
|
||||
[1.1.0]: https://github.com/rlaphoenix/pywidevine/releases/tag/v1.1.0
|
||||
[1.0.1]: https://github.com/rlaphoenix/pywidevine/releases/tag/v1.0.1
|
||||
[1.0.0]: https://github.com/rlaphoenix/pywidevine/releases/tag/v1.0.0
|
||||
|
||||
42
README.md
42
README.md
@@ -1,21 +1,35 @@
|
||||
# pywidevine
|
||||
<p align="center">
|
||||
<img src="docs/images/widevine_icon_24.png"> <a href="https://github.com/rlaphoenix/pywidevine">pywidevine</a>
|
||||
<br/>
|
||||
<sup><em>Python Widevine CDM implementation.</em></sup>
|
||||
</p>
|
||||
|
||||
Widevine CDM (Content Decryption Module) implementation in Python.
|
||||
<p align="center">
|
||||
<a href="https://github.com/rlaphoenix/pywidevine/actions/workflows/ci.yml">
|
||||
<img src="https://github.com/rlaphoenix/pywidevine/actions/workflows/ci.yml/badge.svg" alt="Build status">
|
||||
</a>
|
||||
<a href="https://pypi.org/project/pywidevine">
|
||||
<img src="https://img.shields.io/badge/python-3.7%2B-informational" alt="Python version">
|
||||
</a>
|
||||
<a href="https://deepsource.io/gh/rlaphoenix/pywidevine">
|
||||
<img src="https://deepsource.io/gh/rlaphoenix/pywidevine.svg/?label=active+issues" alt="DeepSource">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
## Disclaimer
|
||||
|
||||
1. This project requires the use of a Google provisioned private key and Client Identification blob.
|
||||
2. Neither of them are provided by this project.
|
||||
3. Public test provisions are available to use for testing this project.
|
||||
4. License Servers have the ability to block requests from test provisions.
|
||||
5. This project does not condone piracy or any action against the terms of the DRM systems.
|
||||
6. All efforts in this project have been the result of Reverse Engineering and Trial & Error.
|
||||
1. This project requires a valid Google-provisioned Private Key and Client Identification blob which are not
|
||||
provided by this project.
|
||||
2. Public test provisions are available and provided by Google to use for testing projects such as this one.
|
||||
3. License Servers have the ability to block requests from any provision, and are likely already blocking test
|
||||
provisions on production endpoints.
|
||||
4. This project does not condone piracy or any action against the terms of the DRM systems.
|
||||
5. All efforts in this project have been the result of Reverse-Engineering, Publicly available research, and Trial
|
||||
& Error.
|
||||
|
||||
## Protocol
|
||||
|
||||

|
||||
|
||||
*Credit*: w3.org
|
||||

|
||||
|
||||
### Web Server
|
||||
|
||||
@@ -60,6 +74,12 @@ been improving its security using math and obscurity for years. It's getting har
|
||||
versions only being beaten by Brute-force style methods. However, they have a huge team of very skilled workers, and
|
||||
making a CDM in C++ has immediate security benefits and a lot of methods to obscure and obfuscate the code.
|
||||
|
||||
## Credit
|
||||
|
||||
- Widevine Icons © Google.
|
||||
- Protocol Overview © https://www.w3.org/TR/encrypted-media -- slightly modified to fit the page better.
|
||||
- The awesome community for their shared research and insight into the Widevine Protocol and Key Derivation.
|
||||
|
||||
## License
|
||||
|
||||
[GNU General Public License, Version 3.0](LICENSE)
|
||||
|
||||
BIN
docs/images/widevine_icon_24.png
Normal file
BIN
docs/images/widevine_icon_24.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 885 B |
438
poetry.lock
generated
438
poetry.lock
generated
@@ -1,3 +1,69 @@
|
||||
[[package]]
|
||||
name = "aiohttp"
|
||||
version = "3.8.1"
|
||||
description = "Async http client/server framework (asyncio)"
|
||||
category = "main"
|
||||
optional = true
|
||||
python-versions = ">=3.6"
|
||||
|
||||
[package.dependencies]
|
||||
aiosignal = ">=1.1.2"
|
||||
async-timeout = ">=4.0.0a3,<5.0"
|
||||
asynctest = {version = "0.13.0", markers = "python_version < \"3.8\""}
|
||||
attrs = ">=17.3.0"
|
||||
charset-normalizer = ">=2.0,<3.0"
|
||||
frozenlist = ">=1.1.1"
|
||||
multidict = ">=4.5,<7.0"
|
||||
typing-extensions = {version = ">=3.7.4", markers = "python_version < \"3.8\""}
|
||||
yarl = ">=1.0,<2.0"
|
||||
|
||||
[package.extras]
|
||||
speedups = ["aiodns", "brotli", "cchardet"]
|
||||
|
||||
[[package]]
|
||||
name = "aiosignal"
|
||||
version = "1.2.0"
|
||||
description = "aiosignal: a list of registered asynchronous callbacks"
|
||||
category = "main"
|
||||
optional = true
|
||||
python-versions = ">=3.6"
|
||||
|
||||
[package.dependencies]
|
||||
frozenlist = ">=1.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "async-timeout"
|
||||
version = "4.0.2"
|
||||
description = "Timeout context manager for asyncio programs"
|
||||
category = "main"
|
||||
optional = true
|
||||
python-versions = ">=3.6"
|
||||
|
||||
[package.dependencies]
|
||||
typing-extensions = {version = ">=3.6.5", markers = "python_version < \"3.8\""}
|
||||
|
||||
[[package]]
|
||||
name = "asynctest"
|
||||
version = "0.13.0"
|
||||
description = "Enhance the standard unittest package with features for testing asyncio libraries"
|
||||
category = "main"
|
||||
optional = true
|
||||
python-versions = ">=3.5"
|
||||
|
||||
[[package]]
|
||||
name = "attrs"
|
||||
version = "21.4.0"
|
||||
description = "Classes Without Boilerplate"
|
||||
category = "main"
|
||||
optional = true
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
||||
|
||||
[package.extras]
|
||||
dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"]
|
||||
docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"]
|
||||
tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "cloudpickle"]
|
||||
tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "cloudpickle"]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2022.6.15"
|
||||
@@ -45,6 +111,14 @@ category = "main"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
|
||||
[[package]]
|
||||
name = "frozenlist"
|
||||
version = "1.3.0"
|
||||
description = "A list-like structure which implements collections.abc.MutableSequence"
|
||||
category = "main"
|
||||
optional = true
|
||||
python-versions = ">=3.7"
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.3"
|
||||
@@ -84,6 +158,14 @@ html5 = ["html5lib"]
|
||||
htmlsoup = ["beautifulsoup4"]
|
||||
source = ["Cython (>=0.29.7)"]
|
||||
|
||||
[[package]]
|
||||
name = "multidict"
|
||||
version = "6.0.2"
|
||||
description = "multidict implementation"
|
||||
category = "main"
|
||||
optional = true
|
||||
python-versions = ">=3.7"
|
||||
|
||||
[[package]]
|
||||
name = "protobuf"
|
||||
version = "3.19.3"
|
||||
@@ -111,6 +193,14 @@ python-versions = "*"
|
||||
[package.dependencies]
|
||||
construct = "2.8.8"
|
||||
|
||||
[[package]]
|
||||
name = "pyyaml"
|
||||
version = "6.0"
|
||||
description = "YAML parser and emitter for Python"
|
||||
category = "main"
|
||||
optional = true
|
||||
python-versions = ">=3.6"
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.28.1"
|
||||
@@ -137,6 +227,14 @@ category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
|
||||
[[package]]
|
||||
name = "unidecode"
|
||||
version = "1.3.4"
|
||||
description = "ASCII transliterations of Unicode text"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.5"
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "1.26.10"
|
||||
@@ -150,6 +248,19 @@ brotli = ["brotlicffi (>=0.8.0)", "brotli (>=1.0.9)", "brotlipy (>=0.6.0)"]
|
||||
secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"]
|
||||
socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "yarl"
|
||||
version = "1.7.2"
|
||||
description = "Yet another URL library"
|
||||
category = "main"
|
||||
optional = true
|
||||
python-versions = ">=3.6"
|
||||
|
||||
[package.dependencies]
|
||||
idna = ">=2.0"
|
||||
multidict = ">=4.0"
|
||||
typing-extensions = {version = ">=3.7.4", markers = "python_version < \"3.8\""}
|
||||
|
||||
[[package]]
|
||||
name = "zipp"
|
||||
version = "3.8.1"
|
||||
@@ -162,12 +273,102 @@ python-versions = ">=3.7"
|
||||
docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)", "jaraco.tidelift (>=1.4)"]
|
||||
testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.3)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)"]
|
||||
|
||||
[extras]
|
||||
serve = ["aiohttp", "PyYAML"]
|
||||
|
||||
[metadata]
|
||||
lock-version = "1.1"
|
||||
python-versions = ">=3.7,<3.11"
|
||||
content-hash = "9c6a76629e0f0a4e98b6c47707899519f930debd70312d2e909eb42f94cd212f"
|
||||
content-hash = "5180330dabbacf34bd4d6faf8f3d4bdcaae26dc7b2951df04010ce54400eb520"
|
||||
|
||||
[metadata.files]
|
||||
aiohttp = [
|
||||
{file = "aiohttp-3.8.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:1ed0b6477896559f17b9eaeb6d38e07f7f9ffe40b9f0f9627ae8b9926ae260a8"},
|
||||
{file = "aiohttp-3.8.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7dadf3c307b31e0e61689cbf9e06be7a867c563d5a63ce9dca578f956609abf8"},
|
||||
{file = "aiohttp-3.8.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a79004bb58748f31ae1cbe9fa891054baaa46fb106c2dc7af9f8e3304dc30316"},
|
||||
{file = "aiohttp-3.8.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:12de6add4038df8f72fac606dff775791a60f113a725c960f2bab01d8b8e6b15"},
|
||||
{file = "aiohttp-3.8.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f0d5f33feb5f69ddd57a4a4bd3d56c719a141080b445cbf18f238973c5c9923"},
|
||||
{file = "aiohttp-3.8.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eaba923151d9deea315be1f3e2b31cc39a6d1d2f682f942905951f4e40200922"},
|
||||
{file = "aiohttp-3.8.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:099ebd2c37ac74cce10a3527d2b49af80243e2a4fa39e7bce41617fbc35fa3c1"},
|
||||
{file = "aiohttp-3.8.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:2e5d962cf7e1d426aa0e528a7e198658cdc8aa4fe87f781d039ad75dcd52c516"},
|
||||
{file = "aiohttp-3.8.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:fa0ffcace9b3aa34d205d8130f7873fcfefcb6a4dd3dd705b0dab69af6712642"},
|
||||
{file = "aiohttp-3.8.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:61bfc23df345d8c9716d03717c2ed5e27374e0fe6f659ea64edcd27b4b044cf7"},
|
||||
{file = "aiohttp-3.8.1-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:31560d268ff62143e92423ef183680b9829b1b482c011713ae941997921eebc8"},
|
||||
{file = "aiohttp-3.8.1-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:01d7bdb774a9acc838e6b8f1d114f45303841b89b95984cbb7d80ea41172a9e3"},
|
||||
{file = "aiohttp-3.8.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:97ef77eb6b044134c0b3a96e16abcb05ecce892965a2124c566af0fd60f717e2"},
|
||||
{file = "aiohttp-3.8.1-cp310-cp310-win32.whl", hash = "sha256:c2aef4703f1f2ddc6df17519885dbfa3514929149d3ff900b73f45998f2532fa"},
|
||||
{file = "aiohttp-3.8.1-cp310-cp310-win_amd64.whl", hash = "sha256:713ac174a629d39b7c6a3aa757b337599798da4c1157114a314e4e391cd28e32"},
|
||||
{file = "aiohttp-3.8.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:473d93d4450880fe278696549f2e7aed8cd23708c3c1997981464475f32137db"},
|
||||
{file = "aiohttp-3.8.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:99b5eeae8e019e7aad8af8bb314fb908dd2e028b3cdaad87ec05095394cce632"},
|
||||
{file = "aiohttp-3.8.1-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3af642b43ce56c24d063325dd2cf20ee012d2b9ba4c3c008755a301aaea720ad"},
|
||||
{file = "aiohttp-3.8.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c3630c3ef435c0a7c549ba170a0633a56e92629aeed0e707fec832dee313fb7a"},
|
||||
{file = "aiohttp-3.8.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:4a4a4e30bf1edcad13fb0804300557aedd07a92cabc74382fdd0ba6ca2661091"},
|
||||
{file = "aiohttp-3.8.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:6f8b01295e26c68b3a1b90efb7a89029110d3a4139270b24fda961893216c440"},
|
||||
{file = "aiohttp-3.8.1-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:a25fa703a527158aaf10dafd956f7d42ac6d30ec80e9a70846253dd13e2f067b"},
|
||||
{file = "aiohttp-3.8.1-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:5bfde62d1d2641a1f5173b8c8c2d96ceb4854f54a44c23102e2ccc7e02f003ec"},
|
||||
{file = "aiohttp-3.8.1-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:51467000f3647d519272392f484126aa716f747859794ac9924a7aafa86cd411"},
|
||||
{file = "aiohttp-3.8.1-cp36-cp36m-musllinux_1_1_s390x.whl", hash = "sha256:03a6d5349c9ee8f79ab3ff3694d6ce1cfc3ced1c9d36200cb8f08ba06bd3b782"},
|
||||
{file = "aiohttp-3.8.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:102e487eeb82afac440581e5d7f8f44560b36cf0bdd11abc51a46c1cd88914d4"},
|
||||
{file = "aiohttp-3.8.1-cp36-cp36m-win32.whl", hash = "sha256:4aed991a28ea3ce320dc8ce655875e1e00a11bdd29fe9444dd4f88c30d558602"},
|
||||
{file = "aiohttp-3.8.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b0e20cddbd676ab8a64c774fefa0ad787cc506afd844de95da56060348021e96"},
|
||||
{file = "aiohttp-3.8.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:37951ad2f4a6df6506750a23f7cbabad24c73c65f23f72e95897bb2cecbae676"},
|
||||
{file = "aiohttp-3.8.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c23b1ad869653bc818e972b7a3a79852d0e494e9ab7e1a701a3decc49c20d51"},
|
||||
{file = "aiohttp-3.8.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:15b09b06dae900777833fe7fc4b4aa426556ce95847a3e8d7548e2d19e34edb8"},
|
||||
{file = "aiohttp-3.8.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:477c3ea0ba410b2b56b7efb072c36fa91b1e6fc331761798fa3f28bb224830dd"},
|
||||
{file = "aiohttp-3.8.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:2f2f69dca064926e79997f45b2f34e202b320fd3782f17a91941f7eb85502ee2"},
|
||||
{file = "aiohttp-3.8.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:ef9612483cb35171d51d9173647eed5d0069eaa2ee812793a75373447d487aa4"},
|
||||
{file = "aiohttp-3.8.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:6d69f36d445c45cda7b3b26afef2fc34ef5ac0cdc75584a87ef307ee3c8c6d00"},
|
||||
{file = "aiohttp-3.8.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:55c3d1072704d27401c92339144d199d9de7b52627f724a949fc7d5fc56d8b93"},
|
||||
{file = "aiohttp-3.8.1-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:b9d00268fcb9f66fbcc7cd9fe423741d90c75ee029a1d15c09b22d23253c0a44"},
|
||||
{file = "aiohttp-3.8.1-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:07b05cd3305e8a73112103c834e91cd27ce5b4bd07850c4b4dbd1877d3f45be7"},
|
||||
{file = "aiohttp-3.8.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:c34dc4958b232ef6188c4318cb7b2c2d80521c9a56c52449f8f93ab7bc2a8a1c"},
|
||||
{file = "aiohttp-3.8.1-cp37-cp37m-win32.whl", hash = "sha256:d2f9b69293c33aaa53d923032fe227feac867f81682f002ce33ffae978f0a9a9"},
|
||||
{file = "aiohttp-3.8.1-cp37-cp37m-win_amd64.whl", hash = "sha256:6ae828d3a003f03ae31915c31fa684b9890ea44c9c989056fea96e3d12a9fa17"},
|
||||
{file = "aiohttp-3.8.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:0c7ebbbde809ff4e970824b2b6cb7e4222be6b95a296e46c03cf050878fc1785"},
|
||||
{file = "aiohttp-3.8.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8b7ef7cbd4fec9a1e811a5de813311ed4f7ac7d93e0fda233c9b3e1428f7dd7b"},
|
||||
{file = "aiohttp-3.8.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c3d6a4d0619e09dcd61021debf7059955c2004fa29f48788a3dfaf9c9901a7cd"},
|
||||
{file = "aiohttp-3.8.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:718626a174e7e467f0558954f94af117b7d4695d48eb980146016afa4b580b2e"},
|
||||
{file = "aiohttp-3.8.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:589c72667a5febd36f1315aa6e5f56dd4aa4862df295cb51c769d16142ddd7cd"},
|
||||
{file = "aiohttp-3.8.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2ed076098b171573161eb146afcb9129b5ff63308960aeca4b676d9d3c35e700"},
|
||||
{file = "aiohttp-3.8.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:086f92daf51a032d062ec5f58af5ca6a44d082c35299c96376a41cbb33034675"},
|
||||
{file = "aiohttp-3.8.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:11691cf4dc5b94236ccc609b70fec991234e7ef8d4c02dd0c9668d1e486f5abf"},
|
||||
{file = "aiohttp-3.8.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:31d1e1c0dbf19ebccbfd62eff461518dcb1e307b195e93bba60c965a4dcf1ba0"},
|
||||
{file = "aiohttp-3.8.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:11a67c0d562e07067c4e86bffc1553f2cf5b664d6111c894671b2b8712f3aba5"},
|
||||
{file = "aiohttp-3.8.1-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:bb01ba6b0d3f6c68b89fce7305080145d4877ad3acaed424bae4d4ee75faa950"},
|
||||
{file = "aiohttp-3.8.1-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:44db35a9e15d6fe5c40d74952e803b1d96e964f683b5a78c3cc64eb177878155"},
|
||||
{file = "aiohttp-3.8.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:844a9b460871ee0a0b0b68a64890dae9c415e513db0f4a7e3cab41a0f2fedf33"},
|
||||
{file = "aiohttp-3.8.1-cp38-cp38-win32.whl", hash = "sha256:7d08744e9bae2ca9c382581f7dce1273fe3c9bae94ff572c3626e8da5b193c6a"},
|
||||
{file = "aiohttp-3.8.1-cp38-cp38-win_amd64.whl", hash = "sha256:04d48b8ce6ab3cf2097b1855e1505181bdd05586ca275f2505514a6e274e8e75"},
|
||||
{file = "aiohttp-3.8.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:f5315a2eb0239185af1bddb1abf472d877fede3cc8d143c6cddad37678293237"},
|
||||
{file = "aiohttp-3.8.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a996d01ca39b8dfe77440f3cd600825d05841088fd6bc0144cc6c2ec14cc5f74"},
|
||||
{file = "aiohttp-3.8.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:13487abd2f761d4be7c8ff9080de2671e53fff69711d46de703c310c4c9317ca"},
|
||||
{file = "aiohttp-3.8.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ea302f34477fda3f85560a06d9ebdc7fa41e82420e892fc50b577e35fc6a50b2"},
|
||||
{file = "aiohttp-3.8.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a2f635ce61a89c5732537a7896b6319a8fcfa23ba09bec36e1b1ac0ab31270d2"},
|
||||
{file = "aiohttp-3.8.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e999f2d0e12eea01caeecb17b653f3713d758f6dcc770417cf29ef08d3931421"},
|
||||
{file = "aiohttp-3.8.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0770e2806a30e744b4e21c9d73b7bee18a1cfa3c47991ee2e5a65b887c49d5cf"},
|
||||
{file = "aiohttp-3.8.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:d15367ce87c8e9e09b0f989bfd72dc641bcd04ba091c68cd305312d00962addd"},
|
||||
{file = "aiohttp-3.8.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:6c7cefb4b0640703eb1069835c02486669312bf2f12b48a748e0a7756d0de33d"},
|
||||
{file = "aiohttp-3.8.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:71927042ed6365a09a98a6377501af5c9f0a4d38083652bcd2281a06a5976724"},
|
||||
{file = "aiohttp-3.8.1-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:28d490af82bc6b7ce53ff31337a18a10498303fe66f701ab65ef27e143c3b0ef"},
|
||||
{file = "aiohttp-3.8.1-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:b6613280ccedf24354406caf785db748bebbddcf31408b20c0b48cb86af76866"},
|
||||
{file = "aiohttp-3.8.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:81e3d8c34c623ca4e36c46524a3530e99c0bc95ed068fd6e9b55cb721d408fb2"},
|
||||
{file = "aiohttp-3.8.1-cp39-cp39-win32.whl", hash = "sha256:7187a76598bdb895af0adbd2fb7474d7f6025d170bc0a1130242da817ce9e7d1"},
|
||||
{file = "aiohttp-3.8.1-cp39-cp39-win_amd64.whl", hash = "sha256:1c182cb873bc91b411e184dab7a2b664d4fea2743df0e4d57402f7f3fa644bac"},
|
||||
{file = "aiohttp-3.8.1.tar.gz", hash = "sha256:fc5471e1a54de15ef71c1bc6ebe80d4dc681ea600e68bfd1cbce40427f0b7578"},
|
||||
]
|
||||
aiosignal = [
|
||||
{file = "aiosignal-1.2.0-py3-none-any.whl", hash = "sha256:26e62109036cd181df6e6ad646f91f0dcfd05fe16d0cb924138ff2ab75d64e3a"},
|
||||
{file = "aiosignal-1.2.0.tar.gz", hash = "sha256:78ed67db6c7b7ced4f98e495e572106d5c432a93e1ddd1bf475e1dc05f5b7df2"},
|
||||
]
|
||||
async-timeout = [
|
||||
{file = "async-timeout-4.0.2.tar.gz", hash = "sha256:2163e1640ddb52b7a8c80d0a67a08587e5d245cc9c553a74a847056bc2976b15"},
|
||||
{file = "async_timeout-4.0.2-py3-none-any.whl", hash = "sha256:8ca1e4fcf50d07413d66d1a5e416e42cfdf5851c981d679a09851a6853383b3c"},
|
||||
]
|
||||
asynctest = []
|
||||
attrs = [
|
||||
{file = "attrs-21.4.0-py2.py3-none-any.whl", hash = "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4"},
|
||||
{file = "attrs-21.4.0.tar.gz", hash = "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd"},
|
||||
]
|
||||
certifi = []
|
||||
charset-normalizer = []
|
||||
click = [
|
||||
@@ -181,6 +382,67 @@ colorama = [
|
||||
construct = [
|
||||
{file = "construct-2.8.8.tar.gz", hash = "sha256:1b84b8147f6fd15bcf64b737c3e8ac5100811ad80c830cb4b2545140511c4157"},
|
||||
]
|
||||
frozenlist = [
|
||||
{file = "frozenlist-1.3.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d2257aaba9660f78c7b1d8fea963b68f3feffb1a9d5d05a18401ca9eb3e8d0a3"},
|
||||
{file = "frozenlist-1.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4a44ebbf601d7bac77976d429e9bdb5a4614f9f4027777f9e54fd765196e9d3b"},
|
||||
{file = "frozenlist-1.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:45334234ec30fc4ea677f43171b18a27505bfb2dba9aca4398a62692c0ea8868"},
|
||||
{file = "frozenlist-1.3.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:47be22dc27ed933d55ee55845d34a3e4e9f6fee93039e7f8ebadb0c2f60d403f"},
|
||||
{file = "frozenlist-1.3.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:03a7dd1bfce30216a3f51a84e6dd0e4a573d23ca50f0346634916ff105ba6e6b"},
|
||||
{file = "frozenlist-1.3.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:691ddf6dc50480ce49f68441f1d16a4c3325887453837036e0fb94736eae1e58"},
|
||||
{file = "frozenlist-1.3.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bde99812f237f79eaf3f04ebffd74f6718bbd216101b35ac7955c2d47c17da02"},
|
||||
{file = "frozenlist-1.3.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6a202458d1298ced3768f5a7d44301e7c86defac162ace0ab7434c2e961166e8"},
|
||||
{file = "frozenlist-1.3.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b9e3e9e365991f8cc5f5edc1fd65b58b41d0514a6a7ad95ef5c7f34eb49b3d3e"},
|
||||
{file = "frozenlist-1.3.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:04cb491c4b1c051734d41ea2552fde292f5f3a9c911363f74f39c23659c4af78"},
|
||||
{file = "frozenlist-1.3.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:436496321dad302b8b27ca955364a439ed1f0999311c393dccb243e451ff66aa"},
|
||||
{file = "frozenlist-1.3.0-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:754728d65f1acc61e0f4df784456106e35afb7bf39cfe37227ab00436fb38676"},
|
||||
{file = "frozenlist-1.3.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6eb275c6385dd72594758cbe96c07cdb9bd6becf84235f4a594bdf21e3596c9d"},
|
||||
{file = "frozenlist-1.3.0-cp310-cp310-win32.whl", hash = "sha256:e30b2f9683812eb30cf3f0a8e9f79f8d590a7999f731cf39f9105a7c4a39489d"},
|
||||
{file = "frozenlist-1.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:f7353ba3367473d1d616ee727945f439e027f0bb16ac1a750219a8344d1d5d3c"},
|
||||
{file = "frozenlist-1.3.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:88aafd445a233dbbf8a65a62bc3249a0acd0d81ab18f6feb461cc5a938610d24"},
|
||||
{file = "frozenlist-1.3.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4406cfabef8f07b3b3af0f50f70938ec06d9f0fc26cbdeaab431cbc3ca3caeaa"},
|
||||
{file = "frozenlist-1.3.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8cf829bd2e2956066dd4de43fd8ec881d87842a06708c035b37ef632930505a2"},
|
||||
{file = "frozenlist-1.3.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:603b9091bd70fae7be28bdb8aa5c9990f4241aa33abb673390a7f7329296695f"},
|
||||
{file = "frozenlist-1.3.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:25af28b560e0c76fa41f550eacb389905633e7ac02d6eb3c09017fa1c8cdfde1"},
|
||||
{file = "frozenlist-1.3.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94c7a8a9fc9383b52c410a2ec952521906d355d18fccc927fca52ab575ee8b93"},
|
||||
{file = "frozenlist-1.3.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:65bc6e2fece04e2145ab6e3c47428d1bbc05aede61ae365b2c1bddd94906e478"},
|
||||
{file = "frozenlist-1.3.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:3f7c935c7b58b0d78c0beea0c7358e165f95f1fd8a7e98baa40d22a05b4a8141"},
|
||||
{file = "frozenlist-1.3.0-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:bd89acd1b8bb4f31b47072615d72e7f53a948d302b7c1d1455e42622de180eae"},
|
||||
{file = "frozenlist-1.3.0-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:6983a31698490825171be44ffbafeaa930ddf590d3f051e397143a5045513b01"},
|
||||
{file = "frozenlist-1.3.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:adac9700675cf99e3615eb6a0eb5e9f5a4143c7d42c05cea2e7f71c27a3d0846"},
|
||||
{file = "frozenlist-1.3.0-cp37-cp37m-win32.whl", hash = "sha256:0c36e78b9509e97042ef869c0e1e6ef6429e55817c12d78245eb915e1cca7468"},
|
||||
{file = "frozenlist-1.3.0-cp37-cp37m-win_amd64.whl", hash = "sha256:57f4d3f03a18facacb2a6bcd21bccd011e3b75d463dc49f838fd699d074fabd1"},
|
||||
{file = "frozenlist-1.3.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:8c905a5186d77111f02144fab5b849ab524f1e876a1e75205cd1386a9be4b00a"},
|
||||
{file = "frozenlist-1.3.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b5009062d78a8c6890d50b4e53b0ddda31841b3935c1937e2ed8c1bda1c7fb9d"},
|
||||
{file = "frozenlist-1.3.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2fdc3cd845e5a1f71a0c3518528bfdbfe2efaf9886d6f49eacc5ee4fd9a10953"},
|
||||
{file = "frozenlist-1.3.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:92e650bd09b5dda929523b9f8e7f99b24deac61240ecc1a32aeba487afcd970f"},
|
||||
{file = "frozenlist-1.3.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:40dff8962b8eba91fd3848d857203f0bd704b5f1fa2b3fc9af64901a190bba08"},
|
||||
{file = "frozenlist-1.3.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:768efd082074bb203c934e83a61654ed4931ef02412c2fbdecea0cff7ecd0274"},
|
||||
{file = "frozenlist-1.3.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:006d3595e7d4108a12025ddf415ae0f6c9e736e726a5db0183326fd191b14c5e"},
|
||||
{file = "frozenlist-1.3.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:871d42623ae15eb0b0e9df65baeee6976b2e161d0ba93155411d58ff27483ad8"},
|
||||
{file = "frozenlist-1.3.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:aff388be97ef2677ae185e72dc500d19ecaf31b698986800d3fc4f399a5e30a5"},
|
||||
{file = "frozenlist-1.3.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:9f892d6a94ec5c7b785e548e42722e6f3a52f5f32a8461e82ac3e67a3bd073f1"},
|
||||
{file = "frozenlist-1.3.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:e982878792c971cbd60ee510c4ee5bf089a8246226dea1f2138aa0bb67aff148"},
|
||||
{file = "frozenlist-1.3.0-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:c6c321dd013e8fc20735b92cb4892c115f5cdb82c817b1e5b07f6b95d952b2f0"},
|
||||
{file = "frozenlist-1.3.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:30530930410855c451bea83f7b272fb1c495ed9d5cc72895ac29e91279401db3"},
|
||||
{file = "frozenlist-1.3.0-cp38-cp38-win32.whl", hash = "sha256:40ec383bc194accba825fbb7d0ef3dda5736ceab2375462f1d8672d9f6b68d07"},
|
||||
{file = "frozenlist-1.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:f20baa05eaa2bcd5404c445ec51aed1c268d62600362dc6cfe04fae34a424bd9"},
|
||||
{file = "frozenlist-1.3.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:0437fe763fb5d4adad1756050cbf855bbb2bf0d9385c7bb13d7a10b0dd550486"},
|
||||
{file = "frozenlist-1.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b684c68077b84522b5c7eafc1dc735bfa5b341fb011d5552ebe0968e22ed641c"},
|
||||
{file = "frozenlist-1.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:93641a51f89473837333b2f8100f3f89795295b858cd4c7d4a1f18e299dc0a4f"},
|
||||
{file = "frozenlist-1.3.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6d32ff213aef0fd0bcf803bffe15cfa2d4fde237d1d4838e62aec242a8362fa"},
|
||||
{file = "frozenlist-1.3.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:31977f84828b5bb856ca1eb07bf7e3a34f33a5cddce981d880240ba06639b94d"},
|
||||
{file = "frozenlist-1.3.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3c62964192a1c0c30b49f403495911298810bada64e4f03249ca35a33ca0417a"},
|
||||
{file = "frozenlist-1.3.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4eda49bea3602812518765810af732229b4291d2695ed24a0a20e098c45a707b"},
|
||||
{file = "frozenlist-1.3.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acb267b09a509c1df5a4ca04140da96016f40d2ed183cdc356d237286c971b51"},
|
||||
{file = "frozenlist-1.3.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:e1e26ac0a253a2907d654a37e390904426d5ae5483150ce3adedb35c8c06614a"},
|
||||
{file = "frozenlist-1.3.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f96293d6f982c58ebebb428c50163d010c2f05de0cde99fd681bfdc18d4b2dc2"},
|
||||
{file = "frozenlist-1.3.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:e84cb61b0ac40a0c3e0e8b79c575161c5300d1d89e13c0e02f76193982f066ed"},
|
||||
{file = "frozenlist-1.3.0-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:ff9310f05b9d9c5c4dd472983dc956901ee6cb2c3ec1ab116ecdde25f3ce4951"},
|
||||
{file = "frozenlist-1.3.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d26b650b71fdc88065b7a21f8ace70175bcf3b5bdba5ea22df4bfd893e795a3b"},
|
||||
{file = "frozenlist-1.3.0-cp39-cp39-win32.whl", hash = "sha256:01a73627448b1f2145bddb6e6c2259988bb8aee0fb361776ff8604b99616cd08"},
|
||||
{file = "frozenlist-1.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:772965f773757a6026dea111a15e6e2678fbd6216180f82a48a40b27de1ee2ab"},
|
||||
{file = "frozenlist-1.3.0.tar.gz", hash = "sha256:ce6f2ba0edb7b0c1d8976565298ad2deba6f8064d2bebb6ffce2ca896eb35b0b"},
|
||||
]
|
||||
idna = [
|
||||
{file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"},
|
||||
{file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"},
|
||||
@@ -190,10 +452,184 @@ importlib-metadata = [
|
||||
{file = "importlib_metadata-4.12.0.tar.gz", hash = "sha256:637245b8bab2b6502fcbc752cc4b7a6f6243bb02b31c5c26156ad103d3d45670"},
|
||||
]
|
||||
lxml = []
|
||||
multidict = [
|
||||
{file = "multidict-6.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:0b9e95a740109c6047602f4db4da9949e6c5945cefbad34a1299775ddc9a62e2"},
|
||||
{file = "multidict-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac0e27844758d7177989ce406acc6a83c16ed4524ebc363c1f748cba184d89d3"},
|
||||
{file = "multidict-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:041b81a5f6b38244b34dc18c7b6aba91f9cdaf854d9a39e5ff0b58e2b5773b9c"},
|
||||
{file = "multidict-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5fdda29a3c7e76a064f2477c9aab1ba96fd94e02e386f1e665bca1807fc5386f"},
|
||||
{file = "multidict-6.0.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3368bf2398b0e0fcbf46d85795adc4c259299fec50c1416d0f77c0a843a3eed9"},
|
||||
{file = "multidict-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f4f052ee022928d34fe1f4d2bc743f32609fb79ed9c49a1710a5ad6b2198db20"},
|
||||
{file = "multidict-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:225383a6603c086e6cef0f2f05564acb4f4d5f019a4e3e983f572b8530f70c88"},
|
||||
{file = "multidict-6.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:50bd442726e288e884f7be9071016c15a8742eb689a593a0cac49ea093eef0a7"},
|
||||
{file = "multidict-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:47e6a7e923e9cada7c139531feac59448f1f47727a79076c0b1ee80274cd8eee"},
|
||||
{file = "multidict-6.0.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:0556a1d4ea2d949efe5fd76a09b4a82e3a4a30700553a6725535098d8d9fb672"},
|
||||
{file = "multidict-6.0.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:626fe10ac87851f4cffecee161fc6f8f9853f0f6f1035b59337a51d29ff3b4f9"},
|
||||
{file = "multidict-6.0.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:8064b7c6f0af936a741ea1efd18690bacfbae4078c0c385d7c3f611d11f0cf87"},
|
||||
{file = "multidict-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:2d36e929d7f6a16d4eb11b250719c39560dd70545356365b494249e2186bc389"},
|
||||
{file = "multidict-6.0.2-cp310-cp310-win32.whl", hash = "sha256:fcb91630817aa8b9bc4a74023e4198480587269c272c58b3279875ed7235c293"},
|
||||
{file = "multidict-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:8cbf0132f3de7cc6c6ce00147cc78e6439ea736cee6bca4f068bcf892b0fd658"},
|
||||
{file = "multidict-6.0.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:05f6949d6169878a03e607a21e3b862eaf8e356590e8bdae4227eedadacf6e51"},
|
||||
{file = "multidict-6.0.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2c2e459f7050aeb7c1b1276763364884595d47000c1cddb51764c0d8976e608"},
|
||||
{file = "multidict-6.0.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d0509e469d48940147e1235d994cd849a8f8195e0bca65f8f5439c56e17872a3"},
|
||||
{file = "multidict-6.0.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:514fe2b8d750d6cdb4712346a2c5084a80220821a3e91f3f71eec11cf8d28fd4"},
|
||||
{file = "multidict-6.0.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:19adcfc2a7197cdc3987044e3f415168fc5dc1f720c932eb1ef4f71a2067e08b"},
|
||||
{file = "multidict-6.0.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9d153e7f1f9ba0b23ad1568b3b9e17301e23b042c23870f9ee0522dc5cc79e8"},
|
||||
{file = "multidict-6.0.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:aef9cc3d9c7d63d924adac329c33835e0243b5052a6dfcbf7732a921c6e918ba"},
|
||||
{file = "multidict-6.0.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:4571f1beddff25f3e925eea34268422622963cd8dc395bb8778eb28418248e43"},
|
||||
{file = "multidict-6.0.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:d48b8ee1d4068561ce8033d2c344cf5232cb29ee1a0206a7b828c79cbc5982b8"},
|
||||
{file = "multidict-6.0.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:45183c96ddf61bf96d2684d9fbaf6f3564d86b34cb125761f9a0ef9e36c1d55b"},
|
||||
{file = "multidict-6.0.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:75bdf08716edde767b09e76829db8c1e5ca9d8bb0a8d4bd94ae1eafe3dac5e15"},
|
||||
{file = "multidict-6.0.2-cp37-cp37m-win32.whl", hash = "sha256:a45e1135cb07086833ce969555df39149680e5471c04dfd6a915abd2fc3f6dbc"},
|
||||
{file = "multidict-6.0.2-cp37-cp37m-win_amd64.whl", hash = "sha256:6f3cdef8a247d1eafa649085812f8a310e728bdf3900ff6c434eafb2d443b23a"},
|
||||
{file = "multidict-6.0.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:0327292e745a880459ef71be14e709aaea2f783f3537588fb4ed09b6c01bca60"},
|
||||
{file = "multidict-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e875b6086e325bab7e680e4316d667fc0e5e174bb5611eb16b3ea121c8951b86"},
|
||||
{file = "multidict-6.0.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:feea820722e69451743a3d56ad74948b68bf456984d63c1a92e8347b7b88452d"},
|
||||
{file = "multidict-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc57c68cb9139c7cd6fc39f211b02198e69fb90ce4bc4a094cf5fe0d20fd8b0"},
|
||||
{file = "multidict-6.0.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:497988d6b6ec6ed6f87030ec03280b696ca47dbf0648045e4e1d28b80346560d"},
|
||||
{file = "multidict-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:89171b2c769e03a953d5969b2f272efa931426355b6c0cb508022976a17fd376"},
|
||||
{file = "multidict-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:684133b1e1fe91eda8fa7447f137c9490a064c6b7f392aa857bba83a28cfb693"},
|
||||
{file = "multidict-6.0.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd9fc9c4849a07f3635ccffa895d57abce554b467d611a5009ba4f39b78a8849"},
|
||||
{file = "multidict-6.0.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:e07c8e79d6e6fd37b42f3250dba122053fddb319e84b55dd3a8d6446e1a7ee49"},
|
||||
{file = "multidict-6.0.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:4070613ea2227da2bfb2c35a6041e4371b0af6b0be57f424fe2318b42a748516"},
|
||||
{file = "multidict-6.0.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:47fbeedbf94bed6547d3aa632075d804867a352d86688c04e606971595460227"},
|
||||
{file = "multidict-6.0.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:5774d9218d77befa7b70d836004a768fb9aa4fdb53c97498f4d8d3f67bb9cfa9"},
|
||||
{file = "multidict-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2957489cba47c2539a8eb7ab32ff49101439ccf78eab724c828c1a54ff3ff98d"},
|
||||
{file = "multidict-6.0.2-cp38-cp38-win32.whl", hash = "sha256:e5b20e9599ba74391ca0cfbd7b328fcc20976823ba19bc573983a25b32e92b57"},
|
||||
{file = "multidict-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:8004dca28e15b86d1b1372515f32eb6f814bdf6f00952699bdeb541691091f96"},
|
||||
{file = "multidict-6.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:2e4a0785b84fb59e43c18a015ffc575ba93f7d1dbd272b4cdad9f5134b8a006c"},
|
||||
{file = "multidict-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6701bf8a5d03a43375909ac91b6980aea74b0f5402fbe9428fc3f6edf5d9677e"},
|
||||
{file = "multidict-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a007b1638e148c3cfb6bf0bdc4f82776cef0ac487191d093cdc316905e504071"},
|
||||
{file = "multidict-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:07a017cfa00c9890011628eab2503bee5872f27144936a52eaab449be5eaf032"},
|
||||
{file = "multidict-6.0.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c207fff63adcdf5a485969131dc70e4b194327666b7e8a87a97fbc4fd80a53b2"},
|
||||
{file = "multidict-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:373ba9d1d061c76462d74e7de1c0c8e267e9791ee8cfefcf6b0b2495762c370c"},
|
||||
{file = "multidict-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfba7c6d5d7c9099ba21f84662b037a0ffd4a5e6b26ac07d19e423e6fdf965a9"},
|
||||
{file = "multidict-6.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:19d9bad105dfb34eb539c97b132057a4e709919ec4dd883ece5838bcbf262b80"},
|
||||
{file = "multidict-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:de989b195c3d636ba000ee4281cd03bb1234635b124bf4cd89eeee9ca8fcb09d"},
|
||||
{file = "multidict-6.0.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7c40b7bbece294ae3a87c1bc2abff0ff9beef41d14188cda94ada7bcea99b0fb"},
|
||||
{file = "multidict-6.0.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:d16cce709ebfadc91278a1c005e3c17dd5f71f5098bfae1035149785ea6e9c68"},
|
||||
{file = "multidict-6.0.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:a2c34a93e1d2aa35fbf1485e5010337c72c6791407d03aa5f4eed920343dd360"},
|
||||
{file = "multidict-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:feba80698173761cddd814fa22e88b0661e98cb810f9f986c54aa34d281e4937"},
|
||||
{file = "multidict-6.0.2-cp39-cp39-win32.whl", hash = "sha256:23b616fdc3c74c9fe01d76ce0d1ce872d2d396d8fa8e4899398ad64fb5aa214a"},
|
||||
{file = "multidict-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:4bae31803d708f6f15fd98be6a6ac0b6958fcf68fda3c77a048a4f9073704aae"},
|
||||
{file = "multidict-6.0.2.tar.gz", hash = "sha256:5ff3bd75f38e4c43f1f470f2df7a4d430b821c4ce22be384e1459cb57d6bb013"},
|
||||
]
|
||||
protobuf = []
|
||||
pycryptodome = []
|
||||
pymp4 = []
|
||||
pyyaml = [
|
||||
{file = "PyYAML-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53"},
|
||||
{file = "PyYAML-6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c"},
|
||||
{file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc"},
|
||||
{file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b"},
|
||||
{file = "PyYAML-6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5"},
|
||||
{file = "PyYAML-6.0-cp310-cp310-win32.whl", hash = "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513"},
|
||||
{file = "PyYAML-6.0-cp310-cp310-win_amd64.whl", hash = "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a"},
|
||||
{file = "PyYAML-6.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86"},
|
||||
{file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f"},
|
||||
{file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92"},
|
||||
{file = "PyYAML-6.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4"},
|
||||
{file = "PyYAML-6.0-cp36-cp36m-win32.whl", hash = "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293"},
|
||||
{file = "PyYAML-6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57"},
|
||||
{file = "PyYAML-6.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c"},
|
||||
{file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0"},
|
||||
{file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4"},
|
||||
{file = "PyYAML-6.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9"},
|
||||
{file = "PyYAML-6.0-cp37-cp37m-win32.whl", hash = "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737"},
|
||||
{file = "PyYAML-6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d"},
|
||||
{file = "PyYAML-6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b"},
|
||||
{file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba"},
|
||||
{file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34"},
|
||||
{file = "PyYAML-6.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287"},
|
||||
{file = "PyYAML-6.0-cp38-cp38-win32.whl", hash = "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78"},
|
||||
{file = "PyYAML-6.0-cp38-cp38-win_amd64.whl", hash = "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07"},
|
||||
{file = "PyYAML-6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b"},
|
||||
{file = "PyYAML-6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174"},
|
||||
{file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803"},
|
||||
{file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3"},
|
||||
{file = "PyYAML-6.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0"},
|
||||
{file = "PyYAML-6.0-cp39-cp39-win32.whl", hash = "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb"},
|
||||
{file = "PyYAML-6.0-cp39-cp39-win_amd64.whl", hash = "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c"},
|
||||
{file = "PyYAML-6.0.tar.gz", hash = "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2"},
|
||||
]
|
||||
requests = []
|
||||
typing-extensions = []
|
||||
unidecode = [
|
||||
{file = "Unidecode-1.3.4-py3-none-any.whl", hash = "sha256:afa04efcdd818a93237574791be9b2817d7077c25a068b00f8cff7baa4e59257"},
|
||||
{file = "Unidecode-1.3.4.tar.gz", hash = "sha256:8e4352fb93d5a735c788110d2e7ac8e8031eb06ccbfe8d324ab71735015f9342"},
|
||||
]
|
||||
urllib3 = []
|
||||
yarl = [
|
||||
{file = "yarl-1.7.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f2a8508f7350512434e41065684076f640ecce176d262a7d54f0da41d99c5a95"},
|
||||
{file = "yarl-1.7.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:da6df107b9ccfe52d3a48165e48d72db0eca3e3029b5b8cb4fe6ee3cb870ba8b"},
|
||||
{file = "yarl-1.7.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a1d0894f238763717bdcfea74558c94e3bc34aeacd3351d769460c1a586a8b05"},
|
||||
{file = "yarl-1.7.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dfe4b95b7e00c6635a72e2d00b478e8a28bfb122dc76349a06e20792eb53a523"},
|
||||
{file = "yarl-1.7.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c145ab54702334c42237a6c6c4cc08703b6aa9b94e2f227ceb3d477d20c36c63"},
|
||||
{file = "yarl-1.7.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1ca56f002eaf7998b5fcf73b2421790da9d2586331805f38acd9997743114e98"},
|
||||
{file = "yarl-1.7.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:1d3d5ad8ea96bd6d643d80c7b8d5977b4e2fb1bab6c9da7322616fd26203d125"},
|
||||
{file = "yarl-1.7.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:167ab7f64e409e9bdd99333fe8c67b5574a1f0495dcfd905bc7454e766729b9e"},
|
||||
{file = "yarl-1.7.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:95a1873b6c0dd1c437fb3bb4a4aaa699a48c218ac7ca1e74b0bee0ab16c7d60d"},
|
||||
{file = "yarl-1.7.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6152224d0a1eb254f97df3997d79dadd8bb2c1a02ef283dbb34b97d4f8492d23"},
|
||||
{file = "yarl-1.7.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:5bb7d54b8f61ba6eee541fba4b83d22b8a046b4ef4d8eb7f15a7e35db2e1e245"},
|
||||
{file = "yarl-1.7.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:9c1f083e7e71b2dd01f7cd7434a5f88c15213194df38bc29b388ccdf1492b739"},
|
||||
{file = "yarl-1.7.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f44477ae29025d8ea87ec308539f95963ffdc31a82f42ca9deecf2d505242e72"},
|
||||
{file = "yarl-1.7.2-cp310-cp310-win32.whl", hash = "sha256:cff3ba513db55cc6a35076f32c4cdc27032bd075c9faef31fec749e64b45d26c"},
|
||||
{file = "yarl-1.7.2-cp310-cp310-win_amd64.whl", hash = "sha256:c9c6d927e098c2d360695f2e9d38870b2e92e0919be07dbe339aefa32a090265"},
|
||||
{file = "yarl-1.7.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:9b4c77d92d56a4c5027572752aa35082e40c561eec776048330d2907aead891d"},
|
||||
{file = "yarl-1.7.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c01a89a44bb672c38f42b49cdb0ad667b116d731b3f4c896f72302ff77d71656"},
|
||||
{file = "yarl-1.7.2-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c19324a1c5399b602f3b6e7db9478e5b1adf5cf58901996fc973fe4fccd73eed"},
|
||||
{file = "yarl-1.7.2-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3abddf0b8e41445426d29f955b24aeecc83fa1072be1be4e0d194134a7d9baee"},
|
||||
{file = "yarl-1.7.2-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6a1a9fe17621af43e9b9fcea8bd088ba682c8192d744b386ee3c47b56eaabb2c"},
|
||||
{file = "yarl-1.7.2-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:8b0915ee85150963a9504c10de4e4729ae700af11df0dc5550e6587ed7891e92"},
|
||||
{file = "yarl-1.7.2-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:29e0656d5497733dcddc21797da5a2ab990c0cb9719f1f969e58a4abac66234d"},
|
||||
{file = "yarl-1.7.2-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:bf19725fec28452474d9887a128e98dd67eee7b7d52e932e6949c532d820dc3b"},
|
||||
{file = "yarl-1.7.2-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:d6f3d62e16c10e88d2168ba2d065aa374e3c538998ed04996cd373ff2036d64c"},
|
||||
{file = "yarl-1.7.2-cp36-cp36m-musllinux_1_1_s390x.whl", hash = "sha256:ac10bbac36cd89eac19f4e51c032ba6b412b3892b685076f4acd2de18ca990aa"},
|
||||
{file = "yarl-1.7.2-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:aa32aaa97d8b2ed4e54dc65d241a0da1c627454950f7d7b1f95b13985afd6c5d"},
|
||||
{file = "yarl-1.7.2-cp36-cp36m-win32.whl", hash = "sha256:87f6e082bce21464857ba58b569370e7b547d239ca22248be68ea5d6b51464a1"},
|
||||
{file = "yarl-1.7.2-cp36-cp36m-win_amd64.whl", hash = "sha256:ac35ccde589ab6a1870a484ed136d49a26bcd06b6a1c6397b1967ca13ceb3913"},
|
||||
{file = "yarl-1.7.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a467a431a0817a292121c13cbe637348b546e6ef47ca14a790aa2fa8cc93df63"},
|
||||
{file = "yarl-1.7.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ab0c3274d0a846840bf6c27d2c60ba771a12e4d7586bf550eefc2df0b56b3b4"},
|
||||
{file = "yarl-1.7.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d260d4dc495c05d6600264a197d9d6f7fc9347f21d2594926202fd08cf89a8ba"},
|
||||
{file = "yarl-1.7.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fc4dd8b01a8112809e6b636b00f487846956402834a7fd59d46d4f4267181c41"},
|
||||
{file = "yarl-1.7.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c1164a2eac148d85bbdd23e07dfcc930f2e633220f3eb3c3e2a25f6148c2819e"},
|
||||
{file = "yarl-1.7.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:67e94028817defe5e705079b10a8438b8cb56e7115fa01640e9c0bb3edf67332"},
|
||||
{file = "yarl-1.7.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:89ccbf58e6a0ab89d487c92a490cb5660d06c3a47ca08872859672f9c511fc52"},
|
||||
{file = "yarl-1.7.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:8cce6f9fa3df25f55521fbb5c7e4a736683148bcc0c75b21863789e5185f9185"},
|
||||
{file = "yarl-1.7.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:211fcd65c58bf250fb994b53bc45a442ddc9f441f6fec53e65de8cba48ded986"},
|
||||
{file = "yarl-1.7.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:c10ea1e80a697cf7d80d1ed414b5cb8f1eec07d618f54637067ae3c0334133c4"},
|
||||
{file = "yarl-1.7.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:52690eb521d690ab041c3919666bea13ab9fbff80d615ec16fa81a297131276b"},
|
||||
{file = "yarl-1.7.2-cp37-cp37m-win32.whl", hash = "sha256:695ba021a9e04418507fa930d5f0704edbce47076bdcfeeaba1c83683e5649d1"},
|
||||
{file = "yarl-1.7.2-cp37-cp37m-win_amd64.whl", hash = "sha256:c17965ff3706beedafd458c452bf15bac693ecd146a60a06a214614dc097a271"},
|
||||
{file = "yarl-1.7.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:fce78593346c014d0d986b7ebc80d782b7f5e19843ca798ed62f8e3ba8728576"},
|
||||
{file = "yarl-1.7.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c2a1ac41a6aa980db03d098a5531f13985edcb451bcd9d00670b03129922cd0d"},
|
||||
{file = "yarl-1.7.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:39d5493c5ecd75c8093fa7700a2fb5c94fe28c839c8e40144b7ab7ccba6938c8"},
|
||||
{file = "yarl-1.7.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1eb6480ef366d75b54c68164094a6a560c247370a68c02dddb11f20c4c6d3c9d"},
|
||||
{file = "yarl-1.7.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ba63585a89c9885f18331a55d25fe81dc2d82b71311ff8bd378fc8004202ff6"},
|
||||
{file = "yarl-1.7.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e39378894ee6ae9f555ae2de332d513a5763276a9265f8e7cbaeb1b1ee74623a"},
|
||||
{file = "yarl-1.7.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c0910c6b6c31359d2f6184828888c983d54d09d581a4a23547a35f1d0b9484b1"},
|
||||
{file = "yarl-1.7.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:6feca8b6bfb9eef6ee057628e71e1734caf520a907b6ec0d62839e8293e945c0"},
|
||||
{file = "yarl-1.7.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:8300401dc88cad23f5b4e4c1226f44a5aa696436a4026e456fe0e5d2f7f486e6"},
|
||||
{file = "yarl-1.7.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:788713c2896f426a4e166b11f4ec538b5736294ebf7d5f654ae445fd44270832"},
|
||||
{file = "yarl-1.7.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:fd547ec596d90c8676e369dd8a581a21227fe9b4ad37d0dc7feb4ccf544c2d59"},
|
||||
{file = "yarl-1.7.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:737e401cd0c493f7e3dd4db72aca11cfe069531c9761b8ea474926936b3c57c8"},
|
||||
{file = "yarl-1.7.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:baf81561f2972fb895e7844882898bda1eef4b07b5b385bcd308d2098f1a767b"},
|
||||
{file = "yarl-1.7.2-cp38-cp38-win32.whl", hash = "sha256:ede3b46cdb719c794427dcce9d8beb4abe8b9aa1e97526cc20de9bd6583ad1ef"},
|
||||
{file = "yarl-1.7.2-cp38-cp38-win_amd64.whl", hash = "sha256:cc8b7a7254c0fc3187d43d6cb54b5032d2365efd1df0cd1749c0c4df5f0ad45f"},
|
||||
{file = "yarl-1.7.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:580c1f15500e137a8c37053e4cbf6058944d4c114701fa59944607505c2fe3a0"},
|
||||
{file = "yarl-1.7.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3ec1d9a0d7780416e657f1e405ba35ec1ba453a4f1511eb8b9fbab81cb8b3ce1"},
|
||||
{file = "yarl-1.7.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3bf8cfe8856708ede6a73907bf0501f2dc4e104085e070a41f5d88e7faf237f3"},
|
||||
{file = "yarl-1.7.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1be4bbb3d27a4e9aa5f3df2ab61e3701ce8fcbd3e9846dbce7c033a7e8136746"},
|
||||
{file = "yarl-1.7.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:534b047277a9a19d858cde163aba93f3e1677d5acd92f7d10ace419d478540de"},
|
||||
{file = "yarl-1.7.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6ddcd80d79c96eb19c354d9dca95291589c5954099836b7c8d29278a7ec0bda"},
|
||||
{file = "yarl-1.7.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:9bfcd43c65fbb339dc7086b5315750efa42a34eefad0256ba114cd8ad3896f4b"},
|
||||
{file = "yarl-1.7.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f64394bd7ceef1237cc604b5a89bf748c95982a84bcd3c4bbeb40f685c810794"},
|
||||
{file = "yarl-1.7.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:044daf3012e43d4b3538562da94a88fb12a6490652dbc29fb19adfa02cf72eac"},
|
||||
{file = "yarl-1.7.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:368bcf400247318382cc150aaa632582d0780b28ee6053cd80268c7e72796dec"},
|
||||
{file = "yarl-1.7.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:bab827163113177aee910adb1f48ff7af31ee0289f434f7e22d10baf624a6dfe"},
|
||||
{file = "yarl-1.7.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:0cba38120db72123db7c58322fa69e3c0efa933040ffb586c3a87c063ec7cae8"},
|
||||
{file = "yarl-1.7.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:59218fef177296451b23214c91ea3aba7858b4ae3306dde120224cfe0f7a6ee8"},
|
||||
{file = "yarl-1.7.2-cp39-cp39-win32.whl", hash = "sha256:1edc172dcca3f11b38a9d5c7505c83c1913c0addc99cd28e993efeaafdfaa18d"},
|
||||
{file = "yarl-1.7.2-cp39-cp39-win_amd64.whl", hash = "sha256:797c2c412b04403d2da075fb93c123df35239cd7b4cc4e0cd9e5839b73f52c58"},
|
||||
{file = "yarl-1.7.2.tar.gz", hash = "sha256:45399b46d60c253327a460e99856752009fcee5f5d3c80b2f7c0cae1c38d56dd"},
|
||||
]
|
||||
zipp = []
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
|
||||
|
||||
[tool.poetry]
|
||||
name = "pywidevine"
|
||||
version = "1.0.1"
|
||||
version = "1.2.1"
|
||||
description = "Widevine CDM (Content Decryption Module) implementation in Python."
|
||||
authors = ["rlaphoenix <rlaphoenix@pm.me>"]
|
||||
license = "GPL-3.0-only"
|
||||
@@ -12,7 +12,7 @@ readme = "README.md"
|
||||
repository = "https://github.com/rlaphoenix/pywidevine"
|
||||
keywords = ["widevine", "drm", "google"]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Intended Audience :: Developers",
|
||||
"Intended Audience :: End Users/Desktop",
|
||||
"Natural Language :: English",
|
||||
@@ -24,6 +24,7 @@ classifiers = [
|
||||
[tool.poetry.urls]
|
||||
"Bug Tracker" = "https://github.com/rlaphoenix/pywidevine/issues"
|
||||
"Forums" = "https://github.com/rlaphoenix/pywidevine/discussions"
|
||||
"Changelog" = "https://github.com/rlaphoenix/pywidevine/blob/master/CHANGELOG.md"
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = ">=3.7,<3.11"
|
||||
@@ -32,7 +33,13 @@ pymp4 = "^1.2.0"
|
||||
pycryptodome = "^3.15.0"
|
||||
click = "^8.1.3"
|
||||
requests = "^2.28.1"
|
||||
lxml = "^4.9.1"
|
||||
lxml = ">=4.8.0"
|
||||
Unidecode = "^1.3.4"
|
||||
aiohttp = {version = "^3.8.1", optional = true}
|
||||
PyYAML = {version = "^6.0", optional = true}
|
||||
|
||||
[tool.poetry.extras]
|
||||
serve = ["aiohttp", "PyYAML"]
|
||||
|
||||
[tool.poetry.scripts]
|
||||
pywidevine = "pywidevine.main:main"
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "1.0.1"
|
||||
__version__ = "1.2.1"
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import base64
|
||||
import binascii
|
||||
import random
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Union, Optional
|
||||
from typing import Union, Container, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from Crypto.Cipher import AES, PKCS1_OAEP
|
||||
@@ -13,15 +14,17 @@ from Crypto.PublicKey import RSA
|
||||
from Crypto.Random import get_random_bytes
|
||||
from Crypto.Signature import pss
|
||||
from Crypto.Util import Padding
|
||||
from construct import Container
|
||||
from google.protobuf.message import DecodeError
|
||||
|
||||
from pywidevine.utils import get_binary_path
|
||||
from pywidevine.license_protocol_pb2 import LicenseType, SignedMessage, LicenseRequest, ProtocolVersion, \
|
||||
SignedDrmCertificate, DrmCertificate, EncryptedClientIdentification, ClientIdentification, License
|
||||
from pywidevine.device import Device
|
||||
from pywidevine.exceptions import TooManySessions, InvalidSession, InvalidLicenseType, SignatureMismatch, \
|
||||
InvalidInitData, InvalidLicenseMessage, NoKeysLoaded, InvalidContext
|
||||
from pywidevine.key import Key
|
||||
from pywidevine.license_protocol_pb2 import DrmCertificate, SignedMessage, SignedDrmCertificate, LicenseType, \
|
||||
LicenseRequest, ProtocolVersion, ClientIdentification, EncryptedClientIdentification, License
|
||||
from pywidevine.pssh import PSSH
|
||||
from pywidevine.session import Session
|
||||
from pywidevine.utils import get_binary_path
|
||||
|
||||
|
||||
class Cdm:
|
||||
@@ -41,92 +44,185 @@ class Cdm:
|
||||
"+Gf2Ogsrf9s2LFvE7NVV2FvKqcWTw4PIV9Sdqrd+QLeFHd/SSZiAjjWyWOddeOrAyhb3BHMEwg2T7eTo/xxvF+YkP"
|
||||
"j89qPwXCYcOxF+6gjomPwzvofcJOxkJkoMmMzcFBDopvab5tDQsyN9UPLGhGC98X/8z8QSQ+spbJTYLdgFenFoGq4"
|
||||
"7gLwDS6NWYYQSqzE3Udf2W7pzk4ybyG4PHBYV3s4cyzdq8amvtE/sNSdOKReuHpfQ=")
|
||||
root_signed_cert = SignedDrmCertificate()
|
||||
root_signed_cert.ParseFromString(base64.b64decode(
|
||||
"CpwDCAASAQAY3ZSIiwUijgMwggGKAoIBgQC0/jnDZZAD2zwRlwnoaM3yw16b8udNI7EQ24dl39z7nzWgVwNTTPZtNX2meNuzNtI/nECplSZy"
|
||||
"f7i+Zt/FIZh4FRZoXS9GDkPLioQ5q/uwNYAivjQji6tTW3LsS7VIaVM+R1/9Cf2ndhOPD5LWTN+udqm62SIQqZ1xRdbX4RklhZxTmpfrhNfM"
|
||||
"qIiCIHAmIP1+QFAn4iWTb7w+cqD6wb0ptE2CXMG0y5xyfrDpihc+GWP8/YJIK7eyM7l97Eu6iR8nuJuISISqGJIOZfXIbBH/azbkdDTKjDOx"
|
||||
"+biOtOYS4AKYeVJeRTP/Edzrw1O6fGAaET0A+9K3qjD6T15Id1sX3HXvb9IZbdy+f7B4j9yCYEy/5CkGXmmMOROtFCXtGbLynwGCDVZEiMg1"
|
||||
"7B8RsyTgWQ035Ec86kt/lzEcgXyUikx9aBWE/6UI/Rjn5yvkRycSEbgj7FiTPKwS0ohtQT3F/hzcufjUUT4H5QNvpxLoEve1zqaWVT94tGSC"
|
||||
"UNIzX5ECAwEAARKAA1jx1k0ECXvf1+9dOwI5F/oUNnVKOGeFVxKnFO41FtU9v0KG9mkAds2T9Hyy355EzUzUrgkYU0Qy7OBhG+XaE9NVxd0a"
|
||||
"y5AeflvG6Q8in76FAv6QMcxrA4S9IsRV+vXyCM1lQVjofSnaBFiC9TdpvPNaV4QXezKHcLKwdpyywxXRESYqI3WZPrl3IjINvBoZwdVlkHZV"
|
||||
"dA8OaU1fTY8Zr9/WFjGUqJJfT7x6Mfiujq0zt+kw0IwKimyDNfiKgbL+HIisKmbF/73mF9BiC9yKRfewPlrIHkokL2yl4xyIFIPVxe9enz2F"
|
||||
"RXPia1BSV0z7kmxmdYrWDRuu8+yvUSIDXQouY5OcCwEgqKmELhfKrnPsIht5rvagcizfB0fbiIYwFHghESKIrNdUdPnzJsKlVshWTwApHQh7"
|
||||
"evuVicPumFSePGuUBRMS9nG5qxPDDJtGCHs9Mmpoyh6ckGLF7RC5HxclzpC5bc3ERvWjYhN0AqdipPpV2d7PouaAdFUGSdUCDA=="
|
||||
))
|
||||
root_cert = DrmCertificate()
|
||||
root_cert.ParseFromString(root_signed_cert.drm_certificate)
|
||||
|
||||
NUM_OF_SESSIONS = 0
|
||||
MAX_NUM_OF_SESSIONS = 50 # most common limit
|
||||
|
||||
def __init__(self, device: Device, pssh: Union[Container, bytes, str], raw: bool = False):
|
||||
def __init__(self, device: Device):
|
||||
"""Initialize a Widevine Content Decryption Module (CDM)."""
|
||||
if not device:
|
||||
raise ValueError("A Widevine Device must be provided.")
|
||||
self.device = device
|
||||
|
||||
self._sessions: dict[bytes, Session] = {}
|
||||
|
||||
def open(self) -> bytes:
|
||||
"""
|
||||
Open a Widevine Content Decryption Module (CDM) session.
|
||||
|
||||
Parameters:
|
||||
device: Widevine Device containing the Client ID, Device Private Key, and
|
||||
more device-specific information.
|
||||
pssh: Protection System Specific Header Box or Init Data. This should be a
|
||||
compliant mp4 pssh box, or just the init data (Widevine Cenc Header).
|
||||
raw: This should be set to True if the PSSH data provided is arbitrary data.
|
||||
E.g., a PSSH Box where the init data is not a Widevine Cenc Header, or
|
||||
is simply arbitrary data.
|
||||
|
||||
Devices have a limit on how many sessions can be open and active concurrently.
|
||||
The limit is different for each device and security level, most commonly 50.
|
||||
This limit is handled by the OEM Crypto API. Multiple sessions can be open at
|
||||
a time and sessions should be closed when no longer needed.
|
||||
Raises:
|
||||
TooManySessions: If the session cannot be opened as limit has been reached.
|
||||
"""
|
||||
if not device:
|
||||
raise ValueError("A Widevine Device must be provided.")
|
||||
if not pssh:
|
||||
raise ValueError("A PSSH Box must be provided.")
|
||||
if len(self._sessions) > self.MAX_NUM_OF_SESSIONS:
|
||||
raise TooManySessions(f"Too many Sessions open ({self.MAX_NUM_OF_SESSIONS}).")
|
||||
|
||||
if self.NUM_OF_SESSIONS >= self.MAX_NUM_OF_SESSIONS:
|
||||
raise ValueError(
|
||||
f"Too many Sessions open {self.NUM_OF_SESSIONS}/{self.MAX_NUM_OF_SESSIONS}. "
|
||||
f"Close some Sessions to be able to open more."
|
||||
)
|
||||
session = Session()
|
||||
self._sessions[session.id] = session
|
||||
|
||||
self.NUM_OF_SESSIONS += 1
|
||||
return session.id
|
||||
|
||||
self.device = device
|
||||
self.init_data = pssh
|
||||
def close(self, session_id: bytes) -> None:
|
||||
"""
|
||||
Close a Widevine Content Decryption Module (CDM) session.
|
||||
|
||||
if not raw:
|
||||
# we only want the init_data of the pssh box
|
||||
self.init_data = PSSH.get_as_box(pssh).init_data
|
||||
Parameters:
|
||||
session_id: Session identifier.
|
||||
|
||||
self.session_id = get_random_bytes(16)
|
||||
self.service_certificate: Optional[SignedMessage] = None
|
||||
self.context: dict[bytes, tuple[bytes, bytes]] = {}
|
||||
Raises:
|
||||
InvalidSession: If the Session identifier is invalid.
|
||||
"""
|
||||
session = self._sessions.get(session_id)
|
||||
if not session:
|
||||
raise InvalidSession(f"Session identifier {session_id!r} is invalid.")
|
||||
del self._sessions[session_id]
|
||||
|
||||
def set_service_certificate(self, certificate: Union[bytes, str]) -> SignedMessage:
|
||||
def set_service_certificate(self, session_id: bytes, certificate: Union[bytes, str]) -> str:
|
||||
"""
|
||||
Set a Service Privacy Certificate for Privacy Mode. (optional but recommended)
|
||||
|
||||
Parameters:
|
||||
certificate: Signed Message in Base64 or Bytes form obtained from the Service.
|
||||
Some services have their own, but most use the common privacy cert,
|
||||
(common_privacy_cert).
|
||||
|
||||
Returns the parsed Signed Message if successful, otherwise raises a DecodeError.
|
||||
|
||||
The Service Certificate is used to encrypt Client IDs in Licenses. This is also
|
||||
known as Privacy Mode and may be required for some services or for some devices.
|
||||
Chrome CDM requires it as of the enforcement of VMP (Verified Media Path).
|
||||
"""
|
||||
if isinstance(certificate, str):
|
||||
certificate = base64.b64decode(certificate) # assuming base64
|
||||
|
||||
signed_message = SignedMessage()
|
||||
try:
|
||||
signed_message.ParseFromString(certificate)
|
||||
except DecodeError as e:
|
||||
raise DecodeError(f"Could not parse certificate as a Signed Message: {e}")
|
||||
|
||||
self.service_certificate = signed_message
|
||||
return signed_message
|
||||
|
||||
def get_license_challenge(self, type_: LicenseType = LicenseType.STREAMING, privacy_mode: bool = True) -> bytes:
|
||||
"""
|
||||
Get a License Challenge to send to a License Server.
|
||||
We reject direct DrmCertificates as they do not have signature verification and
|
||||
cannot be verified. You must provide a SignedDrmCertificate or a SignedMessage
|
||||
containing a SignedDrmCertificate.
|
||||
|
||||
Parameters:
|
||||
type_: Type of License you wish to exchange, often `STREAMING`.
|
||||
The `OFFLINE` Licenses are for Offline licensing of Downloaded content.
|
||||
session_id: Session identifier.
|
||||
certificate: SignedDrmCertificate (or SignedMessage containing one) in Base64
|
||||
or Bytes form obtained from the Service. Some services have their own,
|
||||
but most use the common privacy cert, (common_privacy_cert).
|
||||
|
||||
Raises:
|
||||
InvalidSession: If the Session identifier is invalid.
|
||||
DecodeError: If the certificate could not be parsed as a SignedDrmCertificate
|
||||
nor a SignedMessage containing a SignedDrmCertificate.
|
||||
SignatureMismatch: If the Signature of the SignedDrmCertificate does not
|
||||
match the underlying DrmCertificate.
|
||||
|
||||
Returns the Service Provider ID of the verified DrmCertificate if successful.
|
||||
"""
|
||||
session = self._sessions.get(session_id)
|
||||
if not session:
|
||||
raise InvalidSession(f"Session identifier {session_id!r} is invalid.")
|
||||
|
||||
if isinstance(certificate, str):
|
||||
try:
|
||||
certificate = base64.b64decode(certificate) # assuming base64
|
||||
except binascii.Error:
|
||||
raise DecodeError("Could not decode certificate string as Base64, expected bytes.")
|
||||
elif not isinstance(certificate, bytes):
|
||||
raise DecodeError(f"Expecting Certificate to be bytes, not {certificate!r}")
|
||||
|
||||
signed_message = SignedMessage()
|
||||
signed_drm_certificate = SignedDrmCertificate()
|
||||
|
||||
try:
|
||||
signed_message.ParseFromString(certificate)
|
||||
if signed_message.SerializeToString() == certificate:
|
||||
signed_drm_certificate.ParseFromString(signed_message.msg)
|
||||
else:
|
||||
signed_drm_certificate.ParseFromString(certificate)
|
||||
if signed_drm_certificate.SerializeToString() != certificate:
|
||||
raise DecodeError()
|
||||
# Craft a SignedMessage as it's stored as a SignedMessage
|
||||
signed_message.Clear()
|
||||
signed_message.msg = signed_drm_certificate.SerializeToString()
|
||||
# we don't need to sign this message, this is normal
|
||||
except DecodeError:
|
||||
# could be a direct unsigned DrmCertificate, but reject those anyway
|
||||
raise DecodeError("Could not parse certificate as a SignedDrmCertificate")
|
||||
|
||||
try:
|
||||
pss. \
|
||||
new(RSA.import_key(self.root_cert.public_key)). \
|
||||
verify(
|
||||
msg_hash=SHA1.new(signed_drm_certificate.drm_certificate),
|
||||
signature=signed_drm_certificate.signature
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
raise SignatureMismatch("Signature Mismatch on SignedDrmCertificate, rejecting certificate")
|
||||
else:
|
||||
session.service_certificate = signed_message
|
||||
drm_certificate = DrmCertificate()
|
||||
drm_certificate.ParseFromString(signed_drm_certificate.drm_certificate)
|
||||
return drm_certificate.provider_id
|
||||
|
||||
def get_license_challenge(
|
||||
self,
|
||||
session_id: bytes,
|
||||
init_data: Union[Container, bytes, str],
|
||||
type_: Union[int, str] = LicenseType.STREAMING,
|
||||
privacy_mode: bool = True
|
||||
) -> bytes:
|
||||
"""
|
||||
Get a License Request (Challenge) to send to a License Server.
|
||||
|
||||
Parameters:
|
||||
session_id: Session identifier.
|
||||
init_data: Widevine Cenc Header (Init Data) or a Protection System Specific
|
||||
Header Box to take the init data from.
|
||||
type_: Type of License you wish to exchange, often `STREAMING`. The `OFFLINE`
|
||||
Licenses are for Offline licensing of Downloaded content.
|
||||
privacy_mode: Encrypt the Client ID using the Privacy Certificate. If the
|
||||
privacy certificate is not set yet, this does nothing.
|
||||
|
||||
Raises:
|
||||
InvalidSession: If the Session identifier is invalid.
|
||||
InvalidInitData: If the Init Data (or PSSH box) provided is invalid.
|
||||
InvalidLicenseType: If the type_ parameter value is not a License Type. It
|
||||
must be a LicenseType enum, or a string/int representing the enum's keys
|
||||
or values.
|
||||
|
||||
Returns a SignedMessage containing a LicenseRequest message. It's signed with
|
||||
the Private Key of the device provision.
|
||||
"""
|
||||
session = self._sessions.get(session_id)
|
||||
if not session:
|
||||
raise InvalidSession(f"Session identifier {session_id!r} is invalid.")
|
||||
|
||||
if not init_data:
|
||||
raise InvalidInitData("The init_data must not be empty.")
|
||||
try:
|
||||
init_data = PSSH.get_as_box(init_data).init_data
|
||||
except (ValueError, binascii.Error, DecodeError) as e:
|
||||
raise InvalidInitData(str(e))
|
||||
|
||||
try:
|
||||
if isinstance(type_, int):
|
||||
LicenseType.Name(int(type_))
|
||||
elif isinstance(type_, str):
|
||||
type_ = LicenseType.Value(type_)
|
||||
elif not isinstance(type_, LicenseType):
|
||||
raise InvalidLicenseType()
|
||||
except ValueError:
|
||||
raise InvalidLicenseType(f"License Type {type_!r} is invalid")
|
||||
|
||||
request_id = get_random_bytes(16)
|
||||
|
||||
license_request = LicenseRequest()
|
||||
@@ -135,120 +231,195 @@ class Cdm:
|
||||
license_request.protocol_version = ProtocolVersion.Value("VERSION_2_1")
|
||||
license_request.key_control_nonce = random.randrange(1, 2 ** 31)
|
||||
|
||||
license_request.content_id.widevine_pssh_data.pssh_data.append(self.init_data)
|
||||
license_request.content_id.widevine_pssh_data.pssh_data.append(init_data)
|
||||
license_request.content_id.widevine_pssh_data.license_type = type_
|
||||
license_request.content_id.widevine_pssh_data.request_id = request_id
|
||||
|
||||
if self.service_certificate and privacy_mode:
|
||||
if session.service_certificate and privacy_mode:
|
||||
# encrypt the client id for privacy mode
|
||||
license_request.encrypted_client_id.CopyFrom(self.encrypt_client_id(
|
||||
client_id=self.device.client_id,
|
||||
service_certificate=self.service_certificate
|
||||
service_certificate=session.service_certificate
|
||||
))
|
||||
else:
|
||||
license_request.client_id.CopyFrom(self.device.client_id)
|
||||
|
||||
license_message = SignedMessage()
|
||||
license_message.type = SignedMessage.MessageType.Value("LICENSE_REQUEST")
|
||||
license_message.type = SignedMessage.MessageType.LICENSE_REQUEST
|
||||
license_message.msg = license_request.SerializeToString()
|
||||
|
||||
license_message.signature = pss. \
|
||||
new(self.device.private_key). \
|
||||
sign(SHA1.new(license_message.msg))
|
||||
|
||||
self.context[request_id] = self.derive_context(license_message.msg)
|
||||
session.context[request_id] = self.derive_context(license_message.msg)
|
||||
|
||||
return license_message.SerializeToString()
|
||||
|
||||
def parse_license(self, license_message: Union[bytes, str]) -> list[Key]:
|
||||
def parse_license(self, session_id: bytes, license_message: Union[SignedMessage, bytes, str]) -> None:
|
||||
"""
|
||||
Load Keys from a License Message from a License Server Response.
|
||||
|
||||
License Messages can only be loaded a single time. An InvalidContext error will
|
||||
be raised if you attempt to parse a License Message more than once.
|
||||
|
||||
Parameters:
|
||||
session_id: Session identifier.
|
||||
license_message: A SignedMessage containing a License message.
|
||||
|
||||
Raises:
|
||||
InvalidSession: If the Session identifier is invalid.
|
||||
InvalidLicenseMessage: The License message could not be decoded as a Signed
|
||||
Message or License message.
|
||||
InvalidContext: If the Session has no Context Data. This is likely to happen
|
||||
if the License Challenge was not made by this CDM instance, or was not
|
||||
by this CDM at all. It could also happen if the Session is closed after
|
||||
calling parse_license but not before it got the context data.
|
||||
SignatureMismatch: If the Signature of the License SignedMessage does not
|
||||
match the underlying License.
|
||||
"""
|
||||
session = self._sessions.get(session_id)
|
||||
if not session:
|
||||
raise InvalidSession(f"Session identifier {session_id!r} is invalid.")
|
||||
|
||||
if not license_message:
|
||||
raise ValueError("Cannot parse an empty license_message as a SignedMessage")
|
||||
raise InvalidLicenseMessage("Cannot parse an empty license_message")
|
||||
|
||||
if isinstance(license_message, str):
|
||||
license_message = base64.b64decode(license_message)
|
||||
try:
|
||||
license_message = base64.b64decode(license_message)
|
||||
except (binascii.Error, binascii.Incomplete) as e:
|
||||
raise InvalidLicenseMessage(f"Could not decode license_message as Base64, {e}")
|
||||
|
||||
if isinstance(license_message, bytes):
|
||||
signed_message = SignedMessage()
|
||||
try:
|
||||
signed_message.ParseFromString(license_message)
|
||||
except DecodeError:
|
||||
raise ValueError("Failed to parse license_message as a SignedMessage")
|
||||
except DecodeError as e:
|
||||
raise InvalidLicenseMessage(f"Could not parse license_message as a SignedMessage, {e}")
|
||||
license_message = signed_message
|
||||
|
||||
if not isinstance(license_message, SignedMessage):
|
||||
raise ValueError(f"Expecting license_response to be a SignedMessage, got {license_message!r}")
|
||||
raise InvalidLicenseMessage(f"Expecting license_response to be a SignedMessage, got {license_message!r}")
|
||||
|
||||
if license_message.type != SignedMessage.MessageType.LICENSE:
|
||||
raise InvalidLicenseMessage(
|
||||
f"Expecting a LICENSE message, not a "
|
||||
f"'{SignedMessage.MessageType.Name(license_message.type)}' message."
|
||||
)
|
||||
|
||||
licence = License()
|
||||
licence.ParseFromString(license_message.msg)
|
||||
|
||||
context = self.context[licence.id.request_id]
|
||||
context = session.context.get(licence.id.request_id)
|
||||
if not context:
|
||||
raise ValueError("Cannot parse a license message without first making a license request")
|
||||
raise InvalidContext("Cannot parse a license message without first making a license request")
|
||||
|
||||
session_key = PKCS1_OAEP. \
|
||||
new(self.device.private_key). \
|
||||
decrypt(license_message.session_key)
|
||||
|
||||
enc_key, mac_key_server, mac_key_client = self.derive_keys(*context, session_key)
|
||||
enc_key, mac_key_server, _ = self.derive_keys(*context, session_key)
|
||||
|
||||
license_signature = HMAC. \
|
||||
computed_signature = HMAC. \
|
||||
new(mac_key_server, digestmod=SHA256). \
|
||||
update(licence.SerializeToString()). \
|
||||
digest()
|
||||
|
||||
if license_message.signature != license_signature:
|
||||
raise ValueError("The License Signature doesn't match the Signature listed in the Message")
|
||||
if license_message.signature != computed_signature:
|
||||
raise SignatureMismatch("Signature Mismatch on License Message, rejecting license")
|
||||
|
||||
return [
|
||||
session.keys = [
|
||||
Key.from_key_container(key, enc_key)
|
||||
for key in licence.key
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def decrypt(content_keys: dict[UUID, str], input_: Path, output: Path, temp: Optional[Path] = None):
|
||||
del session.context[licence.id.request_id]
|
||||
|
||||
def decrypt(
|
||||
self,
|
||||
session_id: bytes,
|
||||
input_file: Union[Path, str],
|
||||
output_file: Union[Path, str],
|
||||
temp_dir: Optional[Union[Path, str]] = None,
|
||||
exists_ok: bool = False
|
||||
):
|
||||
"""
|
||||
Decrypt a Widevine-encrypted file using Shaka-packager.
|
||||
Shaka-packager is much more stable than mp4decrypt.
|
||||
|
||||
Parameters:
|
||||
session_id: Session identifier.
|
||||
input_file: File to be decrypted with Session's currently loaded keys.
|
||||
output_file: Location to save decrypted file.
|
||||
temp_dir: Directory to store temporary data while decrypting.
|
||||
exists_ok: Allow overwriting the output_file if it exists.
|
||||
|
||||
Raises:
|
||||
EnvironmentError if the Shaka Packager executable could not be found.
|
||||
ValueError if the track has not yet been downloaded.
|
||||
SubprocessError if Shaka Packager returned a non-zero exit code.
|
||||
ValueError: If the input or output paths have not been supplied or are
|
||||
invalid.
|
||||
FileNotFoundError: If the input file path does not exist.
|
||||
FileExistsError: If the output file path already exists. Ignored if exists_ok
|
||||
is set to True.
|
||||
NoKeysLoaded: No License was parsed for this Session, No Keys available.
|
||||
EnvironmentError: If the shaka-packager executable could not be found.
|
||||
subprocess.CalledProcessError: If the shaka-packager call returned a non-zero
|
||||
exit code.
|
||||
"""
|
||||
if not content_keys:
|
||||
raise ValueError("Cannot decrypt without any Content Keys")
|
||||
if not input_:
|
||||
if not input_file:
|
||||
raise ValueError("Cannot decrypt nothing, specify an input path")
|
||||
if not output:
|
||||
if not output_file:
|
||||
raise ValueError("Cannot decrypt nowhere, specify an output path")
|
||||
|
||||
if not isinstance(input_file, (Path, str)):
|
||||
raise ValueError(f"Expecting input_file to be a Path or str, got {input_file!r}")
|
||||
if not isinstance(output_file, (Path, str)):
|
||||
raise ValueError(f"Expecting output_file to be a Path or str, got {output_file!r}")
|
||||
if not isinstance(temp_dir, (Path, str)) and temp_dir is not None:
|
||||
raise ValueError(f"Expecting temp_dir to be a Path or str, got {temp_dir!r}")
|
||||
|
||||
input_file = Path(input_file)
|
||||
output_file = Path(output_file)
|
||||
if temp_dir:
|
||||
temp_dir = Path(temp_dir)
|
||||
|
||||
if not input_file.is_file():
|
||||
raise FileNotFoundError(f"Input file does not exist, {input_file}")
|
||||
if output_file.is_file() and not exists_ok:
|
||||
raise FileExistsError(f"Output file already exists, {output_file}")
|
||||
|
||||
session = self._sessions.get(session_id)
|
||||
if not session:
|
||||
raise InvalidSession(f"Session identifier {session_id!r} is invalid.")
|
||||
|
||||
if not session.keys:
|
||||
raise NoKeysLoaded("No Keys are loaded yet, cannot decrypt")
|
||||
|
||||
platform = {"win32": "win", "darwin": "osx"}.get(sys.platform, sys.platform)
|
||||
executable = get_binary_path("shaka-packager", f"packager-{platform}", f"packager-{platform}-x64")
|
||||
if not executable:
|
||||
raise EnvironmentError("Shaka Packager executable not found but is required")
|
||||
|
||||
args = [
|
||||
f"input={input_},stream=0,output={output}",
|
||||
"--enable_raw_key_decryption", "--keys",
|
||||
",".join([
|
||||
*[
|
||||
"label={}:key_id={}:key={}".format(i, kid.hex, key.lower())
|
||||
for i, (kid, key) in enumerate(content_keys.items())
|
||||
],
|
||||
*[
|
||||
# Apple TV+ needs this as their files do not use the KID supplied in the manifest
|
||||
"label={}:key_id={}:key={}".format(i, "00" * 16, key.lower())
|
||||
for i, (kid, key) in enumerate(content_keys.items(), len(content_keys))
|
||||
f"input={input_file},stream=0,output={output_file}",
|
||||
"--enable_raw_key_decryption",
|
||||
"--keys", ",".join([
|
||||
label
|
||||
for i, key in enumerate(session.keys)
|
||||
for label in [
|
||||
f"label=1_{i}:key_id={key.kid.hex}:key={key.key.hex()}",
|
||||
# some services need the KID blanked, e.g., Apple TV+
|
||||
f"label=2_{i}:key_id={'0' * 32}:key={key.key.hex()}"
|
||||
]
|
||||
]),
|
||||
if key.type == "CONTENT"
|
||||
])
|
||||
]
|
||||
|
||||
if temp:
|
||||
temp.mkdir(parents=True, exist_ok=True)
|
||||
args.extend(["--temp_dir", temp])
|
||||
if temp_dir:
|
||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
args.extend(["--temp_dir", temp_dir])
|
||||
|
||||
try:
|
||||
subprocess.check_call([executable, *args])
|
||||
except subprocess.CalledProcessError as e:
|
||||
raise subprocess.SubprocessError(f"Failed to Decrypt! Shaka Packager Error: {e}")
|
||||
subprocess.check_call([executable, *args])
|
||||
|
||||
@staticmethod
|
||||
def encrypt_client_id(
|
||||
@@ -262,17 +433,15 @@ class Cdm:
|
||||
privacy_iv = iv or get_random_bytes(16)
|
||||
|
||||
if isinstance(service_certificate, SignedMessage):
|
||||
signed_service_certificate = SignedDrmCertificate()
|
||||
signed_service_certificate.ParseFromString(service_certificate.msg)
|
||||
service_certificate = signed_service_certificate
|
||||
|
||||
signed_drm_certificate = SignedDrmCertificate()
|
||||
signed_drm_certificate.ParseFromString(service_certificate.msg)
|
||||
service_certificate = signed_drm_certificate
|
||||
if isinstance(service_certificate, SignedDrmCertificate):
|
||||
service_service_drm_certificate = DrmCertificate()
|
||||
service_service_drm_certificate.ParseFromString(service_certificate.drm_certificate)
|
||||
service_certificate = service_service_drm_certificate
|
||||
|
||||
drm_certificate = DrmCertificate()
|
||||
drm_certificate.ParseFromString(service_certificate.drm_certificate)
|
||||
service_certificate = drm_certificate
|
||||
if not isinstance(service_certificate, DrmCertificate):
|
||||
raise ValueError(f"Service Certificate is in an unexpected type {service_certificate!r}")
|
||||
raise ValueError(f"Expecting Service Certificate to be a DrmCertificate, not {service_certificate!r}")
|
||||
|
||||
enc_client_id = EncryptedClientIdentification()
|
||||
enc_client_id.provider_id = service_certificate.provider_id
|
||||
@@ -328,7 +497,8 @@ class Cdm:
|
||||
"""
|
||||
|
||||
def _derive(session_key: bytes, context: bytes, counter: int) -> bytes:
|
||||
return CMAC.new(session_key, ciphermod=AES). \
|
||||
return CMAC. \
|
||||
new(session_key, ciphermod=AES). \
|
||||
update(counter.to_bytes(1, "big") + context). \
|
||||
digest()
|
||||
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import logging
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
from construct import BitStruct, Bytes, Const
|
||||
from construct import BitStruct, Bytes, Const, ConstructError, Container
|
||||
from construct import Enum as CEnum
|
||||
from construct import Flag, Int8ub, Int16ub
|
||||
from construct import Int8ub, Int16ub
|
||||
from construct import Optional as COptional
|
||||
from construct import Padded, Padding, Struct, this
|
||||
from Crypto.PublicKey import RSA
|
||||
@@ -21,12 +22,17 @@ class _Types(Enum):
|
||||
ANDROID = 2
|
||||
|
||||
|
||||
class Device:
|
||||
# needed so bin_format can enumerate the types
|
||||
Types = _Types
|
||||
class _Structures:
|
||||
magic = Const(b"WVD")
|
||||
|
||||
bin_format = Struct(
|
||||
"signature" / Const(b"WVD"),
|
||||
header = Struct(
|
||||
"signature" / magic,
|
||||
"version" / Int8ub
|
||||
)
|
||||
|
||||
# - Removed vmp and vmp_len as it should already be within the Client ID
|
||||
v2 = Struct(
|
||||
"signature" / magic,
|
||||
"version" / Const(Int8ub, 2),
|
||||
"type_" / CEnum(
|
||||
Int8ub,
|
||||
@@ -34,8 +40,8 @@ class Device:
|
||||
),
|
||||
"security_level" / Int8ub,
|
||||
"flags" / Padded(1, COptional(BitStruct(
|
||||
Padding(7),
|
||||
"send_key_control_nonce" / Flag # deprecated, do not use
|
||||
# no per-device flags yet
|
||||
Padding(8)
|
||||
))),
|
||||
"private_key_len" / Int16ub,
|
||||
"private_key" / Bytes(this.private_key_len),
|
||||
@@ -43,9 +49,32 @@ class Device:
|
||||
"client_id" / Bytes(this.client_id_len)
|
||||
)
|
||||
|
||||
# == Bin Format Revisions == #
|
||||
# Version 2: Removed vmp and vmp_len as it should already be within the Client ID
|
||||
# Version 1: Removed system_id as it can be retrieved from the Client ID's DRM Certificate
|
||||
# - Removed system_id as it can be retrieved from the Client ID's DRM Certificate
|
||||
v1 = Struct(
|
||||
"signature" / magic,
|
||||
"version" / Const(Int8ub, 1),
|
||||
"type_" / CEnum(
|
||||
Int8ub,
|
||||
**{t.name: t.value for t in _Types}
|
||||
),
|
||||
"security_level" / Int8ub,
|
||||
"flags" / Padded(1, COptional(BitStruct(
|
||||
# no per-device flags yet
|
||||
Padding(8)
|
||||
))),
|
||||
"private_key_len" / Int16ub,
|
||||
"private_key" / Bytes(this.private_key_len),
|
||||
"client_id_len" / Int16ub,
|
||||
"client_id" / Bytes(this.client_id_len),
|
||||
"vmp_len" / Int16ub,
|
||||
"vmp" / Bytes(this.vmp_len)
|
||||
)
|
||||
|
||||
|
||||
class Device:
|
||||
Types = _Types
|
||||
Structures = _Structures
|
||||
supported_structure = Structures.v2
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -109,20 +138,20 @@ class Device:
|
||||
data = base64.b64decode(data)
|
||||
if not isinstance(data, bytes):
|
||||
raise ValueError(f"Expecting Bytes or Base64 input, got {data!r}")
|
||||
return cls(**cls.bin_format.parse(data))
|
||||
return cls(**cls.supported_structure.parse(data))
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: Union[Path, str]) -> Device:
|
||||
if not isinstance(path, (Path, str)):
|
||||
raise ValueError(f"Expecting Path object or path string, got {path!r}")
|
||||
with Path(path).open(mode="rb") as f:
|
||||
return cls(**cls.bin_format.parse_stream(f))
|
||||
return cls(**cls.supported_structure.parse_stream(f))
|
||||
|
||||
def dumps(self) -> bytes:
|
||||
private_key = self.private_key.export_key("DER") if self.private_key else None
|
||||
return self.bin_format.build(dict(
|
||||
return self.supported_structure.build(dict(
|
||||
version=2,
|
||||
type=self.type.value,
|
||||
type_=self.type.value,
|
||||
security_level=self.security_level,
|
||||
flags=self.flags,
|
||||
private_key_len=len(private_key) if private_key else 0,
|
||||
@@ -138,5 +167,54 @@ class Device:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(self.dumps())
|
||||
|
||||
@classmethod
|
||||
def migrate(cls, data: Union[bytes, str]) -> Device:
|
||||
if isinstance(data, str):
|
||||
data = base64.b64decode(data)
|
||||
if not isinstance(data, bytes):
|
||||
raise ValueError(f"Expecting Bytes or Base64 input, got {data!r}")
|
||||
|
||||
header = _Structures.header.parse(data)
|
||||
if header.version == 2:
|
||||
raise ValueError("Device Data is already migrated to the latest version.")
|
||||
if header.version == 0 or header.version > 2:
|
||||
# we have never used version 0, likely data that just so happened to use the WVD magic
|
||||
raise ValueError("Device Data does not seem to be a WVD file (v0).")
|
||||
|
||||
if header.version == 1: # v1 to v2
|
||||
data = _Structures.v1.parse(data)
|
||||
data.version = 2 # update version to 2 to allow loading
|
||||
data.flags = Container() # blank flags that may have been used in v1
|
||||
|
||||
vmp = FileHashes()
|
||||
if data.vmp:
|
||||
try:
|
||||
vmp.ParseFromString(data.vmp)
|
||||
except DecodeError as e:
|
||||
raise DecodeError(f"Failed to parse VMP data as FileHashes, {e}")
|
||||
data.vmp = vmp
|
||||
|
||||
client_id = ClientIdentification()
|
||||
try:
|
||||
client_id.ParseFromString(data.client_id)
|
||||
except DecodeError as e:
|
||||
raise DecodeError(f"Failed to parse VMP data as FileHashes, {e}")
|
||||
|
||||
new_vmp_data = data.vmp.SerializeToString()
|
||||
if client_id.vmp_data and client_id.vmp_data != new_vmp_data:
|
||||
logging.getLogger("migrate").warning("Client ID already has Verified Media Path data")
|
||||
client_id.vmp_data = new_vmp_data
|
||||
data.client_id = client_id.SerializeToString()
|
||||
|
||||
try:
|
||||
data = _Structures.v2.build(data)
|
||||
except ConstructError as e:
|
||||
raise ValueError(f"Migration failed, {e}")
|
||||
|
||||
try:
|
||||
return cls.loads(data)
|
||||
except ConstructError as e:
|
||||
raise ValueError(f"Device Data seems to be corrupt or invalid, or migration failed, {e}")
|
||||
|
||||
|
||||
__ALL__ = (Device,)
|
||||
|
||||
34
pywidevine/exceptions.py
Normal file
34
pywidevine/exceptions.py
Normal file
@@ -0,0 +1,34 @@
|
||||
class PyWidevineException(Exception):
|
||||
"""Exceptions used by pywidevine."""
|
||||
|
||||
|
||||
class TooManySessions(PyWidevineException):
|
||||
"""Too many Sessions are open."""
|
||||
|
||||
|
||||
class InvalidSession(PyWidevineException):
|
||||
"""No Session is open with the specified identifier."""
|
||||
|
||||
|
||||
class InvalidInitData(PyWidevineException):
|
||||
"""The Widevine Cenc Header Data is invalid or empty."""
|
||||
|
||||
|
||||
class InvalidLicenseType(PyWidevineException):
|
||||
"""The License Type is an Invalid Value."""
|
||||
|
||||
|
||||
class InvalidLicenseMessage(PyWidevineException):
|
||||
"""The License Message is Invalid or Missing."""
|
||||
|
||||
|
||||
class InvalidContext(PyWidevineException):
|
||||
"""The Context is Invalid or Missing."""
|
||||
|
||||
|
||||
class SignatureMismatch(PyWidevineException):
|
||||
"""The Signature did not match."""
|
||||
|
||||
|
||||
class NoKeysLoaded(PyWidevineException):
|
||||
"""No License was parsed for this Session, No Keys available."""
|
||||
@@ -1,14 +1,18 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from zlib import crc32
|
||||
|
||||
import click
|
||||
import requests
|
||||
from construct import ConstructError
|
||||
from unidecode import unidecode, UnidecodeError
|
||||
|
||||
from pywidevine import __version__
|
||||
from pywidevine.cdm import Cdm
|
||||
from pywidevine.device import Device
|
||||
from pywidevine.license_protocol_pb2 import LicenseType
|
||||
from pywidevine.license_protocol_pb2 import LicenseType, FileHashes
|
||||
|
||||
|
||||
@click.group(invoke_without_command=True)
|
||||
@@ -37,11 +41,9 @@ def main(version: bool, debug: bool) -> None:
|
||||
@click.option("-t", "--type", "type_", type=click.Choice(LicenseType.keys(), case_sensitive=False),
|
||||
default="STREAMING",
|
||||
help="License Type to Request.")
|
||||
@click.option("-r", "--raw", is_flag=True, default=False,
|
||||
help="PSSH is Raw.")
|
||||
@click.option("-p", "--privacy", is_flag=True, default=False,
|
||||
help="Use Privacy Mode, off by default.")
|
||||
def license_(device: Path, pssh: str, server: str, type_: str, raw: bool, privacy: bool):
|
||||
def license_(device: Path, pssh: str, server: str, type_: str, privacy: bool):
|
||||
"""
|
||||
Make a License Request for PSSH to SERVER using DEVICE.
|
||||
It will return a list of all keys within the returned license.
|
||||
@@ -66,10 +68,14 @@ def license_(device: Path, pssh: str, server: str, type_: str, raw: bool, privac
|
||||
log.debug(device)
|
||||
|
||||
# load cdm
|
||||
cdm = Cdm(device, pssh, raw)
|
||||
log.info(f"[+] Loaded CDM with PSSH: {pssh}")
|
||||
cdm = Cdm(device)
|
||||
log.info(f"[+] Loaded CDM")
|
||||
log.debug(cdm)
|
||||
|
||||
# open cdm session
|
||||
session_id = cdm.open()
|
||||
log.info(f"[+] Opened CDM Session: {session_id.hex()}")
|
||||
|
||||
if privacy:
|
||||
# get service cert for license server via cert challenge
|
||||
service_cert = requests.post(
|
||||
@@ -80,13 +86,13 @@ def license_(device: Path, pssh: str, server: str, type_: str, raw: bool, privac
|
||||
log.error(f"[-] Failed to get Service Privacy Certificate: [{service_cert.status_code}] {service_cert.text}")
|
||||
return
|
||||
service_cert = service_cert.content
|
||||
cdm.set_service_certificate(service_cert)
|
||||
log.info("[+] Set Service Privacy Certificate")
|
||||
provider_id = cdm.set_service_certificate(session_id, service_cert)
|
||||
log.info(f"[+] Set Service Privacy Certificate: {provider_id}")
|
||||
log.debug(service_cert)
|
||||
|
||||
# get license challenge
|
||||
license_type = LicenseType.Value(type_)
|
||||
challenge = cdm.get_license_challenge(license_type, privacy_mode=True)
|
||||
challenge = cdm.get_license_challenge(session_id, pssh, license_type, privacy_mode=True)
|
||||
log.info("[+] Created License Request Message (Challenge)")
|
||||
log.debug(challenge)
|
||||
|
||||
@@ -103,18 +109,25 @@ def license_(device: Path, pssh: str, server: str, type_: str, raw: bool, privac
|
||||
log.debug(licence)
|
||||
|
||||
# parse license challenge
|
||||
keys = cdm.parse_license(licence)
|
||||
cdm.parse_license(session_id, licence)
|
||||
log.info("[+] License Parsed Successfully")
|
||||
|
||||
# print keys
|
||||
for key in keys:
|
||||
# Note: This showcases how insecure a Python CDM implementation is
|
||||
# The keys should not be given to the user, but we cannot prevent this
|
||||
for key in cdm._sessions[session_id].keys:
|
||||
log.info(f"[{key.type}] {key.kid.hex}:{key.key.hex()}")
|
||||
|
||||
# close session, disposes of session data
|
||||
cdm.close(session_id)
|
||||
|
||||
|
||||
@main.command()
|
||||
@click.argument("device", type=Path)
|
||||
@click.option("-p", "--privacy", is_flag=True, default=False,
|
||||
help="Use Privacy Mode, off by default.")
|
||||
@click.pass_context
|
||||
def test(ctx: click.Context, device: Path):
|
||||
def test(ctx: click.Context, device: Path, privacy: bool):
|
||||
"""
|
||||
Test the CDM code by getting Content Keys for Bitmovin's Art of Motion example.
|
||||
https://bitmovin.com/demos/drm
|
||||
@@ -136,10 +149,6 @@ def test(ctx: click.Context, device: Path):
|
||||
# Download feature on Netflix Apps. Otherwise, use STREAMING or AUTOMATIC.
|
||||
license_type = LicenseType.STREAMING
|
||||
|
||||
# If the PSSH is not a valid mp4 pssh box, nor a valid CENC Header (init data) then
|
||||
# set this to True, otherwise leave it False.
|
||||
raw = False
|
||||
|
||||
# this runs the `cdm license` CLI-command code with the data we set above
|
||||
# it will print information as it goes to the terminal
|
||||
ctx.invoke(
|
||||
@@ -148,5 +157,149 @@ def test(ctx: click.Context, device: Path):
|
||||
pssh=pssh,
|
||||
server=license_server,
|
||||
type_=LicenseType.Name(license_type),
|
||||
raw=raw
|
||||
privacy=privacy
|
||||
)
|
||||
|
||||
|
||||
@main.command()
|
||||
@click.option("-t", "--type", "type_", type=click.Choice([x.name for x in Device.Types], case_sensitive=False),
|
||||
required=True, help="Device Type")
|
||||
@click.option("-l", "--level", type=click.IntRange(1, 3), required=True, help="Device Security Level")
|
||||
@click.option("-k", "--key", type=Path, required=True, help="Device RSA Private Key in PEM or DER format")
|
||||
@click.option("-c", "--client_id", type=Path, required=True, help="Widevine ClientIdentification Blob file")
|
||||
@click.option("-v", "--vmp", type=Path, default=None, help="Widevine FileHashes Blob file")
|
||||
@click.option("-o", "--output", type=Path, default=None, help="Output Directory")
|
||||
@click.pass_context
|
||||
def create_device(
|
||||
ctx: click.Context,
|
||||
type_: str,
|
||||
level: int,
|
||||
key: Path,
|
||||
client_id: Path,
|
||||
vmp: Optional[Path] = None,
|
||||
output: Optional[Path] = None
|
||||
) -> None:
|
||||
"""
|
||||
Create a Widevine Device (.wvd) file from an RSA Private Key (PEM or DER) and Client ID Blob.
|
||||
Optionally also a VMP (Verified Media Path) Blob, which will be stored in the Client ID.
|
||||
"""
|
||||
if not key.is_file():
|
||||
raise click.UsageError("key: Not a path to a file, or it doesn't exist.", ctx)
|
||||
if not client_id.is_file():
|
||||
raise click.UsageError("client_id: Not a path to a file, or it doesn't exist.", ctx)
|
||||
if vmp and not vmp.is_file():
|
||||
raise click.UsageError("vmp: Not a path to a file, or it doesn't exist.", ctx)
|
||||
|
||||
log = logging.getLogger("create-device")
|
||||
|
||||
device = Device(
|
||||
type_=Device.Types[type_.upper()],
|
||||
security_level=level,
|
||||
flags=None,
|
||||
private_key=key.read_bytes(),
|
||||
client_id=client_id.read_bytes()
|
||||
)
|
||||
|
||||
if vmp:
|
||||
new_vmp_data = vmp.read_bytes()
|
||||
if device.client_id.vmp_data and device.client_id.vmp_data != new_vmp_data:
|
||||
log.warning("Client ID already has Verified Media Path data")
|
||||
device.client_id.vmp_data = new_vmp_data
|
||||
|
||||
client_info = {}
|
||||
for entry in device.client_id.client_info:
|
||||
client_info[entry.name] = entry.value
|
||||
|
||||
wvd_bin = device.dumps()
|
||||
|
||||
name = f"{client_info['company_name']} {client_info['model_name']}"
|
||||
if client_info.get("widevine_cdm_version"):
|
||||
name += f" {client_info['widevine_cdm_version']}"
|
||||
name += f" {crc32(wvd_bin).to_bytes(4, 'big').hex()}"
|
||||
|
||||
try:
|
||||
name = unidecode(name.strip().lower().replace(" ", "_"))
|
||||
except UnidecodeError as e:
|
||||
raise click.ClickException(f"Failed to sanitize name, {e}")
|
||||
|
||||
out_path = (output or Path.cwd()) / f"{name}_{device.system_id}_l{device.security_level}.wvd"
|
||||
out_path.write_bytes(wvd_bin)
|
||||
|
||||
log.info(f"Created Widevine Device (.wvd) file, {out_path.name}")
|
||||
log.info(f" + Type: {device.type.name}")
|
||||
log.info(f" + System ID: {device.system_id}")
|
||||
log.info(f" + Security Level: {device.security_level}")
|
||||
log.info(f" + Flags: {device.flags}")
|
||||
log.info(f" + Private Key: {bool(device.private_key)} ({device.private_key.size_in_bits()} bit)")
|
||||
log.info(f" + Client ID: {bool(device.client_id)} ({len(device.client_id.SerializeToString())} bytes)")
|
||||
if device.client_id.vmp_data:
|
||||
file_hashes_ = FileHashes()
|
||||
file_hashes_.ParseFromString(device.client_id.vmp_data)
|
||||
log.info(f" + VMP: True ({len(file_hashes_.signatures)} signatures)")
|
||||
else:
|
||||
log.info(" + VMP: False")
|
||||
log.info(f" + Saved to: {out_path.absolute()}")
|
||||
|
||||
|
||||
@main.command()
|
||||
@click.argument("path", type=Path)
|
||||
@click.pass_context
|
||||
def migrate(ctx: click.Context, path: Path) -> None:
|
||||
"""
|
||||
Upgrade from earlier versions of the Widevine Device (.wvd) format.
|
||||
|
||||
The path argument can be a direct path to a Widevine Device (.wvd) file, or a path
|
||||
to a folder of Widevine Devices files.
|
||||
|
||||
The migrated devices are saved to its original location, overwriting the old version.
|
||||
"""
|
||||
if not path.exists():
|
||||
raise click.UsageError(f"path: The path '{path}' does not exist.", ctx)
|
||||
|
||||
log = logging.getLogger("migrate")
|
||||
|
||||
if path.is_dir():
|
||||
devices = list(path.glob("*.wvd"))
|
||||
else:
|
||||
devices = [path]
|
||||
|
||||
migrated = 0
|
||||
for device in devices:
|
||||
log.info(f"Migrating {device.name}...")
|
||||
|
||||
try:
|
||||
new_device = Device.migrate(device.read_bytes())
|
||||
except (ConstructError, ValueError) as e:
|
||||
log.error(f" - {e}")
|
||||
continue
|
||||
|
||||
log.debug(new_device)
|
||||
new_device.dump(device)
|
||||
|
||||
log.info(" + Success")
|
||||
migrated += 1
|
||||
|
||||
log.info(f"Migrated {migrated}/{len(devices)} devices!")
|
||||
|
||||
|
||||
@main.command("serve", short_help="Serve your local CDM and Widevine Devices Remotely.")
|
||||
@click.argument("config", type=Path)
|
||||
@click.option("-h", "--host", type=str, default="127.0.0.1", help="Host to serve from.")
|
||||
@click.option("-p", "--port", type=int, default=8786, help="Port to serve from.")
|
||||
def serve_(config: Path, host: str, port: int):
|
||||
"""
|
||||
Serve your local CDM and Widevine Devices Remotely.
|
||||
|
||||
\b
|
||||
[CONFIG] is a path to a serve config file.
|
||||
See `serve.example.yml` for an example config file.
|
||||
|
||||
\b
|
||||
Host as 127.0.0.1 may block remote access even if port-forwarded.
|
||||
Instead, use 0.0.0.0 and ensure the TCP port you choose is forwarded.
|
||||
"""
|
||||
from pywidevine import serve
|
||||
import yaml
|
||||
|
||||
config = yaml.safe_load(config.read_text(encoding="utf8"))
|
||||
serve.run(config, host, port)
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
from typing import Union
|
||||
from uuid import UUID
|
||||
|
||||
import construct
|
||||
from construct import Container
|
||||
from google.protobuf.message import DecodeError
|
||||
from lxml import etree
|
||||
@@ -22,28 +24,6 @@ class PSSH:
|
||||
def __init__(self, box: Container):
|
||||
self._box = box
|
||||
|
||||
@staticmethod
|
||||
def from_init_data(init_data: Union[str, bytes, WidevinePsshData]) -> Container:
|
||||
"""Craft a new PSSH Box from just Widevine PSSH Data (init data)."""
|
||||
if isinstance(init_data, str):
|
||||
init_data = base64.b64decode(init_data)
|
||||
if isinstance(init_data, bytes):
|
||||
cenc_header = WidevinePsshData()
|
||||
cenc_header.ParseFromString(init_data)
|
||||
init_data = cenc_header
|
||||
if not isinstance(init_data, WidevinePsshData):
|
||||
raise ValueError(f"Unexpected value for init_data, {init_data!r}")
|
||||
|
||||
box = Box.parse(Box.build(dict(
|
||||
type=b"pssh",
|
||||
version=0,
|
||||
flags=0,
|
||||
system_ID=PSSH.SystemId.Widevine,
|
||||
init_data=init_data.SerializeToString()
|
||||
)))
|
||||
|
||||
return box
|
||||
|
||||
@staticmethod
|
||||
def from_playready_pssh(box: Container) -> Container:
|
||||
"""
|
||||
@@ -100,36 +80,59 @@ class PSSH:
|
||||
return box
|
||||
|
||||
@staticmethod
|
||||
def get_as_box(data: Union[Container, bytes, str]) -> Container:
|
||||
def get_as_box(data: Union[Container, bytes, str], strict: bool = False) -> Container:
|
||||
"""
|
||||
Get the possibly arbitrary data as a parsed PSSH mp4 box.
|
||||
If the data is just Widevine PSSH Data (init data) then it will be crafted
|
||||
into a new PSSH mp4 box.
|
||||
If the data could not be recognized as a PSSH box of some form of encoding
|
||||
it will raise a ValueError.
|
||||
Get possibly arbitrary data as a parsed PSSH mp4 box.
|
||||
|
||||
Parameters:
|
||||
data: PSSH mp4 box, Widevine Cenc Header (init data), or arbitrary data to
|
||||
parse or craft into a PSSH mp4 box.
|
||||
strict: Do not return a PSSH box for arbitrary data. Require the data to be
|
||||
at least a PSSH mp4 box, or a Widevine Cenc Header.
|
||||
|
||||
Raises:
|
||||
ValueError: If the data is empty, or an unexpected type.
|
||||
binascii.Error: If the data could not be decoded as Base64 if provided
|
||||
as a string.
|
||||
DecodeError: If the data could not be parsed as a PSSH mp4 box
|
||||
nor a Widevine Cenc Header while strict=True.
|
||||
"""
|
||||
if isinstance(data, str):
|
||||
data = base64.b64decode(data)
|
||||
if isinstance(data, bytes):
|
||||
if base64.b64encode(data) == b"CAES": # likely widevine pssh data
|
||||
try:
|
||||
cenc_header = WidevinePsshData()
|
||||
cenc_header.ParseFromString(data)
|
||||
except DecodeError:
|
||||
# not actually init data after all
|
||||
pass
|
||||
else:
|
||||
data = Box.parse(Box.build(dict(
|
||||
type=b"pssh",
|
||||
version=0,
|
||||
flags=0,
|
||||
system_ID=PSSH.SystemId.Widevine,
|
||||
init_data=cenc_header.SerializeToString()
|
||||
)))
|
||||
data = Box.parse(data)
|
||||
if not data:
|
||||
raise ValueError("Data must not be empty.")
|
||||
|
||||
if isinstance(data, Container):
|
||||
return data
|
||||
raise ValueError(f"Unrecognized PSSH data: {data!r}")
|
||||
|
||||
if isinstance(data, str):
|
||||
try:
|
||||
data = base64.b64decode(data)
|
||||
except (binascii.Error, binascii.Incomplete) as e:
|
||||
raise binascii.Error(f"Could not decode data as Base64, {e}")
|
||||
|
||||
if isinstance(data, bytes):
|
||||
try:
|
||||
data = Box.parse(data)
|
||||
except (IOError, construct.ConstructError):
|
||||
if strict:
|
||||
try:
|
||||
cenc_header = WidevinePsshData()
|
||||
if cenc_header.MergeFromString(data) < len(data):
|
||||
raise DecodeError()
|
||||
except DecodeError:
|
||||
raise DecodeError(f"Could not parse data as a PSSH mp4 box nor a Widevine Cenc Header.")
|
||||
else:
|
||||
data = cenc_header.SerializeToString()
|
||||
data = Box.parse(Box.build(dict(
|
||||
type=b"pssh",
|
||||
version=0,
|
||||
flags=0,
|
||||
system_ID=PSSH.SystemId.Widevine,
|
||||
init_data=data
|
||||
)))
|
||||
else:
|
||||
raise ValueError(f"Data is an unexpected type, expected bytes got {data!r}.")
|
||||
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
def get_key_ids(box: Container) -> list[UUID]:
|
||||
|
||||
298
pywidevine/serve.py
Normal file
298
pywidevine/serve.py
Normal file
@@ -0,0 +1,298 @@
|
||||
import base64
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional, Union
|
||||
|
||||
try:
|
||||
from aiohttp import web
|
||||
except ImportError:
|
||||
print(
|
||||
"Missing the extra dependencies for serve functionality. "
|
||||
"You may install them under poetry with `poetry install -E serve`, "
|
||||
"or under pip with `pip install pywidevine[serve]`."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
from pywidevine import __version__
|
||||
from pywidevine.cdm import Cdm
|
||||
from pywidevine.device import Device
|
||||
from pywidevine.exceptions import TooManySessions, InvalidSession
|
||||
from pywidevine.license_protocol_pb2 import LicenseType, License
|
||||
|
||||
routes = web.RouteTableDef()
|
||||
|
||||
|
||||
async def _startup(app: web.Application):
|
||||
app["cdms"]: dict[tuple[str, str], Cdm] = {}
|
||||
app["config"]["devices"] = {
|
||||
path.stem: path
|
||||
for x in app["config"]["devices"]
|
||||
for path in [Path(x)]
|
||||
}
|
||||
for device in app["config"]["devices"].values():
|
||||
if not device.is_file():
|
||||
raise FileNotFoundError(f"Device file does not exist: {device}")
|
||||
|
||||
|
||||
async def _cleanup(app: web.Application):
|
||||
app["cdms"].clear()
|
||||
del app["cdms"]
|
||||
app["config"].clear()
|
||||
del app["config"]
|
||||
|
||||
|
||||
@routes.get("/")
|
||||
async def ping(_) -> web.Response:
|
||||
return web.json_response({
|
||||
"status": 200,
|
||||
"message": "Pong!"
|
||||
})
|
||||
|
||||
|
||||
@routes.get("/{device}/open")
|
||||
async def open(request: web.Request) -> web.Response:
|
||||
secret_key = request.headers["X-Secret-Key"]
|
||||
device_name = request.match_info["device"]
|
||||
user = request.app["config"]["users"][secret_key]
|
||||
|
||||
if device_name not in user["devices"] or device_name not in request.app["config"]["devices"]:
|
||||
# we don't want to be verbose with the error as to not reveal device names
|
||||
# by trial and error to users that are not authorized to use them
|
||||
return web.json_response({
|
||||
"status": 403,
|
||||
"message": f"Device '{device_name}' is not found or you are not authorized to use it."
|
||||
}, status=403)
|
||||
|
||||
cdm = request.app["cdms"].get((secret_key, device_name))
|
||||
if not cdm:
|
||||
device = Device.load(request.app["config"]["devices"][device_name])
|
||||
cdm = request.app["cdms"][(secret_key, device_name)] = Cdm(device)
|
||||
|
||||
try:
|
||||
session_id = cdm.open()
|
||||
except TooManySessions as e:
|
||||
return web.json_response({
|
||||
"status": 400,
|
||||
"message": str(e)
|
||||
}, status=400)
|
||||
|
||||
return web.json_response({
|
||||
"status": 200,
|
||||
"message": "Success",
|
||||
"data": {
|
||||
"session_id": session_id.hex(),
|
||||
"device": {
|
||||
"system_id": cdm.device.system_id,
|
||||
"security_level": cdm.device.security_level
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@routes.get("/{device}/close/{session_id}")
|
||||
async def close(request: web.Request) -> web.Response:
|
||||
secret_key = request.headers["X-Secret-Key"]
|
||||
device_name = request.match_info["device"]
|
||||
session_id = bytes.fromhex(request.match_info["session_id"])
|
||||
|
||||
cdm = request.app["cdms"].get((secret_key, device_name))
|
||||
if not cdm:
|
||||
return web.json_response({
|
||||
"status": 400,
|
||||
"message": f"No Cdm session for {device_name} has been opened yet. No session to close."
|
||||
}, status=400)
|
||||
|
||||
try:
|
||||
cdm.close(session_id)
|
||||
except InvalidSession as e:
|
||||
return web.json_response({
|
||||
"status": 400,
|
||||
"message": str(e)
|
||||
}, status=400)
|
||||
|
||||
return web.json_response({
|
||||
"status": 200,
|
||||
"message": f"Successfully closed Session '{session_id.hex()}'."
|
||||
})
|
||||
|
||||
|
||||
@routes.post("/{device}/challenge/{license_type}")
|
||||
async def challenge(request: web.Request) -> web.Response:
|
||||
secret_key = request.headers["X-Secret-Key"]
|
||||
device_name = request.match_info["device"]
|
||||
|
||||
body = await request.json()
|
||||
for required_field in ("session_id", "init_data"):
|
||||
if not body.get(required_field):
|
||||
return web.json_response({
|
||||
"status": 400,
|
||||
"message": f"Missing required field '{required_field}' in JSON body."
|
||||
}, status=400)
|
||||
|
||||
# get session id
|
||||
session_id = bytes.fromhex(body["session_id"])
|
||||
|
||||
# get cdm
|
||||
cdm = request.app["cdms"].get((secret_key, device_name))
|
||||
if not cdm:
|
||||
return web.json_response({
|
||||
"status": 400,
|
||||
"message": f"No Cdm session for {device_name} has been opened yet. No session to use."
|
||||
}, status=400)
|
||||
|
||||
if session_id not in cdm._sessions:
|
||||
# This can happen if:
|
||||
# - API server gets shutdown/restarted,
|
||||
# - The user calls /challenge before /open,
|
||||
# - The user called /open on a different IP Address
|
||||
# - The user closed the session
|
||||
return web.json_response({
|
||||
"status": 400,
|
||||
"message": "Invalid Session ID. Session ID may have Expired."
|
||||
}, status=400)
|
||||
|
||||
# set service certificate
|
||||
service_certificate = body.get("service_certificate")
|
||||
if request.app["config"]["force_privacy_mode"] and not service_certificate:
|
||||
return web.json_response({
|
||||
"status": 403,
|
||||
"message": "No Service Certificate provided but Privacy Mode is Enforced."
|
||||
}, status=403)
|
||||
if service_certificate:
|
||||
cdm.set_service_certificate(session_id, service_certificate)
|
||||
|
||||
# get challenge
|
||||
license_request = cdm.get_license_challenge(
|
||||
session_id=session_id,
|
||||
init_data=body["init_data"],
|
||||
type_=LicenseType.Value(request.match_info["license_type"]),
|
||||
privacy_mode=True
|
||||
)
|
||||
|
||||
return web.json_response({
|
||||
"status": 200,
|
||||
"message": "Success",
|
||||
"data": {
|
||||
"challenge_b64": base64.b64encode(license_request).decode()
|
||||
}
|
||||
}, status=200)
|
||||
|
||||
|
||||
@routes.post("/{device}/keys/{key_type}")
|
||||
async def keys(request: web.Request) -> web.Response:
|
||||
secret_key = request.headers["X-Secret-Key"]
|
||||
device_name = request.match_info["device"]
|
||||
|
||||
body = await request.json()
|
||||
for required_field in ("session_id", "license_message"):
|
||||
if not body.get(required_field):
|
||||
return web.json_response({
|
||||
"status": 400,
|
||||
"message": f"Missing required field '{required_field}' in JSON body."
|
||||
}, status=400)
|
||||
|
||||
# get session id
|
||||
session_id = bytes.fromhex(body["session_id"])
|
||||
|
||||
# get key type
|
||||
key_type = request.match_info["key_type"]
|
||||
if key_type == "ALL":
|
||||
key_type = None
|
||||
else:
|
||||
try:
|
||||
if key_type.isdigit():
|
||||
key_type = License.KeyContainer.KeyType.Name(int(key_type))
|
||||
else:
|
||||
License.KeyContainer.KeyType.Value(key_type) # only test
|
||||
except ValueError as e:
|
||||
return web.json_response({
|
||||
"status": 400,
|
||||
"message": f"The Key Type value is invalid, {e}"
|
||||
}, status=400)
|
||||
|
||||
# get cdm
|
||||
cdm = request.app["cdms"].get((secret_key, device_name))
|
||||
if not cdm:
|
||||
return web.json_response({
|
||||
"status": 400,
|
||||
"message": f"No Cdm session for {device_name} has been opened yet. No session to use."
|
||||
}, status=400)
|
||||
|
||||
if session_id not in cdm._sessions:
|
||||
# This can happen if:
|
||||
# - API server gets shutdown/restarted,
|
||||
# - The user calls /challenge before /open,
|
||||
# - The user called /open on a different IP Address
|
||||
# - The user closed the session
|
||||
return web.json_response({
|
||||
"status": 400,
|
||||
"message": "Invalid Session ID. Session ID may have Expired."
|
||||
}, status=400)
|
||||
|
||||
# parse the license message
|
||||
cdm.parse_license(session_id, body["license_message"])
|
||||
|
||||
# prepare the keys
|
||||
license_keys = [
|
||||
{
|
||||
"key_id": key.kid.hex,
|
||||
"key": key.key.hex(),
|
||||
"type": key.type,
|
||||
"permissions": key.permissions,
|
||||
}
|
||||
for key in cdm._sessions[session_id].keys
|
||||
if not key_type or key.type == key_type
|
||||
]
|
||||
|
||||
return web.json_response({
|
||||
"status": 200,
|
||||
"message": "Success",
|
||||
"data": {
|
||||
# TODO: Add derived context keys like enc/mac[client]/mac[server]
|
||||
"keys": license_keys
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@web.middleware
|
||||
async def authentication(request: web.Request, handler) -> web.Response:
|
||||
response = None
|
||||
if request.path != "/":
|
||||
secret_key = request.headers.get("X-Secret-Key")
|
||||
if not secret_key:
|
||||
request.app.logger.debug(f"{request.remote} did not provide authorization.")
|
||||
response = web.json_response({
|
||||
"status": "401",
|
||||
"message": "Secret Key is Empty."
|
||||
}, status=401)
|
||||
elif secret_key not in request.app["config"]["users"]:
|
||||
request.app.logger.debug(f"{request.remote} failed authentication with '{secret_key}'.")
|
||||
response = web.json_response({
|
||||
"status": "401",
|
||||
"message": "Secret Key is Invalid, the Key is case-sensitive."
|
||||
}, status=401)
|
||||
|
||||
if response is None:
|
||||
try:
|
||||
response = await handler(request)
|
||||
except web.HTTPException as e:
|
||||
request.app.logger.error(f"An unexpected error has occurred, {e}")
|
||||
response = web.json_response({
|
||||
"status": 500,
|
||||
"message": e.reason
|
||||
}, status=500)
|
||||
|
||||
response.headers.update({
|
||||
"Server": f"https://github.com/rlaphoenix/pywidevine serve v{__version__}"
|
||||
})
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def run(config: dict, host: Optional[Union[str, web.HostSequence]] = None, port: Optional[int] = None):
|
||||
app = web.Application(middlewares=[authentication])
|
||||
app.on_startup.append(_startup)
|
||||
app.on_cleanup.append(_cleanup)
|
||||
app.add_routes(routes)
|
||||
app["config"] = config
|
||||
web.run_app(app, host=host, port=port)
|
||||
14
pywidevine/session.py
Normal file
14
pywidevine/session.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from typing import Optional
|
||||
|
||||
from Crypto.Random import get_random_bytes
|
||||
|
||||
from pywidevine.key import Key
|
||||
from pywidevine.license_protocol_pb2 import SignedMessage
|
||||
|
||||
|
||||
class Session:
|
||||
def __init__(self):
|
||||
self.id = get_random_bytes(16)
|
||||
self.service_certificate: Optional[SignedMessage] = None
|
||||
self.context: dict[bytes, tuple[bytes, bytes]] = {}
|
||||
self.keys: list[Key] = []
|
||||
20
serve.example.yml
Normal file
20
serve.example.yml
Normal file
@@ -0,0 +1,20 @@
|
||||
# This data serves as an example configuration file for the `serve` command.
|
||||
# None of the sensitive data should be re-used.
|
||||
|
||||
# List of Widevine Device (.wvd) file paths to use with serve.
|
||||
# Note: Each individual user needs explicit permission to use a device listed.
|
||||
devices:
|
||||
- 'C:\Users\rlaphoenix\Documents\WVDs\test_device_001.wvd'
|
||||
|
||||
# List of User's by Secret Key. The Secret Key must be supplied by the User to use the API.
|
||||
users:
|
||||
fvYBh0C3fRAxlvyJcynD1see3GmNbIiC: # secret key, a-zA-Z-09{32} is recommended, case-sensitive
|
||||
username: jane # only for internal logging, user will not see this name
|
||||
devices: # list of allowed devices by filename
|
||||
- test_key_001
|
||||
# ...
|
||||
|
||||
# All clients must provide a service certificate for privacy mode.
|
||||
# If the client does not provide a certificate, privacy mode may or may not be used.
|
||||
# Enforcing Privacy Mode helps protect the identity of the device and is recommended.
|
||||
force_privacy_mode: true
|
||||
Reference in New Issue
Block a user