Merge pull request #346 from jdepoix/feature/github-actions

migrated CI to GitHub Actions and setup project to use poetry
This commit is contained in:
Jonas Depoix 2024-11-11 16:28:48 +01:00 committed by GitHub
commit ceda81b968
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 1325 additions and 595 deletions

View File

@ -1,30 +0,0 @@
[run]
source = youtube_transcript_api
[report]
omit =
*/__main__.py
exclude_lines =
pragma: no cover
# Don't complain about missing debug-only code:
def __unicode__
def __repr__
if self\.debug
# Don't complain if tests don't hit defensive assertion code:
raise AssertionError
raise NotImplementedError
# Don't complain if non-runnable code isn't run:
if 0:
if __name__ == .__main__.:
# Don't complain about empty stubs of abstract methods
@abstractmethod
@abstractclassmethod
@abstractstaticmethod
show_missing = True

95
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,95 @@
name: CI
on:
push:
branches: [ "master" ]
pull_request:
jobs:
static-checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python 3.9
uses: actions/setup-python@v5
with:
python-version: 3.9
- name: Install dependencies
run: |
pip install poetry poethepoet
poetry install --only dev
- name: Format
run: poe ci-format
- name: Lint
run: poe lint
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
pip install poetry poethepoet
poetry install --with test
- name: Run tests
run: |
poe ci-test
- name: Report intermediate coverage report
uses: coverallsapp/github-action@v2
with:
file: coverage.xml
format: cobertura
flag-name: run-python-${{ matrix.python-version }}
parallel: true
coverage:
needs: test
runs-on: ubuntu-latest
steps:
- name: Finalize coverage report
uses: coverallsapp/github-action@v2
with:
parallel-finished: true
carryforward: "run-python-3.8,run-python-3.9,run-python-3.10,run-python-3.11,run-python-3.12,run-python-3.13"
- uses: actions/checkout@v4
- name: Set up Python 3.9
uses: actions/setup-python@v5
with:
python-version: 3.9
- name: Install dependencies
run: |
pip install poetry poethepoet
poetry install --with test
- name: Check coverage
run: poe coverage
publish:
if: github.event_name == 'push' && contains(github.ref, 'refs/tags/')
needs: [coverage, static-checks]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python 3.9
uses: actions/setup-python@v5
with:
python-version: 3.9
- name: Install dependencies
run: |
pip install poetry
poetry install
- name: Build
run: poetry build
- name: Publish
run: poetry publish -u __token__ -p ${{ secrets.PYPI_TOKEN }}

4
.gitignore vendored
View File

@ -6,4 +6,6 @@ dist
build build
*.egg-info *.egg-info
upload_new_version.sh upload_new_version.sh
.coverage .coverage
coverage.xml
.DS_STORE

View File

@ -1,15 +0,0 @@
language: python
python:
- "3.5"
- "3.6"
- "3.7.11"
- "3.8"
install:
- pip install --upgrade pip
- pip install --upgrade setuptools
- pip install -r requirements.txt
- pip install urllib3==1.26.6
script:
- coverage run -m unittest discover
after_success:
- coveralls

View File

@ -7,8 +7,8 @@
<a href="https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=BAENLEW8VUJ6G&source=url"> <a href="https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=BAENLEW8VUJ6G&source=url">
<img src="https://img.shields.io/badge/Donate-PayPal-green.svg" alt="Donate"> <img src="https://img.shields.io/badge/Donate-PayPal-green.svg" alt="Donate">
</a> </a>
<a href="https://app.travis-ci.com/jdepoix/youtube-transcript-api"> <a href="https://github.com/jdepoix/youtube-transcript-api/actions">
<img src="https://travis-ci.com/jdepoix/youtube-transcript-api.svg?branch=master" alt="Build Status"> <img src="https://github.com/jdepoix/youtube-transcript-api/actions/workflows/ci.yml/badge.svg?branch=master" alt="Build Status">
</a> </a>
<a href="https://coveralls.io/github/jdepoix/youtube-transcript-api?branch=master"> <a href="https://coveralls.io/github/jdepoix/youtube-transcript-api?branch=master">
<img src="https://coveralls.io/repos/github/jdepoix/youtube-transcript-api/badge.svg?branch=master" alt="Coverage Status"> <img src="https://coveralls.io/repos/github/jdepoix/youtube-transcript-api/badge.svg?branch=master" alt="Coverage Status">
@ -49,12 +49,6 @@ It is recommended to [install this module by using pip](https://pypi.org/project
pip install youtube-transcript-api pip install youtube-transcript-api
``` ```
If you want to use it from source, you'll have to install the dependencies manually:
```
pip install -r requirements.txt
```
You can either integrate this module [into an existing application](#api) or just use it via a [CLI](#cli). You can either integrate this module [into an existing application](#api) or just use it via a [CLI](#cli).
## API ## API
@ -371,10 +365,29 @@ Using the CLI:
youtube_transcript_api <first_video_id> <second_video_id> --cookies /path/to/your/cookies.txt youtube_transcript_api <first_video_id> <second_video_id> --cookies /path/to/your/cookies.txt
``` ```
## Warning ## Warning
This code uses an undocumented part of the YouTube API, which is called by the YouTube web-client. So there is no guarantee that it won't stop working tomorrow, if they change how things work. I will however do my best to make things working again as soon as possible if that happens. So if it stops working, let me know! This code uses an undocumented part of the YouTube API, which is called by the YouTube web-client. So there is no guarantee that it won't stop working tomorrow, if they change how things work. I will however do my best to make things working again as soon as possible if that happens. So if it stops working, let me know!
## Contributing
To setup the project locally run (requires [poetry](https://python-poetry.org/docs/) to be installed):
```shell
poetry install --with test,dev
```
There's [poe](https://github.com/nat-n/poethepoet?tab=readme-ov-file#quick-start) tasks to run tests, coverage, the linter and formatter (you'll need to pass all of those for the build to pass):
```shell
poe test
poe coverage
poe format
poe lint
```
If you just want to make sure that your code passes all the necessary checks to get a green build, you can simply run:
```shell
poe precommit
```
## Donations ## Donations

View File

@ -1,3 +0,0 @@
#!/usr/bin/env bash
.venv/bin/coverage run -m unittest discover && .venv/bin/coverage report

415
poetry.lock generated Normal file
View File

@ -0,0 +1,415 @@
# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand.
[[package]]
name = "certifi"
version = "2024.8.30"
description = "Python package for providing Mozilla's CA Bundle."
optional = false
python-versions = ">=3.6"
files = [
{file = "certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8"},
{file = "certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9"},
]
[[package]]
name = "charset-normalizer"
version = "3.4.0"
description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
optional = false
python-versions = ">=3.7.0"
files = [
{file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4f9fc98dad6c2eaa32fc3af1417d95b5e3d08aff968df0cd320066def971f9a6"},
{file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0de7b687289d3c1b3e8660d0741874abe7888100efe14bd0f9fd7141bcbda92b"},
{file = "charset_normalizer-3.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5ed2e36c3e9b4f21dd9422f6893dec0abf2cca553af509b10cd630f878d3eb99"},
{file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40d3ff7fc90b98c637bda91c89d51264a3dcf210cade3a2c6f838c7268d7a4ca"},
{file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1110e22af8ca26b90bd6364fe4c763329b0ebf1ee213ba32b68c73de5752323d"},
{file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86f4e8cca779080f66ff4f191a685ced73d2f72d50216f7112185dc02b90b9b7"},
{file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f683ddc7eedd742e2889d2bfb96d69573fde1d92fcb811979cdb7165bb9c7d3"},
{file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:27623ba66c183eca01bf9ff833875b459cad267aeeb044477fedac35e19ba907"},
{file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f606a1881d2663630ea5b8ce2efe2111740df4b687bd78b34a8131baa007f79b"},
{file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0b309d1747110feb25d7ed6b01afdec269c647d382c857ef4663bbe6ad95a912"},
{file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:136815f06a3ae311fae551c3df1f998a1ebd01ddd424aa5603a4336997629e95"},
{file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:14215b71a762336254351b00ec720a8e85cada43b987da5a042e4ce3e82bd68e"},
{file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79983512b108e4a164b9c8d34de3992f76d48cadc9554c9e60b43f308988aabe"},
{file = "charset_normalizer-3.4.0-cp310-cp310-win32.whl", hash = "sha256:c94057af19bc953643a33581844649a7fdab902624d2eb739738a30e2b3e60fc"},
{file = "charset_normalizer-3.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:55f56e2ebd4e3bc50442fbc0888c9d8c94e4e06a933804e2af3e89e2f9c1c749"},
{file = "charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0d99dd8ff461990f12d6e42c7347fd9ab2532fb70e9621ba520f9e8637161d7c"},
{file = "charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c57516e58fd17d03ebe67e181a4e4e2ccab1168f8c2976c6a334d4f819fe5944"},
{file = "charset_normalizer-3.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6dba5d19c4dfab08e58d5b36304b3f92f3bd5d42c1a3fa37b5ba5cdf6dfcbcee"},
{file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf4475b82be41b07cc5e5ff94810e6a01f276e37c2d55571e3fe175e467a1a1c"},
{file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce031db0408e487fd2775d745ce30a7cd2923667cf3b69d48d219f1d8f5ddeb6"},
{file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ff4e7cdfdb1ab5698e675ca622e72d58a6fa2a8aa58195de0c0061288e6e3ea"},
{file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3710a9751938947e6327ea9f3ea6332a09bf0ba0c09cae9cb1f250bd1f1549bc"},
{file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82357d85de703176b5587dbe6ade8ff67f9f69a41c0733cf2425378b49954de5"},
{file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47334db71978b23ebcf3c0f9f5ee98b8d65992b65c9c4f2d34c2eaf5bcaf0594"},
{file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8ce7fd6767a1cc5a92a639b391891bf1c268b03ec7e021c7d6d902285259685c"},
{file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1a2f519ae173b5b6a2c9d5fa3116ce16e48b3462c8b96dfdded11055e3d6365"},
{file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:63bc5c4ae26e4bc6be6469943b8253c0fd4e4186c43ad46e713ea61a0ba49129"},
{file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bcb4f8ea87d03bc51ad04add8ceaf9b0f085ac045ab4d74e73bbc2dc033f0236"},
{file = "charset_normalizer-3.4.0-cp311-cp311-win32.whl", hash = "sha256:9ae4ef0b3f6b41bad6366fb0ea4fc1d7ed051528e113a60fa2a65a9abb5b1d99"},
{file = "charset_normalizer-3.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cee4373f4d3ad28f1ab6290684d8e2ebdb9e7a1b74fdc39e4c211995f77bec27"},
{file = "charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0713f3adb9d03d49d365b70b84775d0a0d18e4ab08d12bc46baa6132ba78aaf6"},
{file = "charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:de7376c29d95d6719048c194a9cf1a1b0393fbe8488a22008610b0361d834ecf"},
{file = "charset_normalizer-3.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a51b48f42d9358460b78725283f04bddaf44a9358197b889657deba38f329db"},
{file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b295729485b06c1a0683af02a9e42d2caa9db04a373dc38a6a58cdd1e8abddf1"},
{file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ee803480535c44e7f5ad00788526da7d85525cfefaf8acf8ab9a310000be4b03"},
{file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d59d125ffbd6d552765510e3f31ed75ebac2c7470c7274195b9161a32350284"},
{file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cda06946eac330cbe6598f77bb54e690b4ca93f593dee1568ad22b04f347c15"},
{file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07afec21bbbbf8a5cc3651aa96b980afe2526e7f048fdfb7f1014d84acc8b6d8"},
{file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6b40e8d38afe634559e398cc32b1472f376a4099c75fe6299ae607e404c033b2"},
{file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b8dcd239c743aa2f9c22ce674a145e0a25cb1566c495928440a181ca1ccf6719"},
{file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:84450ba661fb96e9fd67629b93d2941c871ca86fc38d835d19d4225ff946a631"},
{file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:44aeb140295a2f0659e113b31cfe92c9061622cadbc9e2a2f7b8ef6b1e29ef4b"},
{file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1db4e7fefefd0f548d73e2e2e041f9df5c59e178b4c72fbac4cc6f535cfb1565"},
{file = "charset_normalizer-3.4.0-cp312-cp312-win32.whl", hash = "sha256:5726cf76c982532c1863fb64d8c6dd0e4c90b6ece9feb06c9f202417a31f7dd7"},
{file = "charset_normalizer-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:b197e7094f232959f8f20541ead1d9862ac5ebea1d58e9849c1bf979255dfac9"},
{file = "charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114"},
{file = "charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed"},
{file = "charset_normalizer-3.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250"},
{file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920"},
{file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64"},
{file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23"},
{file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc"},
{file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d"},
{file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88"},
{file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90"},
{file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b"},
{file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d"},
{file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482"},
{file = "charset_normalizer-3.4.0-cp313-cp313-win32.whl", hash = "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67"},
{file = "charset_normalizer-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b"},
{file = "charset_normalizer-3.4.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:dbe03226baf438ac4fda9e2d0715022fd579cb641c4cf639fa40d53b2fe6f3e2"},
{file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd9a8bd8900e65504a305bf8ae6fa9fbc66de94178c420791d0293702fce2df7"},
{file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b8831399554b92b72af5932cdbbd4ddc55c55f631bb13ff8fe4e6536a06c5c51"},
{file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a14969b8691f7998e74663b77b4c36c0337cb1df552da83d5c9004a93afdb574"},
{file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcaf7c1524c0542ee2fc82cc8ec337f7a9f7edee2532421ab200d2b920fc97cf"},
{file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:425c5f215d0eecee9a56cdb703203dda90423247421bf0d67125add85d0c4455"},
{file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:d5b054862739d276e09928de37c79ddeec42a6e1bfc55863be96a36ba22926f6"},
{file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:f3e73a4255342d4eb26ef6df01e3962e73aa29baa3124a8e824c5d3364a65748"},
{file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:2f6c34da58ea9c1a9515621f4d9ac379871a8f21168ba1b5e09d74250de5ad62"},
{file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:f09cb5a7bbe1ecae6e87901a2eb23e0256bb524a79ccc53eb0b7629fbe7677c4"},
{file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:0099d79bdfcf5c1f0c2c72f91516702ebf8b0b8ddd8905f97a8aecf49712c621"},
{file = "charset_normalizer-3.4.0-cp37-cp37m-win32.whl", hash = "sha256:9c98230f5042f4945f957d006edccc2af1e03ed5e37ce7c373f00a5a4daa6149"},
{file = "charset_normalizer-3.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:62f60aebecfc7f4b82e3f639a7d1433a20ec32824db2199a11ad4f5e146ef5ee"},
{file = "charset_normalizer-3.4.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:af73657b7a68211996527dbfeffbb0864e043d270580c5aef06dc4b659a4b578"},
{file = "charset_normalizer-3.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cab5d0b79d987c67f3b9e9c53f54a61360422a5a0bc075f43cab5621d530c3b6"},
{file = "charset_normalizer-3.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:9289fd5dddcf57bab41d044f1756550f9e7cf0c8e373b8cdf0ce8773dc4bd417"},
{file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b493a043635eb376e50eedf7818f2f322eabbaa974e948bd8bdd29eb7ef2a51"},
{file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9fa2566ca27d67c86569e8c85297aaf413ffab85a8960500f12ea34ff98e4c41"},
{file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8e538f46104c815be19c975572d74afb53f29650ea2025bbfaef359d2de2f7f"},
{file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fd30dc99682dc2c603c2b315bded2799019cea829f8bf57dc6b61efde6611c8"},
{file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2006769bd1640bdf4d5641c69a3d63b71b81445473cac5ded39740a226fa88ab"},
{file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:dc15e99b2d8a656f8e666854404f1ba54765871104e50c8e9813af8a7db07f12"},
{file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:ab2e5bef076f5a235c3774b4f4028a680432cded7cad37bba0fd90d64b187d19"},
{file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:4ec9dd88a5b71abfc74e9df5ebe7921c35cbb3b641181a531ca65cdb5e8e4dea"},
{file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:43193c5cda5d612f247172016c4bb71251c784d7a4d9314677186a838ad34858"},
{file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:aa693779a8b50cd97570e5a0f343538a8dbd3e496fa5dcb87e29406ad0299654"},
{file = "charset_normalizer-3.4.0-cp38-cp38-win32.whl", hash = "sha256:7706f5850360ac01d80c89bcef1640683cc12ed87f42579dab6c5d3ed6888613"},
{file = "charset_normalizer-3.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:c3e446d253bd88f6377260d07c895816ebf33ffffd56c1c792b13bff9c3e1ade"},
{file = "charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:980b4f289d1d90ca5efcf07958d3eb38ed9c0b7676bf2831a54d4f66f9c27dfa"},
{file = "charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f28f891ccd15c514a0981f3b9db9aa23d62fe1a99997512b0491d2ed323d229a"},
{file = "charset_normalizer-3.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8aacce6e2e1edcb6ac625fb0f8c3a9570ccc7bfba1f63419b3769ccf6a00ed0"},
{file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd7af3717683bea4c87acd8c0d3d5b44d56120b26fd3f8a692bdd2d5260c620a"},
{file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ff2ed8194587faf56555927b3aa10e6fb69d931e33953943bc4f837dfee2242"},
{file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e91f541a85298cf35433bf66f3fab2a4a2cff05c127eeca4af174f6d497f0d4b"},
{file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309a7de0a0ff3040acaebb35ec45d18db4b28232f21998851cfa709eeff49d62"},
{file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:285e96d9d53422efc0d7a17c60e59f37fbf3dfa942073f666db4ac71e8d726d0"},
{file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:5d447056e2ca60382d460a604b6302d8db69476fd2015c81e7c35417cfabe4cd"},
{file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:20587d20f557fe189b7947d8e7ec5afa110ccf72a3128d61a2a387c3313f46be"},
{file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:130272c698667a982a5d0e626851ceff662565379baf0ff2cc58067b81d4f11d"},
{file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:ab22fbd9765e6954bc0bcff24c25ff71dcbfdb185fcdaca49e81bac68fe724d3"},
{file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7782afc9b6b42200f7362858f9e73b1f8316afb276d316336c0ec3bd73312742"},
{file = "charset_normalizer-3.4.0-cp39-cp39-win32.whl", hash = "sha256:2de62e8801ddfff069cd5c504ce3bc9672b23266597d4e4f50eda28846c322f2"},
{file = "charset_normalizer-3.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:95c3c157765b031331dd4db3c775e58deaee050a3042fcad72cbc4189d7c8dca"},
{file = "charset_normalizer-3.4.0-py3-none-any.whl", hash = "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079"},
{file = "charset_normalizer-3.4.0.tar.gz", hash = "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e"},
]
[[package]]
name = "colorama"
version = "0.4.6"
description = "Cross-platform colored terminal text."
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
files = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
]
[[package]]
name = "coverage"
version = "7.6.1"
description = "Code coverage measurement for Python"
optional = false
python-versions = ">=3.8"
files = [
{file = "coverage-7.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b06079abebbc0e89e6163b8e8f0e16270124c154dc6e4a47b413dd538859af16"},
{file = "coverage-7.6.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cf4b19715bccd7ee27b6b120e7e9dd56037b9c0681dcc1adc9ba9db3d417fa36"},
{file = "coverage-7.6.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61c0abb4c85b095a784ef23fdd4aede7a2628478e7baba7c5e3deba61070a02"},
{file = "coverage-7.6.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd21f6ae3f08b41004dfb433fa895d858f3f5979e7762d052b12aef444e29afc"},
{file = "coverage-7.6.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f59d57baca39b32db42b83b2a7ba6f47ad9c394ec2076b084c3f029b7afca23"},
{file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a1ac0ae2b8bd743b88ed0502544847c3053d7171a3cff9228af618a068ed9c34"},
{file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e6a08c0be454c3b3beb105c0596ebdc2371fab6bb90c0c0297f4e58fd7e1012c"},
{file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f5796e664fe802da4f57a168c85359a8fbf3eab5e55cd4e4569fbacecc903959"},
{file = "coverage-7.6.1-cp310-cp310-win32.whl", hash = "sha256:7bb65125fcbef8d989fa1dd0e8a060999497629ca5b0efbca209588a73356232"},
{file = "coverage-7.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:3115a95daa9bdba70aea750db7b96b37259a81a709223c8448fa97727d546fe0"},
{file = "coverage-7.6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7dea0889685db8550f839fa202744652e87c60015029ce3f60e006f8c4462c93"},
{file = "coverage-7.6.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ed37bd3c3b063412f7620464a9ac1314d33100329f39799255fb8d3027da50d3"},
{file = "coverage-7.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d85f5e9a5f8b73e2350097c3756ef7e785f55bd71205defa0bfdaf96c31616ff"},
{file = "coverage-7.6.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9bc572be474cafb617672c43fe989d6e48d3c83af02ce8de73fff1c6bb3c198d"},
{file = "coverage-7.6.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0420b573964c760df9e9e86d1a9a622d0d27f417e1a949a8a66dd7bcee7bc6"},
{file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1f4aa8219db826ce6be7099d559f8ec311549bfc4046f7f9fe9b5cea5c581c56"},
{file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:fc5a77d0c516700ebad189b587de289a20a78324bc54baee03dd486f0855d234"},
{file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b48f312cca9621272ae49008c7f613337c53fadca647d6384cc129d2996d1133"},
{file = "coverage-7.6.1-cp311-cp311-win32.whl", hash = "sha256:1125ca0e5fd475cbbba3bb67ae20bd2c23a98fac4e32412883f9bcbaa81c314c"},
{file = "coverage-7.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:8ae539519c4c040c5ffd0632784e21b2f03fc1340752af711f33e5be83a9d6c6"},
{file = "coverage-7.6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:95cae0efeb032af8458fc27d191f85d1717b1d4e49f7cb226cf526ff28179778"},
{file = "coverage-7.6.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5621a9175cf9d0b0c84c2ef2b12e9f5f5071357c4d2ea6ca1cf01814f45d2391"},
{file = "coverage-7.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:260933720fdcd75340e7dbe9060655aff3af1f0c5d20f46b57f262ab6c86a5e8"},
{file = "coverage-7.6.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07e2ca0ad381b91350c0ed49d52699b625aab2b44b65e1b4e02fa9df0e92ad2d"},
{file = "coverage-7.6.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c44fee9975f04b33331cb8eb272827111efc8930cfd582e0320613263ca849ca"},
{file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877abb17e6339d96bf08e7a622d05095e72b71f8afd8a9fefc82cf30ed944163"},
{file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3e0cadcf6733c09154b461f1ca72d5416635e5e4ec4e536192180d34ec160f8a"},
{file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c3c02d12f837d9683e5ab2f3d9844dc57655b92c74e286c262e0fc54213c216d"},
{file = "coverage-7.6.1-cp312-cp312-win32.whl", hash = "sha256:e05882b70b87a18d937ca6768ff33cc3f72847cbc4de4491c8e73880766718e5"},
{file = "coverage-7.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:b5d7b556859dd85f3a541db6a4e0167b86e7273e1cdc973e5b175166bb634fdb"},
{file = "coverage-7.6.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a4acd025ecc06185ba2b801f2de85546e0b8ac787cf9d3b06e7e2a69f925b106"},
{file = "coverage-7.6.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a6d3adcf24b624a7b778533480e32434a39ad8fa30c315208f6d3e5542aeb6e9"},
{file = "coverage-7.6.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0c212c49b6c10e6951362f7c6df3329f04c2b1c28499563d4035d964ab8e08c"},
{file = "coverage-7.6.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6e81d7a3e58882450ec4186ca59a3f20a5d4440f25b1cff6f0902ad890e6748a"},
{file = "coverage-7.6.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78b260de9790fd81e69401c2dc8b17da47c8038176a79092a89cb2b7d945d060"},
{file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a78d169acd38300060b28d600344a803628c3fd585c912cacc9ea8790fe96862"},
{file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2c09f4ce52cb99dd7505cd0fc8e0e37c77b87f46bc9c1eb03fe3bc9991085388"},
{file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6878ef48d4227aace338d88c48738a4258213cd7b74fd9a3d4d7582bb1d8a155"},
{file = "coverage-7.6.1-cp313-cp313-win32.whl", hash = "sha256:44df346d5215a8c0e360307d46ffaabe0f5d3502c8a1cefd700b34baf31d411a"},
{file = "coverage-7.6.1-cp313-cp313-win_amd64.whl", hash = "sha256:8284cf8c0dd272a247bc154eb6c95548722dce90d098c17a883ed36e67cdb129"},
{file = "coverage-7.6.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d3296782ca4eab572a1a4eca686d8bfb00226300dcefdf43faa25b5242ab8a3e"},
{file = "coverage-7.6.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:502753043567491d3ff6d08629270127e0c31d4184c4c8d98f92c26f65019962"},
{file = "coverage-7.6.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a89ecca80709d4076b95f89f308544ec8f7b4727e8a547913a35f16717856cb"},
{file = "coverage-7.6.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a318d68e92e80af8b00fa99609796fdbcdfef3629c77c6283566c6f02c6d6704"},
{file = "coverage-7.6.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13b0a73a0896988f053e4fbb7de6d93388e6dd292b0d87ee51d106f2c11b465b"},
{file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4421712dbfc5562150f7554f13dde997a2e932a6b5f352edcce948a815efee6f"},
{file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:166811d20dfea725e2e4baa71fffd6c968a958577848d2131f39b60043400223"},
{file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:225667980479a17db1048cb2bf8bfb39b8e5be8f164b8f6628b64f78a72cf9d3"},
{file = "coverage-7.6.1-cp313-cp313t-win32.whl", hash = "sha256:170d444ab405852903b7d04ea9ae9b98f98ab6d7e63e1115e82620807519797f"},
{file = "coverage-7.6.1-cp313-cp313t-win_amd64.whl", hash = "sha256:b9f222de8cded79c49bf184bdbc06630d4c58eec9459b939b4a690c82ed05657"},
{file = "coverage-7.6.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6db04803b6c7291985a761004e9060b2bca08da6d04f26a7f2294b8623a0c1a0"},
{file = "coverage-7.6.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f1adfc8ac319e1a348af294106bc6a8458a0f1633cc62a1446aebc30c5fa186a"},
{file = "coverage-7.6.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a95324a9de9650a729239daea117df21f4b9868ce32e63f8b650ebe6cef5595b"},
{file = "coverage-7.6.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b43c03669dc4618ec25270b06ecd3ee4fa94c7f9b3c14bae6571ca00ef98b0d3"},
{file = "coverage-7.6.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8929543a7192c13d177b770008bc4e8119f2e1f881d563fc6b6305d2d0ebe9de"},
{file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:a09ece4a69cf399510c8ab25e0950d9cf2b42f7b3cb0374f95d2e2ff594478a6"},
{file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:9054a0754de38d9dbd01a46621636689124d666bad1936d76c0341f7d71bf569"},
{file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0dbde0f4aa9a16fa4d754356a8f2e36296ff4d83994b2c9d8398aa32f222f989"},
{file = "coverage-7.6.1-cp38-cp38-win32.whl", hash = "sha256:da511e6ad4f7323ee5702e6633085fb76c2f893aaf8ce4c51a0ba4fc07580ea7"},
{file = "coverage-7.6.1-cp38-cp38-win_amd64.whl", hash = "sha256:3f1156e3e8f2872197af3840d8ad307a9dd18e615dc64d9ee41696f287c57ad8"},
{file = "coverage-7.6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:abd5fd0db5f4dc9289408aaf34908072f805ff7792632250dcb36dc591d24255"},
{file = "coverage-7.6.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:547f45fa1a93154bd82050a7f3cddbc1a7a4dd2a9bf5cb7d06f4ae29fe94eaf8"},
{file = "coverage-7.6.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:645786266c8f18a931b65bfcefdbf6952dd0dea98feee39bd188607a9d307ed2"},
{file = "coverage-7.6.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9e0b2df163b8ed01d515807af24f63de04bebcecbd6c3bfeff88385789fdf75a"},
{file = "coverage-7.6.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:609b06f178fe8e9f89ef676532760ec0b4deea15e9969bf754b37f7c40326dbc"},
{file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:702855feff378050ae4f741045e19a32d57d19f3e0676d589df0575008ea5004"},
{file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:2bdb062ea438f22d99cba0d7829c2ef0af1d768d1e4a4f528087224c90b132cb"},
{file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9c56863d44bd1c4fe2abb8a4d6f5371d197f1ac0ebdee542f07f35895fc07f36"},
{file = "coverage-7.6.1-cp39-cp39-win32.whl", hash = "sha256:6e2cd258d7d927d09493c8df1ce9174ad01b381d4729a9d8d4e38670ca24774c"},
{file = "coverage-7.6.1-cp39-cp39-win_amd64.whl", hash = "sha256:06a737c882bd26d0d6ee7269b20b12f14a8704807a01056c80bb881a4b2ce6ca"},
{file = "coverage-7.6.1-pp38.pp39.pp310-none-any.whl", hash = "sha256:e9a6e0eb86070e8ccaedfbd9d38fec54864f3125ab95419970575b42af7541df"},
{file = "coverage-7.6.1.tar.gz", hash = "sha256:953510dfb7b12ab69d20135a0662397f077c59b1e6379a768e97c59d852ee51d"},
]
[package.extras]
toml = ["tomli"]
[[package]]
name = "exceptiongroup"
version = "1.2.2"
description = "Backport of PEP 654 (exception groups)"
optional = false
python-versions = ">=3.7"
files = [
{file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"},
{file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"},
]
[package.extras]
test = ["pytest (>=6)"]
[[package]]
name = "httpretty"
version = "1.1.4"
description = "HTTP client mock for Python"
optional = false
python-versions = ">=3"
files = [
{file = "httpretty-1.1.4.tar.gz", hash = "sha256:20de0e5dd5a18292d36d928cc3d6e52f8b2ac73daec40d41eb62dee154933b68"},
]
[[package]]
name = "idna"
version = "3.10"
description = "Internationalized Domain Names in Applications (IDNA)"
optional = false
python-versions = ">=3.6"
files = [
{file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"},
{file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"},
]
[package.extras]
all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"]
[[package]]
name = "iniconfig"
version = "2.0.0"
description = "brain-dead simple config-ini parsing"
optional = false
python-versions = ">=3.7"
files = [
{file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"},
{file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"},
]
[[package]]
name = "mock"
version = "5.1.0"
description = "Rolling backport of unittest.mock for all Pythons"
optional = false
python-versions = ">=3.6"
files = [
{file = "mock-5.1.0-py3-none-any.whl", hash = "sha256:18c694e5ae8a208cdb3d2c20a993ca1a7b0efa258c247a1e565150f477f83744"},
{file = "mock-5.1.0.tar.gz", hash = "sha256:5e96aad5ccda4718e0a229ed94b2024df75cc2d55575ba5762d31f5767b8767d"},
]
[package.extras]
build = ["blurb", "twine", "wheel"]
docs = ["sphinx"]
test = ["pytest", "pytest-cov"]
[[package]]
name = "packaging"
version = "24.2"
description = "Core utilities for Python packages"
optional = false
python-versions = ">=3.8"
files = [
{file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"},
{file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"},
]
[[package]]
name = "pluggy"
version = "1.5.0"
description = "plugin and hook calling mechanisms for python"
optional = false
python-versions = ">=3.8"
files = [
{file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"},
{file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"},
]
[package.extras]
dev = ["pre-commit", "tox"]
testing = ["pytest", "pytest-benchmark"]
[[package]]
name = "pytest"
version = "8.3.3"
description = "pytest: simple powerful testing with Python"
optional = false
python-versions = ">=3.8"
files = [
{file = "pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2"},
{file = "pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181"},
]
[package.dependencies]
colorama = {version = "*", markers = "sys_platform == \"win32\""}
exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""}
iniconfig = "*"
packaging = "*"
pluggy = ">=1.5,<2"
tomli = {version = ">=1", markers = "python_version < \"3.11\""}
[package.extras]
dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"]
[[package]]
name = "requests"
version = "2.32.3"
description = "Python HTTP for Humans."
optional = false
python-versions = ">=3.8"
files = [
{file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"},
{file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"},
]
[package.dependencies]
certifi = ">=2017.4.17"
charset-normalizer = ">=2,<4"
idna = ">=2.5,<4"
urllib3 = ">=1.21.1,<3"
[package.extras]
socks = ["PySocks (>=1.5.6,!=1.5.7)"]
use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
[[package]]
name = "ruff"
version = "0.6.9"
description = "An extremely fast Python linter and code formatter, written in Rust."
optional = false
python-versions = ">=3.7"
files = [
{file = "ruff-0.6.9-py3-none-linux_armv6l.whl", hash = "sha256:064df58d84ccc0ac0fcd63bc3090b251d90e2a372558c0f057c3f75ed73e1ccd"},
{file = "ruff-0.6.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:140d4b5c9f5fc7a7b074908a78ab8d384dd7f6510402267bc76c37195c02a7ec"},
{file = "ruff-0.6.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:53fd8ca5e82bdee8da7f506d7b03a261f24cd43d090ea9db9a1dc59d9313914c"},
{file = "ruff-0.6.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:645d7d8761f915e48a00d4ecc3686969761df69fb561dd914a773c1a8266e14e"},
{file = "ruff-0.6.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eae02b700763e3847595b9d2891488989cac00214da7f845f4bcf2989007d577"},
{file = "ruff-0.6.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7d5ccc9e58112441de8ad4b29dcb7a86dc25c5f770e3c06a9d57e0e5eba48829"},
{file = "ruff-0.6.9-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:417b81aa1c9b60b2f8edc463c58363075412866ae4e2b9ab0f690dc1e87ac1b5"},
{file = "ruff-0.6.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3c866b631f5fbce896a74a6e4383407ba7507b815ccc52bcedabb6810fdb3ef7"},
{file = "ruff-0.6.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7b118afbb3202f5911486ad52da86d1d52305b59e7ef2031cea3425142b97d6f"},
{file = "ruff-0.6.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a67267654edc23c97335586774790cde402fb6bbdb3c2314f1fc087dee320bfa"},
{file = "ruff-0.6.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3ef0cc774b00fec123f635ce5c547dac263f6ee9fb9cc83437c5904183b55ceb"},
{file = "ruff-0.6.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:12edd2af0c60fa61ff31cefb90aef4288ac4d372b4962c2864aeea3a1a2460c0"},
{file = "ruff-0.6.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:55bb01caeaf3a60b2b2bba07308a02fca6ab56233302406ed5245180a05c5625"},
{file = "ruff-0.6.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:925d26471fa24b0ce5a6cdfab1bb526fb4159952385f386bdcc643813d472039"},
{file = "ruff-0.6.9-py3-none-win32.whl", hash = "sha256:eb61ec9bdb2506cffd492e05ac40e5bc6284873aceb605503d8494180d6fc84d"},
{file = "ruff-0.6.9-py3-none-win_amd64.whl", hash = "sha256:785d31851c1ae91f45b3d8fe23b8ae4b5170089021fbb42402d811135f0b7117"},
{file = "ruff-0.6.9-py3-none-win_arm64.whl", hash = "sha256:a9641e31476d601f83cd602608739a0840e348bda93fec9f1ee816f8b6798b93"},
{file = "ruff-0.6.9.tar.gz", hash = "sha256:b076ef717a8e5bc819514ee1d602bbdca5b4420ae13a9cf61a0c0a4f53a2baa2"},
]
[[package]]
name = "tomli"
version = "2.0.2"
description = "A lil' TOML parser"
optional = false
python-versions = ">=3.8"
files = [
{file = "tomli-2.0.2-py3-none-any.whl", hash = "sha256:2ebe24485c53d303f690b0ec092806a085f07af5a5aa1464f3931eec36caaa38"},
{file = "tomli-2.0.2.tar.gz", hash = "sha256:d46d457a85337051c36524bc5349dd91b1877838e2979ac5ced3e710ed8a60ed"},
]
[[package]]
name = "urllib3"
version = "2.2.3"
description = "HTTP library with thread-safe connection pooling, file post, and more."
optional = false
python-versions = ">=3.8"
files = [
{file = "urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac"},
{file = "urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9"},
]
[package.extras]
brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"]
h2 = ["h2 (>=4,<5)"]
socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
zstd = ["zstandard (>=0.18.0)"]
[metadata]
lock-version = "2.0"
python-versions = ">=3.8,<3.14"
content-hash = "370c5c5f94f6000e0fdb76190a3aabd5acadf804802ca70dba41787d306799b4"

94
pyproject.toml Normal file
View File

@ -0,0 +1,94 @@
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
[tool.poetry]
name = "youtube-transcript-api"
version = "0.6.2"
description = "This is an python API which allows you to get the transcripts/subtitles for a given YouTube video. It also works for automatically generated subtitles, supports translating subtitles and it does not require a headless browser, like other selenium based solutions do!"
readme = "README.md"
license = "MIT"
authors = [
"Jonas Depoix <jonas.depoix@web.de>",
]
homepage = "https://github.com/jdepoix/youtube-transcript-api"
repository = "https://github.com/jdepoix/youtube-transcript-api"
keywords = [
"cli",
"subtitle",
"subtitles",
"transcript",
"transcripts",
"youtube",
"youtube-api",
"youtube-subtitles",
"youtube-transcripts",
]
classifiers = [
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
]
[tool.poetry.scripts]
youtube_transcript_api = "youtube_transcript_api.__main__:main"
[tool.poe.tasks]
test = "pytest youtube_transcript_api"
ci-test.shell = "coverage run -m unittest discover && coverage xml"
coverage.shell = "coverage run -m unittest discover && coverage report -m --fail-under=100"
format = "ruff format youtube_transcript_api"
ci-format = "ruff format youtube_transcript_api --check"
lint = "ruff check youtube_transcript_api"
precommit.shell = "poe format && poe lint && poe coverage"
[tool.poetry.dependencies]
python = ">=3.8,<3.14"
requests = "*"
[tool.poetry.group.test]
optional = true
[tool.poetry.group.test.dependencies]
pytest = "^8.3.3"
coverage = "^7.6.1"
mock = "^5.1.0"
httpretty = "^1.1.4"
[tool.poetry.group.dev]
optional = true
[tool.poetry.group.dev.dependencies]
ruff = "^0.6.8"
[tool.coverage.run]
source = ["youtube_transcript_api"]
[tool.coverage.report]
omit = ["*/__main__.py", "youtube_transcript_api/test/*"]
exclude_lines = [
"pragma: no cover",
# Don't complain about missing debug-only code:
"def __unicode__",
"def __repr__",
"if self\\.debug",
# Don't complain if tests don't hit defensive assertion code:
"raise AssertionError",
"raise NotImplementedError",
# Don't complain if non-runnable code isn't run:
"if 0:",
"if __name__ == .__main__.:",
# Don't complain about empty stubs of abstract methods
"@abstractmethod",
"@abstractclassmethod",
"@abstractstaticmethod"
]
show_missing = true

View File

@ -1,7 +0,0 @@
requests
# testing
mock==3.0.5
httpretty==1.1.4
coveralls==1.11.1
coverage==5.2.1

View File

@ -1,59 +0,0 @@
import os
import unittest
import setuptools
def _get_file_content(file_name):
with open(file_name, 'r') as file_handler:
return file_handler.read()
def get_long_description():
return _get_file_content('README.md')
def get_test_suite():
test_loader = unittest.TestLoader()
test_suite = test_loader.discover(
'test', pattern='test_*.py',
top_level_dir='{dirname}/youtube_transcript_api'.format(dirname=os.path.dirname(__file__))
)
return test_suite
setuptools.setup(
name="youtube_transcript_api",
version="0.6.2",
author="Jonas Depoix",
author_email="jonas.depoix@web.de",
description="This is an python API which allows you to get the transcripts/subtitles for a given YouTube video. It also works for automatically generated subtitles, supports translating subtitles and it does not require a headless browser, like other selenium based solutions do!",
long_description=get_long_description(),
long_description_content_type="text/markdown",
keywords="youtube-api subtitles youtube transcripts transcript subtitle youtube-subtitles youtube-transcripts cli",
url="https://github.com/jdepoix/youtube-transcript-api",
packages=setuptools.find_packages(),
classifiers=(
"Programming Language :: Python :: 3.5",
"Programming Language :: Python :: 3.6",
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
),
install_requires=[
'requests',
],
tests_require=[
'mock',
'httpretty',
'coverage',
'coveralls',
],
test_suite='setup.get_test_suite',
entry_points={
'console_scripts': [
'youtube_transcript_api = youtube_transcript_api.__main__:main',
],
},
)

View File

@ -1,3 +1,4 @@
# ruff: noqa: F401
from ._api import YouTubeTranscriptApi from ._api import YouTubeTranscriptApi
from ._transcripts import TranscriptList, Transcript from ._transcripts import TranscriptList, Transcript
from ._errors import ( from ._errors import (

View File

@ -11,5 +11,5 @@ def main():
print(YouTubeTranscriptCli(sys.argv[1:]).run()) print(YouTubeTranscriptCli(sys.argv[1:]).run())
if __name__ == '__main__': if __name__ == "__main__":
main() main()

View File

@ -1,17 +1,17 @@
import requests import requests
try: # pragma: no cover
try: # pragma: no cover
import http.cookiejar as cookiejar import http.cookiejar as cookiejar
CookieLoadError = (FileNotFoundError, cookiejar.LoadError) CookieLoadError = (FileNotFoundError, cookiejar.LoadError)
except ImportError: # pragma: no cover except ImportError: # pragma: no cover
import cookielib as cookiejar import cookielib as cookiejar
CookieLoadError = IOError CookieLoadError = IOError
from ._transcripts import TranscriptListFetcher from ._transcripts import TranscriptListFetcher
from ._errors import ( from ._errors import CookiePathInvalid, CookiesInvalid
CookiePathInvalid,
CookiesInvalid
)
class YouTubeTranscriptApi(object): class YouTubeTranscriptApi(object):
@ -71,8 +71,15 @@ class YouTubeTranscriptApi(object):
return TranscriptListFetcher(http_client).fetch(video_id) return TranscriptListFetcher(http_client).fetch(video_id)
@classmethod @classmethod
def get_transcripts(cls, video_ids, languages=('en',), continue_after_error=False, proxies=None, def get_transcripts(
cookies=None, preserve_formatting=False): cls,
video_ids,
languages=("en",),
continue_after_error=False,
proxies=None,
cookies=None,
preserve_formatting=False,
):
""" """
Retrieves the transcripts for a list of videos. Retrieves the transcripts for a list of videos.
@ -102,7 +109,9 @@ class YouTubeTranscriptApi(object):
for video_id in video_ids: for video_id in video_ids:
try: try:
data[video_id] = cls.get_transcript(video_id, languages, proxies, cookies, preserve_formatting) data[video_id] = cls.get_transcript(
video_id, languages, proxies, cookies, preserve_formatting
)
except Exception as exception: except Exception as exception:
if not continue_after_error: if not continue_after_error:
raise exception raise exception
@ -112,7 +121,14 @@ class YouTubeTranscriptApi(object):
return data, unretrievable_videos return data, unretrievable_videos
@classmethod @classmethod
def get_transcript(cls, video_id, languages=('en',), proxies=None, cookies=None, preserve_formatting=False): def get_transcript(
cls,
video_id,
languages=("en",),
proxies=None,
cookies=None,
preserve_formatting=False,
):
""" """
Retrieves the transcript for a single video. This is just a shortcut for calling:: Retrieves the transcript for a single video. This is just a shortcut for calling::
@ -134,7 +150,11 @@ class YouTubeTranscriptApi(object):
:rtype [{'text': str, 'start': float, 'end': float}]: :rtype [{'text': str, 'start': float, 'end': float}]:
""" """
assert isinstance(video_id, str), "`video_id` must be a string" assert isinstance(video_id, str), "`video_id` must be a string"
return cls.list_transcripts(video_id, proxies, cookies).find_transcript(languages).fetch(preserve_formatting=preserve_formatting) return (
cls.list_transcripts(video_id, proxies, cookies)
.find_transcript(languages)
.fetch(preserve_formatting=preserve_formatting)
)
@classmethod @classmethod
def _load_cookies(cls, cookies, video_id): def _load_cookies(cls, cookies, video_id):

View File

@ -13,10 +13,10 @@ class YouTubeTranscriptCli(object):
parsed_args = self._parse_args() parsed_args = self._parse_args()
if parsed_args.exclude_manually_created and parsed_args.exclude_generated: if parsed_args.exclude_manually_created and parsed_args.exclude_generated:
return '' return ""
proxies = None proxies = None
if parsed_args.http_proxy != '' or parsed_args.https_proxy != '': if parsed_args.http_proxy != "" or parsed_args.https_proxy != "":
proxies = {"http": parsed_args.http_proxy, "https": parsed_args.https_proxy} proxies = {"http": parsed_args.http_proxy, "https": parsed_args.https_proxy}
cookies = parsed_args.cookies cookies = parsed_args.cookies
@ -26,25 +26,41 @@ class YouTubeTranscriptCli(object):
for video_id in parsed_args.video_ids: for video_id in parsed_args.video_ids:
try: try:
transcripts.append(self._fetch_transcript(parsed_args, proxies, cookies, video_id)) transcripts.append(
self._fetch_transcript(parsed_args, proxies, cookies, video_id)
)
except Exception as exception: except Exception as exception:
exceptions.append(exception) exceptions.append(exception)
return '\n\n'.join( return "\n\n".join(
[str(exception) for exception in exceptions] [str(exception) for exception in exceptions]
+ ([FormatterLoader().load(parsed_args.format).format_transcripts(transcripts)] if transcripts else []) + (
[
FormatterLoader()
.load(parsed_args.format)
.format_transcripts(transcripts)
]
if transcripts
else []
)
) )
def _fetch_transcript(self, parsed_args, proxies, cookies, video_id): def _fetch_transcript(self, parsed_args, proxies, cookies, video_id):
transcript_list = YouTubeTranscriptApi.list_transcripts(video_id, proxies=proxies, cookies=cookies) transcript_list = YouTubeTranscriptApi.list_transcripts(
video_id, proxies=proxies, cookies=cookies
)
if parsed_args.list_transcripts: if parsed_args.list_transcripts:
return str(transcript_list) return str(transcript_list)
if parsed_args.exclude_manually_created: if parsed_args.exclude_manually_created:
transcript = transcript_list.find_generated_transcript(parsed_args.languages) transcript = transcript_list.find_generated_transcript(
parsed_args.languages
)
elif parsed_args.exclude_generated: elif parsed_args.exclude_generated:
transcript = transcript_list.find_manually_created_transcript(parsed_args.languages) transcript = transcript_list.find_manually_created_transcript(
parsed_args.languages
)
else: else:
transcript = transcript_list.find_transcript(parsed_args.languages) transcript = transcript_list.find_transcript(parsed_args.languages)
@ -56,80 +72,84 @@ class YouTubeTranscriptCli(object):
def _parse_args(self): def _parse_args(self):
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description=( description=(
'This is an python API which allows you to get the transcripts/subtitles for a given YouTube video. ' "This is an python API which allows you to get the transcripts/subtitles for a given YouTube video. "
'It also works for automatically generated subtitles and it does not require a headless browser, like ' "It also works for automatically generated subtitles and it does not require a headless browser, like "
'other selenium based solutions do!' "other selenium based solutions do!"
) )
) )
parser.add_argument( parser.add_argument(
'--list-transcripts', "--list-transcripts",
action='store_const', action="store_const",
const=True, const=True,
default=False, default=False,
help='This will list the languages in which the given videos are available in.', help="This will list the languages in which the given videos are available in.",
) )
parser.add_argument('video_ids', nargs='+', type=str, help='List of YouTube video IDs.')
parser.add_argument( parser.add_argument(
'--languages', "video_ids", nargs="+", type=str, help="List of YouTube video IDs."
nargs='*', )
default=['en',], parser.add_argument(
"--languages",
nargs="*",
default=[
"en",
],
type=str, type=str,
help=( help=(
'A list of language codes in a descending priority. For example, if this is set to "de en" it will ' 'A list of language codes in a descending priority. For example, if this is set to "de en" it will '
'first try to fetch the german transcript (de) and then fetch the english transcript (en) if it fails ' "first try to fetch the german transcript (de) and then fetch the english transcript (en) if it fails "
'to do so. As I can\'t provide a complete list of all working language codes with full certainty, you ' "to do so. As I can't provide a complete list of all working language codes with full certainty, you "
'may have to play around with the language codes a bit, to find the one which is working for you!' "may have to play around with the language codes a bit, to find the one which is working for you!"
), ),
) )
parser.add_argument( parser.add_argument(
'--exclude-generated', "--exclude-generated",
action='store_const', action="store_const",
const=True, const=True,
default=False, default=False,
help='If this flag is set transcripts which have been generated by YouTube will not be retrieved.', help="If this flag is set transcripts which have been generated by YouTube will not be retrieved.",
) )
parser.add_argument( parser.add_argument(
'--exclude-manually-created', "--exclude-manually-created",
action='store_const', action="store_const",
const=True, const=True,
default=False, default=False,
help='If this flag is set transcripts which have been manually created will not be retrieved.', help="If this flag is set transcripts which have been manually created will not be retrieved.",
) )
parser.add_argument( parser.add_argument(
'--format', "--format",
type=str, type=str,
default='pretty', default="pretty",
choices=tuple(FormatterLoader.TYPES.keys()), choices=tuple(FormatterLoader.TYPES.keys()),
) )
parser.add_argument( parser.add_argument(
'--translate', "--translate",
default='', default="",
help=( help=(
'The language code for the language you want this transcript to be translated to. Use the ' "The language code for the language you want this transcript to be translated to. Use the "
'--list-transcripts feature to find out which languages are translatable and which translation ' "--list-transcripts feature to find out which languages are translatable and which translation "
'languages are available.' "languages are available."
) ),
) )
parser.add_argument( parser.add_argument(
'--http-proxy', "--http-proxy",
default='', default="",
metavar='URL', metavar="URL",
help='Use the specified HTTP proxy.' help="Use the specified HTTP proxy.",
) )
parser.add_argument( parser.add_argument(
'--https-proxy', "--https-proxy",
default='', default="",
metavar='URL', metavar="URL",
help='Use the specified HTTPS proxy.' help="Use the specified HTTPS proxy.",
) )
parser.add_argument( parser.add_argument(
'--cookies', "--cookies",
default=None, default=None,
help='The cookie file that will be used for authorization with youtube.' help="The cookie file that will be used for authorization with youtube.",
) )
return self._sanitize_video_ids(parser.parse_args(self._args)) return self._sanitize_video_ids(parser.parse_args(self._args))
def _sanitize_video_ids(self, args): def _sanitize_video_ids(self, args):
args.video_ids = [video_id.replace('\\', '') for video_id in args.video_ids] args.video_ids = [video_id.replace("\\", "") for video_id in args.video_ids]
return args return args

View File

@ -5,16 +5,17 @@ class CouldNotRetrieveTranscript(Exception):
""" """
Raised if a transcript could not be retrieved. Raised if a transcript could not be retrieved.
""" """
ERROR_MESSAGE = '\nCould not retrieve a transcript for the video {video_url}!'
CAUSE_MESSAGE_INTRO = ' This is most likely caused by:\n\n{cause}' ERROR_MESSAGE = "\nCould not retrieve a transcript for the video {video_url}!"
CAUSE_MESSAGE = '' CAUSE_MESSAGE_INTRO = " This is most likely caused by:\n\n{cause}"
CAUSE_MESSAGE = ""
GITHUB_REFERRAL = ( GITHUB_REFERRAL = (
'\n\nIf you are sure that the described cause is not responsible for this error ' "\n\nIf you are sure that the described cause is not responsible for this error "
'and that a transcript should be retrievable, please create an issue at ' "and that a transcript should be retrievable, please create an issue at "
'https://github.com/jdepoix/youtube-transcript-api/issues. ' "https://github.com/jdepoix/youtube-transcript-api/issues. "
'Please add which version of youtube_transcript_api you are using ' "Please add which version of youtube_transcript_api you are using "
'and provide the information needed to replicate the error. ' "and provide the information needed to replicate the error. "
'Also make sure that there are no open issues which already describe your problem!' "Also make sure that there are no open issues which already describe your problem!"
) )
def __init__(self, video_id): def __init__(self, video_id):
@ -23,10 +24,14 @@ class CouldNotRetrieveTranscript(Exception):
def _build_error_message(self): def _build_error_message(self):
cause = self.cause cause = self.cause
error_message = self.ERROR_MESSAGE.format(video_url=WATCH_URL.format(video_id=self.video_id)) error_message = self.ERROR_MESSAGE.format(
video_url=WATCH_URL.format(video_id=self.video_id)
)
if cause: if cause:
error_message += self.CAUSE_MESSAGE_INTRO.format(cause=cause) + self.GITHUB_REFERRAL error_message += (
self.CAUSE_MESSAGE_INTRO.format(cause=cause) + self.GITHUB_REFERRAL
)
return error_message return error_message
@ -36,7 +41,7 @@ class CouldNotRetrieveTranscript(Exception):
class YouTubeRequestFailed(CouldNotRetrieveTranscript): class YouTubeRequestFailed(CouldNotRetrieveTranscript):
CAUSE_MESSAGE = 'Request to YouTube failed: {reason}' CAUSE_MESSAGE = "Request to YouTube failed: {reason}"
def __init__(self, video_id, http_error): def __init__(self, video_id, http_error):
self.reason = str(http_error) self.reason = str(http_error)
@ -50,12 +55,12 @@ class YouTubeRequestFailed(CouldNotRetrieveTranscript):
class VideoUnavailable(CouldNotRetrieveTranscript): class VideoUnavailable(CouldNotRetrieveTranscript):
CAUSE_MESSAGE = 'The video is no longer available' CAUSE_MESSAGE = "The video is no longer available"
class InvalidVideoId(CouldNotRetrieveTranscript): class InvalidVideoId(CouldNotRetrieveTranscript):
CAUSE_MESSAGE = ( CAUSE_MESSAGE = (
'You provided an invalid video id. Make sure you are using the video id and NOT the url!\n\n' "You provided an invalid video id. Make sure you are using the video id and NOT the url!\n\n"
'Do NOT run: `YouTubeTranscriptApi.get_transcript("https://www.youtube.com/watch?v=1234")`\n' 'Do NOT run: `YouTubeTranscriptApi.get_transcript("https://www.youtube.com/watch?v=1234")`\n'
'Instead run: `YouTubeTranscriptApi.get_transcript("1234")`' 'Instead run: `YouTubeTranscriptApi.get_transcript("1234")`'
) )
@ -63,48 +68,48 @@ class InvalidVideoId(CouldNotRetrieveTranscript):
class TooManyRequests(CouldNotRetrieveTranscript): class TooManyRequests(CouldNotRetrieveTranscript):
CAUSE_MESSAGE = ( CAUSE_MESSAGE = (
'YouTube is receiving too many requests from this IP and now requires solving a captcha to continue. ' "YouTube is receiving too many requests from this IP and now requires solving a captcha to continue. "
'One of the following things can be done to work around this:\n\ "One of the following things can be done to work around this:\n\
- Manually solve the captcha in a browser and export the cookie. ' - Manually solve the captcha in a browser and export the cookie. "
'Read here how to use that cookie with ' "Read here how to use that cookie with "
'youtube-transcript-api: https://github.com/jdepoix/youtube-transcript-api#cookies\n\ "youtube-transcript-api: https://github.com/jdepoix/youtube-transcript-api#cookies\n\
- Use a different IP address\n\ - Use a different IP address\n\
- Wait until the ban on your IP has been lifted' - Wait until the ban on your IP has been lifted"
) )
class TranscriptsDisabled(CouldNotRetrieveTranscript): class TranscriptsDisabled(CouldNotRetrieveTranscript):
CAUSE_MESSAGE = 'Subtitles are disabled for this video' CAUSE_MESSAGE = "Subtitles are disabled for this video"
class NoTranscriptAvailable(CouldNotRetrieveTranscript): class NoTranscriptAvailable(CouldNotRetrieveTranscript):
CAUSE_MESSAGE = 'No transcripts are available for this video' CAUSE_MESSAGE = "No transcripts are available for this video"
class NotTranslatable(CouldNotRetrieveTranscript): class NotTranslatable(CouldNotRetrieveTranscript):
CAUSE_MESSAGE = 'The requested language is not translatable' CAUSE_MESSAGE = "The requested language is not translatable"
class TranslationLanguageNotAvailable(CouldNotRetrieveTranscript): class TranslationLanguageNotAvailable(CouldNotRetrieveTranscript):
CAUSE_MESSAGE = 'The requested translation language is not available' CAUSE_MESSAGE = "The requested translation language is not available"
class CookiePathInvalid(CouldNotRetrieveTranscript): class CookiePathInvalid(CouldNotRetrieveTranscript):
CAUSE_MESSAGE = 'The provided cookie file was unable to be loaded' CAUSE_MESSAGE = "The provided cookie file was unable to be loaded"
class CookiesInvalid(CouldNotRetrieveTranscript): class CookiesInvalid(CouldNotRetrieveTranscript):
CAUSE_MESSAGE = 'The cookies provided are not valid (may have expired)' CAUSE_MESSAGE = "The cookies provided are not valid (may have expired)"
class FailedToCreateConsentCookie(CouldNotRetrieveTranscript): class FailedToCreateConsentCookie(CouldNotRetrieveTranscript):
CAUSE_MESSAGE = 'Failed to automatically give consent to saving cookies' CAUSE_MESSAGE = "Failed to automatically give consent to saving cookies"
class NoTranscriptFound(CouldNotRetrieveTranscript): class NoTranscriptFound(CouldNotRetrieveTranscript):
CAUSE_MESSAGE = ( CAUSE_MESSAGE = (
'No transcripts were found for any of the requested language codes: {requested_language_codes}\n\n' "No transcripts were found for any of the requested language codes: {requested_language_codes}\n\n"
'{transcript_data}' "{transcript_data}"
) )
def __init__(self, video_id, requested_language_codes, transcript_data): def __init__(self, video_id, requested_language_codes, transcript_data):

View File

@ -2,10 +2,10 @@ import sys
# This can only be tested by using different python versions, therefore it is not covered by coverage.py # This can only be tested by using different python versions, therefore it is not covered by coverage.py
if sys.version_info.major == 3 and sys.version_info.minor >= 4: # pragma: no cover if sys.version_info.major == 3 and sys.version_info.minor >= 4: # pragma: no cover
# Python 3.4+ # Python 3.4+
from html import unescape from html import unescape
else: # pragma: no cover else: # pragma: no cover
if sys.version_info.major <= 2: if sys.version_info.major <= 2:
# Python 2 # Python 2
import HTMLParser import HTMLParser

View File

@ -1 +1 @@
WATCH_URL = 'https://www.youtube.com/watch?v={video_id}' WATCH_URL = "https://www.youtube.com/watch?v={video_id}"

View File

@ -2,8 +2,9 @@ import sys
# This can only be tested by using different python versions, therefore it is not covered by coverage.py # This can only be tested by using different python versions, therefore it is not covered by coverage.py
if sys.version_info.major == 2: # pragma: no cover if sys.version_info.major == 2: # pragma: no cover
# ruff: noqa: F821
reload(sys) reload(sys)
sys.setdefaultencoding('utf-8') sys.setdefaultencoding("utf-8")
import json import json
@ -52,7 +53,7 @@ class TranscriptListFetcher(object):
splitted_html = html.split('"captions":') splitted_html = html.split('"captions":')
if len(splitted_html) <= 1: if len(splitted_html) <= 1:
if video_id.startswith('http://') or video_id.startswith('https://'): if video_id.startswith("http://") or video_id.startswith("https://"):
raise InvalidVideoId(video_id) raise InvalidVideoId(video_id)
if 'class="g-recaptcha"' in html: if 'class="g-recaptcha"' in html:
raise TooManyRequests(video_id) raise TooManyRequests(video_id)
@ -62,12 +63,12 @@ class TranscriptListFetcher(object):
raise TranscriptsDisabled(video_id) raise TranscriptsDisabled(video_id)
captions_json = json.loads( captions_json = json.loads(
splitted_html[1].split(',"videoDetails')[0].replace('\n', '') splitted_html[1].split(',"videoDetails')[0].replace("\n", "")
).get('playerCaptionsTracklistRenderer') ).get("playerCaptionsTracklistRenderer")
if captions_json is None: if captions_json is None:
raise TranscriptsDisabled(video_id) raise TranscriptsDisabled(video_id)
if 'captionTracks' not in captions_json: if "captionTracks" not in captions_json:
raise NoTranscriptAvailable(video_id) raise NoTranscriptAvailable(video_id)
return captions_json return captions_json
@ -76,7 +77,9 @@ class TranscriptListFetcher(object):
match = re.search('name="v" value="(.*?)"', html) match = re.search('name="v" value="(.*?)"', html)
if match is None: if match is None:
raise FailedToCreateConsentCookie(video_id) raise FailedToCreateConsentCookie(video_id)
self._http_client.cookies.set('CONSENT', 'YES+' + match.group(1), domain='.youtube.com') self._http_client.cookies.set(
"CONSENT", "YES+" + match.group(1), domain=".youtube.com"
)
def _fetch_video_html(self, video_id): def _fetch_video_html(self, video_id):
html = self._fetch_html(video_id) html = self._fetch_html(video_id)
@ -88,7 +91,9 @@ class TranscriptListFetcher(object):
return html return html
def _fetch_html(self, video_id): def _fetch_html(self, video_id):
response = self._http_client.get(WATCH_URL.format(video_id=video_id), headers={'Accept-Language': 'en-US'}) response = self._http_client.get(
WATCH_URL.format(video_id=video_id), headers={"Accept-Language": "en-US"}
)
return unescape(_raise_http_errors(response, video_id).text) return unescape(_raise_http_errors(response, video_id).text)
@ -98,7 +103,13 @@ class TranscriptList(object):
for a given YouTube video. Also it provides functionality to search for a transcript in a given language. for a given YouTube video. Also it provides functionality to search for a transcript in a given language.
""" """
def __init__(self, video_id, manually_created_transcripts, generated_transcripts, translation_languages): def __init__(
self,
video_id,
manually_created_transcripts,
generated_transcripts,
translation_languages,
):
""" """
The constructor is only for internal use. Use the static build method instead. The constructor is only for internal use. Use the static build method instead.
@ -132,28 +143,29 @@ class TranscriptList(object):
""" """
translation_languages = [ translation_languages = [
{ {
'language': translation_language['languageName']['simpleText'], "language": translation_language["languageName"]["simpleText"],
'language_code': translation_language['languageCode'], "language_code": translation_language["languageCode"],
} for translation_language in captions_json.get('translationLanguages', []) }
for translation_language in captions_json.get("translationLanguages", [])
] ]
manually_created_transcripts = {} manually_created_transcripts = {}
generated_transcripts = {} generated_transcripts = {}
for caption in captions_json['captionTracks']: for caption in captions_json["captionTracks"]:
if caption.get('kind', '') == 'asr': if caption.get("kind", "") == "asr":
transcript_dict = generated_transcripts transcript_dict = generated_transcripts
else: else:
transcript_dict = manually_created_transcripts transcript_dict = manually_created_transcripts
transcript_dict[caption['languageCode']] = Transcript( transcript_dict[caption["languageCode"]] = Transcript(
http_client, http_client,
video_id, video_id,
caption['baseUrl'], caption["baseUrl"],
caption['name']['simpleText'], caption["name"]["simpleText"],
caption['languageCode'], caption["languageCode"],
caption.get('kind', '') == 'asr', caption.get("kind", "") == "asr",
translation_languages if caption.get('isTranslatable', False) else [], translation_languages if caption.get("isTranslatable", False) else [],
) )
return TranscriptList( return TranscriptList(
@ -164,7 +176,10 @@ class TranscriptList(object):
) )
def __iter__(self): def __iter__(self):
return iter(list(self._manually_created_transcripts.values()) + list(self._generated_transcripts.values())) return iter(
list(self._manually_created_transcripts.values())
+ list(self._generated_transcripts.values())
)
def find_transcript(self, language_codes): def find_transcript(self, language_codes):
""" """
@ -180,7 +195,10 @@ class TranscriptList(object):
:rtype Transcript: :rtype Transcript:
:raises: NoTranscriptFound :raises: NoTranscriptFound
""" """
return self._find_transcript(language_codes, [self._manually_created_transcripts, self._generated_transcripts]) return self._find_transcript(
language_codes,
[self._manually_created_transcripts, self._generated_transcripts],
)
def find_generated_transcript(self, language_codes): def find_generated_transcript(self, language_codes):
""" """
@ -208,7 +226,9 @@ class TranscriptList(object):
:rtype Transcript: :rtype Transcript:
:raises: NoTranscriptFound :raises: NoTranscriptFound
""" """
return self._find_transcript(language_codes, [self._manually_created_transcripts]) return self._find_transcript(
language_codes, [self._manually_created_transcripts]
)
def _find_transcript(self, language_codes, transcript_dicts): def _find_transcript(self, language_codes, transcript_dicts):
for language_code in language_codes: for language_code in language_codes:
@ -216,44 +236,54 @@ class TranscriptList(object):
if language_code in transcript_dict: if language_code in transcript_dict:
return transcript_dict[language_code] return transcript_dict[language_code]
raise NoTranscriptFound( raise NoTranscriptFound(self.video_id, language_codes, self)
self.video_id,
language_codes,
self
)
def __str__(self): def __str__(self):
return ( return (
'For this video ({video_id}) transcripts are available in the following languages:\n\n' "For this video ({video_id}) transcripts are available in the following languages:\n\n"
'(MANUALLY CREATED)\n' "(MANUALLY CREATED)\n"
'{available_manually_created_transcript_languages}\n\n' "{available_manually_created_transcript_languages}\n\n"
'(GENERATED)\n' "(GENERATED)\n"
'{available_generated_transcripts}\n\n' "{available_generated_transcripts}\n\n"
'(TRANSLATION LANGUAGES)\n' "(TRANSLATION LANGUAGES)\n"
'{available_translation_languages}' "{available_translation_languages}"
).format( ).format(
video_id=self.video_id, video_id=self.video_id,
available_manually_created_transcript_languages=self._get_language_description( available_manually_created_transcript_languages=self._get_language_description(
str(transcript) for transcript in self._manually_created_transcripts.values() str(transcript)
for transcript in self._manually_created_transcripts.values()
), ),
available_generated_transcripts=self._get_language_description( available_generated_transcripts=self._get_language_description(
str(transcript) for transcript in self._generated_transcripts.values() str(transcript) for transcript in self._generated_transcripts.values()
), ),
available_translation_languages=self._get_language_description( available_translation_languages=self._get_language_description(
'{language_code} ("{language}")'.format( '{language_code} ("{language}")'.format(
language=translation_language['language'], language=translation_language["language"],
language_code=translation_language['language_code'], language_code=translation_language["language_code"],
) for translation_language in self._translation_languages )
) for translation_language in self._translation_languages
),
) )
def _get_language_description(self, transcript_strings): def _get_language_description(self, transcript_strings):
description = '\n'.join(' - {transcript}'.format(transcript=transcript) for transcript in transcript_strings) description = "\n".join(
return description if description else 'None' " - {transcript}".format(transcript=transcript)
for transcript in transcript_strings
)
return description if description else "None"
class Transcript(object): class Transcript(object):
def __init__(self, http_client, video_id, url, language, language_code, is_generated, translation_languages): def __init__(
self,
http_client,
video_id,
url,
language,
language_code,
is_generated,
translation_languages,
):
""" """
You probably don't want to initialize this directly. Usually you'll access Transcript objects using a You probably don't want to initialize this directly. Usually you'll access Transcript objects using a
TranscriptList. TranscriptList.
@ -276,7 +306,7 @@ class Transcript(object):
self.is_generated = is_generated self.is_generated = is_generated
self.translation_languages = translation_languages self.translation_languages = translation_languages
self._translation_languages_dict = { self._translation_languages_dict = {
translation_language['language_code']: translation_language['language'] translation_language["language_code"]: translation_language["language"]
for translation_language in translation_languages for translation_language in translation_languages
} }
@ -288,7 +318,9 @@ class Transcript(object):
:return: a list of dictionaries containing the 'text', 'start' and 'duration' keys :return: a list of dictionaries containing the 'text', 'start' and 'duration' keys
:rtype [{'text': str, 'start': float, 'end': float}]: :rtype [{'text': str, 'start': float, 'end': float}]:
""" """
response = self._http_client.get(self._url, headers={'Accept-Language': 'en-US'}) response = self._http_client.get(
self._url, headers={"Accept-Language": "en-US"}
)
return _TranscriptParser(preserve_formatting=preserve_formatting).parse( return _TranscriptParser(preserve_formatting=preserve_formatting).parse(
_raise_http_errors(response, self.video_id).text, _raise_http_errors(response, self.video_id).text,
) )
@ -297,7 +329,7 @@ class Transcript(object):
return '{language_code} ("{language}"){translation_description}'.format( return '{language_code} ("{language}"){translation_description}'.format(
language=self.language, language=self.language,
language_code=self.language_code, language_code=self.language_code,
translation_description='[TRANSLATABLE]' if self.is_translatable else '' translation_description="[TRANSLATABLE]" if self.is_translatable else "",
) )
@property @property
@ -314,7 +346,9 @@ class Transcript(object):
return Transcript( return Transcript(
self._http_client, self._http_client,
self.video_id, self.video_id,
'{url}&tlang={language_code}'.format(url=self._url, language_code=language_code), "{url}&tlang={language_code}".format(
url=self._url, language_code=language_code
),
self._translation_languages_dict[language_code], self._translation_languages_dict[language_code],
language_code, language_code,
True, True,
@ -324,16 +358,16 @@ class Transcript(object):
class _TranscriptParser(object): class _TranscriptParser(object):
_FORMATTING_TAGS = [ _FORMATTING_TAGS = [
'strong', # important "strong", # important
'em', # emphasized "em", # emphasized
'b', # bold "b", # bold
'i', # italic "i", # italic
'mark', # marked "mark", # marked
'small', # smaller "small", # smaller
'del', # deleted "del", # deleted
'ins', # inserted "ins", # inserted
'sub', # subscript "sub", # subscript
'sup', # superscript "sup", # superscript
] ]
def __init__(self, preserve_formatting=False): def __init__(self, preserve_formatting=False):
@ -341,19 +375,19 @@ class _TranscriptParser(object):
def _get_html_regex(self, preserve_formatting): def _get_html_regex(self, preserve_formatting):
if preserve_formatting: if preserve_formatting:
formats_regex = '|'.join(self._FORMATTING_TAGS) formats_regex = "|".join(self._FORMATTING_TAGS)
formats_regex = r'<\/?(?!\/?(' + formats_regex + r')\b).*?\b>' formats_regex = r"<\/?(?!\/?(" + formats_regex + r")\b).*?\b>"
html_regex = re.compile(formats_regex, re.IGNORECASE) html_regex = re.compile(formats_regex, re.IGNORECASE)
else: else:
html_regex = re.compile(r'<[^>]*>', re.IGNORECASE) html_regex = re.compile(r"<[^>]*>", re.IGNORECASE)
return html_regex return html_regex
def parse(self, plain_data): def parse(self, plain_data):
return [ return [
{ {
'text': re.sub(self._html_regex, '', unescape(xml_element.text)), "text": re.sub(self._html_regex, "", unescape(xml_element.text)),
'start': float(xml_element.attrib['start']), "start": float(xml_element.attrib["start"]),
'duration': float(xml_element.attrib.get('dur', '0.0')), "duration": float(xml_element.attrib.get("dur", "0.0")),
} }
for xml_element in ElementTree.fromstring(plain_data) for xml_element in ElementTree.fromstring(plain_data)
if xml_element.text is not None if xml_element.text is not None

View File

@ -12,12 +12,16 @@ class Formatter(object):
""" """
def format_transcript(self, transcript, **kwargs): def format_transcript(self, transcript, **kwargs):
raise NotImplementedError('A subclass of Formatter must implement ' \ raise NotImplementedError(
'their own .format_transcript() method.') "A subclass of Formatter must implement "
"their own .format_transcript() method."
)
def format_transcripts(self, transcripts, **kwargs): def format_transcripts(self, transcripts, **kwargs):
raise NotImplementedError('A subclass of Formatter must implement ' \ raise NotImplementedError(
'their own .format_transcripts() method.') "A subclass of Formatter must implement "
"their own .format_transcripts() method."
)
class PrettyPrintFormatter(Formatter): class PrettyPrintFormatter(Formatter):
@ -68,7 +72,7 @@ class TextFormatter(Formatter):
:return: all transcript text lines separated by newline breaks.' :return: all transcript text lines separated by newline breaks.'
:rtype str :rtype str
""" """
return '\n'.join(line['text'] for line in transcript) return "\n".join(line["text"] for line in transcript)
def format_transcripts(self, transcripts, **kwargs): def format_transcripts(self, transcripts, **kwargs):
"""Converts a list of transcripts into plain text with no timestamps. """Converts a list of transcripts into plain text with no timestamps.
@ -77,21 +81,30 @@ class TextFormatter(Formatter):
:return: all transcript text lines separated by newline breaks.' :return: all transcript text lines separated by newline breaks.'
:rtype str :rtype str
""" """
return '\n\n\n'.join([self.format_transcript(transcript, **kwargs) for transcript in transcripts]) return "\n\n\n".join(
[self.format_transcript(transcript, **kwargs) for transcript in transcripts]
)
class _TextBasedFormatter(TextFormatter): class _TextBasedFormatter(TextFormatter):
def _format_timestamp(self, hours, mins, secs, ms): def _format_timestamp(self, hours, mins, secs, ms):
raise NotImplementedError('A subclass of _TextBasedFormatter must implement ' \ raise NotImplementedError(
'their own .format_timestamp() method.') "A subclass of _TextBasedFormatter must implement "
"their own .format_timestamp() method."
)
def _format_transcript_header(self, lines): def _format_transcript_header(self, lines):
raise NotImplementedError('A subclass of _TextBasedFormatter must implement ' \ raise NotImplementedError(
'their own _format_transcript_header method.') "A subclass of _TextBasedFormatter must implement "
"their own _format_transcript_header method."
)
def _format_transcript_helper(self, i, time_text, line): def _format_transcript_helper(self, i, time_text, line):
raise NotImplementedError('A subclass of _TextBasedFormatter must implement ' \ raise NotImplementedError(
'their own _format_transcript_helper method.') "A subclass of _TextBasedFormatter must implement "
"their own _format_transcript_helper method."
)
def _seconds_to_timestamp(self, time): def _seconds_to_timestamp(self, time):
"""Helper that converts `time` into a transcript cue timestamp. """Helper that converts `time` into a transcript cue timestamp.
@ -109,26 +122,27 @@ class _TextBasedFormatter(TextFormatter):
hours_float, remainder = divmod(time, 3600) hours_float, remainder = divmod(time, 3600)
mins_float, secs_float = divmod(remainder, 60) mins_float, secs_float = divmod(remainder, 60)
hours, mins, secs = int(hours_float), int(mins_float), int(secs_float) hours, mins, secs = int(hours_float), int(mins_float), int(secs_float)
ms = int(round((time - int(time))*1000, 2)) ms = int(round((time - int(time)) * 1000, 2))
return self._format_timestamp(hours, mins, secs, ms) return self._format_timestamp(hours, mins, secs, ms)
def format_transcript(self, transcript, **kwargs): def format_transcript(self, transcript, **kwargs):
"""A basic implementation of WEBVTT/SRT formatting. """A basic implementation of WEBVTT/SRT formatting.
:param transcript: :param transcript:
:reference: :reference:
https://www.w3.org/TR/webvtt1/#introduction-caption https://www.w3.org/TR/webvtt1/#introduction-caption
https://www.3playmedia.com/blog/create-srt-file/ https://www.3playmedia.com/blog/create-srt-file/
""" """
lines = [] lines = []
for i, line in enumerate(transcript): for i, line in enumerate(transcript):
end = line['start'] + line['duration'] end = line["start"] + line["duration"]
time_text = "{} --> {}".format( time_text = "{} --> {}".format(
self._seconds_to_timestamp(line['start']), self._seconds_to_timestamp(line["start"]),
self._seconds_to_timestamp( self._seconds_to_timestamp(
transcript[i + 1]['start'] transcript[i + 1]["start"]
if i < len(transcript) - 1 and transcript[i + 1]['start'] < end else end if i < len(transcript) - 1 and transcript[i + 1]["start"] < end
) else end
),
) )
lines.append(self._format_transcript_helper(i, time_text, line)) lines.append(self._format_transcript_helper(i, time_text, line))
@ -138,12 +152,12 @@ class _TextBasedFormatter(TextFormatter):
class SRTFormatter(_TextBasedFormatter): class SRTFormatter(_TextBasedFormatter):
def _format_timestamp(self, hours, mins, secs, ms): def _format_timestamp(self, hours, mins, secs, ms):
return "{:02d}:{:02d}:{:02d},{:03d}".format(hours, mins, secs, ms) return "{:02d}:{:02d}:{:02d},{:03d}".format(hours, mins, secs, ms)
def _format_transcript_header(self, lines): def _format_transcript_header(self, lines):
return "\n\n".join(lines) + "\n" return "\n\n".join(lines) + "\n"
def _format_transcript_helper(self, i, time_text, line): def _format_transcript_helper(self, i, time_text, line):
return "{}\n{}\n{}".format(i + 1, time_text, line['text']) return "{}\n{}\n{}".format(i + 1, time_text, line["text"])
class WebVTTFormatter(_TextBasedFormatter): class WebVTTFormatter(_TextBasedFormatter):
@ -154,29 +168,29 @@ class WebVTTFormatter(_TextBasedFormatter):
return "WEBVTT\n\n" + "\n\n".join(lines) + "\n" return "WEBVTT\n\n" + "\n\n".join(lines) + "\n"
def _format_transcript_helper(self, i, time_text, line): def _format_transcript_helper(self, i, time_text, line):
return "{}\n{}".format(time_text, line['text']) return "{}\n{}".format(time_text, line["text"])
class FormatterLoader(object): class FormatterLoader(object):
TYPES = { TYPES = {
'json': JSONFormatter, "json": JSONFormatter,
'pretty': PrettyPrintFormatter, "pretty": PrettyPrintFormatter,
'text': TextFormatter, "text": TextFormatter,
'webvtt': WebVTTFormatter, "webvtt": WebVTTFormatter,
'srt' : SRTFormatter, "srt": SRTFormatter,
} }
class UnknownFormatterType(Exception): class UnknownFormatterType(Exception):
def __init__(self, formatter_type): def __init__(self, formatter_type):
super(FormatterLoader.UnknownFormatterType, self).__init__( super(FormatterLoader.UnknownFormatterType, self).__init__(
'The format \'{formatter_type}\' is not supported. ' "The format '{formatter_type}' is not supported. "
'Choose one of the following formats: {supported_formatter_types}'.format( "Choose one of the following formats: {supported_formatter_types}".format(
formatter_type=formatter_type, formatter_type=formatter_type,
supported_formatter_types=', '.join(FormatterLoader.TYPES.keys()), supported_formatter_types=", ".join(FormatterLoader.TYPES.keys()),
) )
) )
def load(self, formatter_type='pretty'): def load(self, formatter_type="pretty"):
""" """
Loads the Formatter for the given formatter type. Loads the Formatter for the given formatter type.

View File

@ -25,8 +25,9 @@ from youtube_transcript_api import (
def load_asset(filename): def load_asset(filename):
filepath = '{dirname}/assets/{filename}'.format( filepath = "{dirname}/assets/{filename}".format(
dirname=os.path.dirname(__file__), filename=filename) dirname=os.path.dirname(__file__), filename=filename
)
with open(filepath, mode="rb") as file: with open(filepath, mode="rb") as file:
return file.read() return file.read()
@ -37,13 +38,13 @@ class TestYouTubeTranscriptApi(TestCase):
httpretty.enable() httpretty.enable()
httpretty.register_uri( httpretty.register_uri(
httpretty.GET, httpretty.GET,
'https://www.youtube.com/watch', "https://www.youtube.com/watch",
body=load_asset('youtube.html.static') body=load_asset("youtube.html.static"),
) )
httpretty.register_uri( httpretty.register_uri(
httpretty.GET, httpretty.GET,
'https://www.youtube.com/api/timedtext', "https://www.youtube.com/api/timedtext",
body=load_asset('transcript.xml.static') body=load_asset("transcript.xml.static"),
) )
def tearDown(self): def tearDown(self):
@ -51,306 +52,362 @@ class TestYouTubeTranscriptApi(TestCase):
httpretty.disable() httpretty.disable()
def test_get_transcript(self): def test_get_transcript(self):
transcript = YouTubeTranscriptApi.get_transcript('GJLlxj_dtq8') transcript = YouTubeTranscriptApi.get_transcript("GJLlxj_dtq8")
self.assertEqual( self.assertEqual(
transcript, transcript,
[ [
{'text': 'Hey, this is just a test', 'start': 0.0, 'duration': 1.54}, {"text": "Hey, this is just a test", "start": 0.0, "duration": 1.54},
{'text': 'this is not the original transcript', 'start': 1.54, 'duration': 4.16}, {
{'text': 'just something shorter, I made up for testing', 'start': 5.7, 'duration': 3.239} "text": "this is not the original transcript",
] "start": 1.54,
"duration": 4.16,
},
{
"text": "just something shorter, I made up for testing",
"start": 5.7,
"duration": 3.239,
},
],
) )
def test_get_transcript_formatted(self): def test_get_transcript_formatted(self):
transcript = YouTubeTranscriptApi.get_transcript('GJLlxj_dtq8', preserve_formatting=True) transcript = YouTubeTranscriptApi.get_transcript(
"GJLlxj_dtq8", preserve_formatting=True
)
self.assertEqual( self.assertEqual(
transcript, transcript,
[ [
{'text': 'Hey, this is just a test', 'start': 0.0, 'duration': 1.54}, {"text": "Hey, this is just a test", "start": 0.0, "duration": 1.54},
{'text': 'this is <i>not</i> the original transcript', 'start': 1.54, 'duration': 4.16}, {
{'text': 'just something shorter, I made up for testing', 'start': 5.7, 'duration': 3.239} "text": "this is <i>not</i> the original transcript",
] "start": 1.54,
"duration": 4.16,
},
{
"text": "just something shorter, I made up for testing",
"start": 5.7,
"duration": 3.239,
},
],
) )
def test_list_transcripts(self): def test_list_transcripts(self):
transcript_list = YouTubeTranscriptApi.list_transcripts('GJLlxj_dtq8') transcript_list = YouTubeTranscriptApi.list_transcripts("GJLlxj_dtq8")
language_codes = {transcript.language_code for transcript in transcript_list} language_codes = {transcript.language_code for transcript in transcript_list}
self.assertEqual(language_codes, {'zh', 'de', 'en', 'hi', 'ja', 'ko', 'es', 'cs', 'en'}) self.assertEqual(
language_codes, {"zh", "de", "en", "hi", "ja", "ko", "es", "cs", "en"}
)
def test_list_transcripts__find_manually_created(self): def test_list_transcripts__find_manually_created(self):
transcript_list = YouTubeTranscriptApi.list_transcripts('GJLlxj_dtq8') transcript_list = YouTubeTranscriptApi.list_transcripts("GJLlxj_dtq8")
transcript = transcript_list.find_manually_created_transcript(['cs']) transcript = transcript_list.find_manually_created_transcript(["cs"])
self.assertFalse(transcript.is_generated) self.assertFalse(transcript.is_generated)
def test_list_transcripts__find_generated(self): def test_list_transcripts__find_generated(self):
transcript_list = YouTubeTranscriptApi.list_transcripts('GJLlxj_dtq8') transcript_list = YouTubeTranscriptApi.list_transcripts("GJLlxj_dtq8")
with self.assertRaises(NoTranscriptFound): with self.assertRaises(NoTranscriptFound):
transcript_list.find_generated_transcript(['cs']) transcript_list.find_generated_transcript(["cs"])
transcript = transcript_list.find_generated_transcript(['en']) transcript = transcript_list.find_generated_transcript(["en"])
self.assertTrue(transcript.is_generated) self.assertTrue(transcript.is_generated)
def test_list_transcripts__url_as_video_id(self): def test_list_transcripts__url_as_video_id(self):
httpretty.register_uri( httpretty.register_uri(
httpretty.GET, httpretty.GET,
'https://www.youtube.com/watch', "https://www.youtube.com/watch",
body=load_asset('youtube_transcripts_disabled.html.static') body=load_asset("youtube_transcripts_disabled.html.static"),
) )
with self.assertRaises(InvalidVideoId): with self.assertRaises(InvalidVideoId):
YouTubeTranscriptApi.list_transcripts('https://www.youtube.com/watch?v=GJLlxj_dtq8') YouTubeTranscriptApi.list_transcripts(
"https://www.youtube.com/watch?v=GJLlxj_dtq8"
)
def test_list_transcripts__no_translation_languages_provided(self): def test_list_transcripts__no_translation_languages_provided(self):
httpretty.register_uri( httpretty.register_uri(
httpretty.GET, httpretty.GET,
'https://www.youtube.com/watch', "https://www.youtube.com/watch",
body=load_asset('youtube_no_translation_languages.html.static') body=load_asset("youtube_no_translation_languages.html.static"),
) )
transcript_list = YouTubeTranscriptApi.list_transcripts('GJLlxj_dtq8') transcript_list = YouTubeTranscriptApi.list_transcripts("GJLlxj_dtq8")
for transcript in transcript_list: for transcript in transcript_list:
self.assertEqual(len(transcript.translation_languages), 0) self.assertEqual(len(transcript.translation_languages), 0)
def test_translate_transcript(self): def test_translate_transcript(self):
transcript = YouTubeTranscriptApi.list_transcripts('GJLlxj_dtq8').find_transcript(['en']) transcript = YouTubeTranscriptApi.list_transcripts(
"GJLlxj_dtq8"
).find_transcript(["en"])
translated_transcript = transcript.translate('af') translated_transcript = transcript.translate("af")
self.assertEqual(translated_transcript.language_code, 'af') self.assertEqual(translated_transcript.language_code, "af")
self.assertIn('&tlang=af', translated_transcript._url) self.assertIn("&tlang=af", translated_transcript._url)
def test_translate_transcript__translation_language_not_available(self): def test_translate_transcript__translation_language_not_available(self):
transcript = YouTubeTranscriptApi.list_transcripts('GJLlxj_dtq8').find_transcript(['en']) transcript = YouTubeTranscriptApi.list_transcripts(
"GJLlxj_dtq8"
).find_transcript(["en"])
with self.assertRaises(TranslationLanguageNotAvailable): with self.assertRaises(TranslationLanguageNotAvailable):
transcript.translate('xyz') transcript.translate("xyz")
def test_translate_transcript__not_translatable(self): def test_translate_transcript__not_translatable(self):
transcript = YouTubeTranscriptApi.list_transcripts('GJLlxj_dtq8').find_transcript(['en']) transcript = YouTubeTranscriptApi.list_transcripts(
"GJLlxj_dtq8"
).find_transcript(["en"])
transcript.translation_languages = [] transcript.translation_languages = []
with self.assertRaises(NotTranslatable): with self.assertRaises(NotTranslatable):
transcript.translate('af') transcript.translate("af")
def test_get_transcript__correct_language_is_used(self): def test_get_transcript__correct_language_is_used(self):
YouTubeTranscriptApi.get_transcript('GJLlxj_dtq8', ['de', 'en']) YouTubeTranscriptApi.get_transcript("GJLlxj_dtq8", ["de", "en"])
query_string = httpretty.last_request().querystring query_string = httpretty.last_request().querystring
self.assertIn('lang', query_string) self.assertIn("lang", query_string)
self.assertEqual(len(query_string['lang']), 1) self.assertEqual(len(query_string["lang"]), 1)
self.assertEqual(query_string['lang'][0], 'de') self.assertEqual(query_string["lang"][0], "de")
def test_get_transcript__fallback_language_is_used(self): def test_get_transcript__fallback_language_is_used(self):
httpretty.register_uri( httpretty.register_uri(
httpretty.GET, httpretty.GET,
'https://www.youtube.com/watch', "https://www.youtube.com/watch",
body=load_asset('youtube_ww1_nl_en.html.static') body=load_asset("youtube_ww1_nl_en.html.static"),
) )
YouTubeTranscriptApi.get_transcript('F1xioXWb8CY', ['de', 'en']) YouTubeTranscriptApi.get_transcript("F1xioXWb8CY", ["de", "en"])
query_string = httpretty.last_request().querystring query_string = httpretty.last_request().querystring
self.assertIn('lang', query_string) self.assertIn("lang", query_string)
self.assertEqual(len(query_string['lang']), 1) self.assertEqual(len(query_string["lang"]), 1)
self.assertEqual(query_string['lang'][0], 'en') self.assertEqual(query_string["lang"][0], "en")
def test_get_transcript__create_consent_cookie_if_needed(self): def test_get_transcript__create_consent_cookie_if_needed(self):
httpretty.register_uri( httpretty.register_uri(
httpretty.GET, httpretty.GET,
'https://www.youtube.com/watch', "https://www.youtube.com/watch",
body=load_asset('youtube_consent_page.html.static') body=load_asset("youtube_consent_page.html.static"),
) )
YouTubeTranscriptApi.get_transcript('F1xioXWb8CY') YouTubeTranscriptApi.get_transcript("F1xioXWb8CY")
self.assertEqual(len(httpretty.latest_requests()), 3) self.assertEqual(len(httpretty.latest_requests()), 3)
for request in httpretty.latest_requests()[1:]: for request in httpretty.latest_requests()[1:]:
self.assertEqual(request.headers['cookie'], 'CONSENT=YES+cb.20210328-17-p0.de+FX+119') self.assertEqual(
request.headers["cookie"], "CONSENT=YES+cb.20210328-17-p0.de+FX+119"
)
def test_get_transcript__exception_if_create_consent_cookie_failed(self): def test_get_transcript__exception_if_create_consent_cookie_failed(self):
httpretty.register_uri( httpretty.register_uri(
httpretty.GET, httpretty.GET,
'https://www.youtube.com/watch', "https://www.youtube.com/watch",
body=load_asset('youtube_consent_page.html.static') body=load_asset("youtube_consent_page.html.static"),
) )
httpretty.register_uri( httpretty.register_uri(
httpretty.GET, httpretty.GET,
'https://www.youtube.com/watch', "https://www.youtube.com/watch",
body=load_asset('youtube_consent_page.html.static') body=load_asset("youtube_consent_page.html.static"),
) )
with self.assertRaises(FailedToCreateConsentCookie): with self.assertRaises(FailedToCreateConsentCookie):
YouTubeTranscriptApi.get_transcript('F1xioXWb8CY') YouTubeTranscriptApi.get_transcript("F1xioXWb8CY")
def test_get_transcript__exception_if_consent_cookie_age_invalid(self): def test_get_transcript__exception_if_consent_cookie_age_invalid(self):
httpretty.register_uri( httpretty.register_uri(
httpretty.GET, httpretty.GET,
'https://www.youtube.com/watch', "https://www.youtube.com/watch",
body=load_asset('youtube_consent_page_invalid.html.static') body=load_asset("youtube_consent_page_invalid.html.static"),
) )
with self.assertRaises(FailedToCreateConsentCookie): with self.assertRaises(FailedToCreateConsentCookie):
YouTubeTranscriptApi.get_transcript('F1xioXWb8CY') YouTubeTranscriptApi.get_transcript("F1xioXWb8CY")
def test_get_transcript__exception_if_video_unavailable(self): def test_get_transcript__exception_if_video_unavailable(self):
httpretty.register_uri( httpretty.register_uri(
httpretty.GET, httpretty.GET,
'https://www.youtube.com/watch', "https://www.youtube.com/watch",
body=load_asset('youtube_video_unavailable.html.static') body=load_asset("youtube_video_unavailable.html.static"),
) )
with self.assertRaises(VideoUnavailable): with self.assertRaises(VideoUnavailable):
YouTubeTranscriptApi.get_transcript('abc') YouTubeTranscriptApi.get_transcript("abc")
def test_get_transcript__exception_if_youtube_request_fails(self): def test_get_transcript__exception_if_youtube_request_fails(self):
httpretty.register_uri( httpretty.register_uri(
httpretty.GET, httpretty.GET, "https://www.youtube.com/watch", status=500
'https://www.youtube.com/watch',
status=500
) )
with self.assertRaises(YouTubeRequestFailed): with self.assertRaises(YouTubeRequestFailed):
YouTubeTranscriptApi.get_transcript('abc') YouTubeTranscriptApi.get_transcript("abc")
def test_get_transcript__exception_if_youtube_request_limit_reached(self): def test_get_transcript__exception_if_youtube_request_limit_reached(self):
httpretty.register_uri( httpretty.register_uri(
httpretty.GET, httpretty.GET,
'https://www.youtube.com/watch', "https://www.youtube.com/watch",
body=load_asset('youtube_too_many_requests.html.static') body=load_asset("youtube_too_many_requests.html.static"),
) )
with self.assertRaises(TooManyRequests): with self.assertRaises(TooManyRequests):
YouTubeTranscriptApi.get_transcript('abc') YouTubeTranscriptApi.get_transcript("abc")
def test_get_transcript__exception_if_transcripts_disabled(self): def test_get_transcript__exception_if_transcripts_disabled(self):
httpretty.register_uri( httpretty.register_uri(
httpretty.GET, httpretty.GET,
'https://www.youtube.com/watch', "https://www.youtube.com/watch",
body=load_asset('youtube_transcripts_disabled.html.static') body=load_asset("youtube_transcripts_disabled.html.static"),
) )
with self.assertRaises(TranscriptsDisabled): with self.assertRaises(TranscriptsDisabled):
YouTubeTranscriptApi.get_transcript('dsMFmonKDD4') YouTubeTranscriptApi.get_transcript("dsMFmonKDD4")
httpretty.register_uri( httpretty.register_uri(
httpretty.GET, httpretty.GET,
'https://www.youtube.com/watch', "https://www.youtube.com/watch",
body=load_asset('youtube_transcripts_disabled2.html.static') body=load_asset("youtube_transcripts_disabled2.html.static"),
) )
with self.assertRaises(TranscriptsDisabled): with self.assertRaises(TranscriptsDisabled):
YouTubeTranscriptApi.get_transcript('Fjg5lYqvzUs') YouTubeTranscriptApi.get_transcript("Fjg5lYqvzUs")
def test_get_transcript__exception_if_language_unavailable(self): def test_get_transcript__exception_if_language_unavailable(self):
with self.assertRaises(NoTranscriptFound): with self.assertRaises(NoTranscriptFound):
YouTubeTranscriptApi.get_transcript('GJLlxj_dtq8', languages=['cz']) YouTubeTranscriptApi.get_transcript("GJLlxj_dtq8", languages=["cz"])
def test_get_transcript__exception_if_no_transcript_available(self): def test_get_transcript__exception_if_no_transcript_available(self):
httpretty.register_uri( httpretty.register_uri(
httpretty.GET, httpretty.GET,
'https://www.youtube.com/watch', "https://www.youtube.com/watch",
body=load_asset('youtube_no_transcript_available.html.static') body=load_asset("youtube_no_transcript_available.html.static"),
) )
with self.assertRaises(NoTranscriptAvailable): with self.assertRaises(NoTranscriptAvailable):
YouTubeTranscriptApi.get_transcript('MwBPvcYFY2E') YouTubeTranscriptApi.get_transcript("MwBPvcYFY2E")
def test_get_transcript__with_proxy(self): def test_get_transcript__with_proxy(self):
proxies = {'http': '', 'https:': ''} proxies = {"http": "", "https:": ""}
transcript = YouTubeTranscriptApi.get_transcript( transcript = YouTubeTranscriptApi.get_transcript("GJLlxj_dtq8", proxies=proxies)
'GJLlxj_dtq8', proxies=proxies
)
self.assertEqual( self.assertEqual(
transcript, transcript,
[ [
{'text': 'Hey, this is just a test', 'start': 0.0, 'duration': 1.54}, {"text": "Hey, this is just a test", "start": 0.0, "duration": 1.54},
{'text': 'this is not the original transcript', 'start': 1.54, 'duration': 4.16}, {
{'text': 'just something shorter, I made up for testing', 'start': 5.7, 'duration': 3.239} "text": "this is not the original transcript",
] "start": 1.54,
"duration": 4.16,
},
{
"text": "just something shorter, I made up for testing",
"start": 5.7,
"duration": 3.239,
},
],
) )
def test_get_transcript__with_cookies(self): def test_get_transcript__with_cookies(self):
dirname, filename = os.path.split(os.path.abspath(__file__)) dirname, filename = os.path.split(os.path.abspath(__file__))
cookies = dirname + '/example_cookies.txt' cookies = dirname + "/example_cookies.txt"
transcript = YouTubeTranscriptApi.get_transcript('GJLlxj_dtq8', cookies=cookies) transcript = YouTubeTranscriptApi.get_transcript("GJLlxj_dtq8", cookies=cookies)
self.assertEqual( self.assertEqual(
transcript, transcript,
[ [
{'text': 'Hey, this is just a test', 'start': 0.0, 'duration': 1.54}, {"text": "Hey, this is just a test", "start": 0.0, "duration": 1.54},
{'text': 'this is not the original transcript', 'start': 1.54, 'duration': 4.16}, {
{'text': 'just something shorter, I made up for testing', 'start': 5.7, 'duration': 3.239} "text": "this is not the original transcript",
] "start": 1.54,
"duration": 4.16,
},
{
"text": "just something shorter, I made up for testing",
"start": 5.7,
"duration": 3.239,
},
],
) )
def test_get_transcript__assertionerror_if_input_not_string(self): def test_get_transcript__assertionerror_if_input_not_string(self):
with self.assertRaises(AssertionError): with self.assertRaises(AssertionError):
YouTubeTranscriptApi.get_transcript(['video_id_1', 'video_id_2']) YouTubeTranscriptApi.get_transcript(["video_id_1", "video_id_2"])
def test_get_transcripts__assertionerror_if_input_not_list(self): def test_get_transcripts__assertionerror_if_input_not_list(self):
with self.assertRaises(AssertionError): with self.assertRaises(AssertionError):
YouTubeTranscriptApi.get_transcripts('video_id_1') YouTubeTranscriptApi.get_transcripts("video_id_1")
@patch('youtube_transcript_api.YouTubeTranscriptApi.get_transcript') @patch("youtube_transcript_api.YouTubeTranscriptApi.get_transcript")
def test_get_transcripts(self, mock_get_transcript): def test_get_transcripts(self, mock_get_transcript):
video_id_1 = 'video_id_1' video_id_1 = "video_id_1"
video_id_2 = 'video_id_2' video_id_2 = "video_id_2"
languages = ['de', 'en'] languages = ["de", "en"]
YouTubeTranscriptApi.get_transcripts([video_id_1, video_id_2], languages=languages) YouTubeTranscriptApi.get_transcripts(
[video_id_1, video_id_2], languages=languages
)
mock_get_transcript.assert_any_call(video_id_1, languages, None, None, False) mock_get_transcript.assert_any_call(video_id_1, languages, None, None, False)
mock_get_transcript.assert_any_call(video_id_2, languages, None, None, False) mock_get_transcript.assert_any_call(video_id_2, languages, None, None, False)
self.assertEqual(mock_get_transcript.call_count, 2) self.assertEqual(mock_get_transcript.call_count, 2)
@patch('youtube_transcript_api.YouTubeTranscriptApi.get_transcript', side_effect=Exception('Error')) @patch(
"youtube_transcript_api.YouTubeTranscriptApi.get_transcript",
side_effect=Exception("Error"),
)
def test_get_transcripts__stop_on_error(self, mock_get_transcript): def test_get_transcripts__stop_on_error(self, mock_get_transcript):
with self.assertRaises(Exception): with self.assertRaises(Exception):
YouTubeTranscriptApi.get_transcripts(['video_id_1', 'video_id_2']) YouTubeTranscriptApi.get_transcripts(["video_id_1", "video_id_2"])
@patch('youtube_transcript_api.YouTubeTranscriptApi.get_transcript', side_effect=Exception('Error')) @patch(
"youtube_transcript_api.YouTubeTranscriptApi.get_transcript",
side_effect=Exception("Error"),
)
def test_get_transcripts__continue_on_error(self, mock_get_transcript): def test_get_transcripts__continue_on_error(self, mock_get_transcript):
video_id_1 = 'video_id_1' video_id_1 = "video_id_1"
video_id_2 = 'video_id_2' video_id_2 = "video_id_2"
YouTubeTranscriptApi.get_transcripts(['video_id_1', 'video_id_2'], continue_after_error=True) YouTubeTranscriptApi.get_transcripts(
["video_id_1", "video_id_2"], continue_after_error=True
)
mock_get_transcript.assert_any_call(video_id_1, ('en',), None, None, False) mock_get_transcript.assert_any_call(video_id_1, ("en",), None, None, False)
mock_get_transcript.assert_any_call(video_id_2, ('en',), None, None, False) mock_get_transcript.assert_any_call(video_id_2, ("en",), None, None, False)
@patch('youtube_transcript_api.YouTubeTranscriptApi.get_transcript') @patch("youtube_transcript_api.YouTubeTranscriptApi.get_transcript")
def test_get_transcripts__with_cookies(self, mock_get_transcript): def test_get_transcripts__with_cookies(self, mock_get_transcript):
cookies = '/example_cookies.txt' cookies = "/example_cookies.txt"
YouTubeTranscriptApi.get_transcripts(['GJLlxj_dtq8'], cookies=cookies) YouTubeTranscriptApi.get_transcripts(["GJLlxj_dtq8"], cookies=cookies)
mock_get_transcript.assert_any_call('GJLlxj_dtq8', ('en',), None, cookies, False) mock_get_transcript.assert_any_call(
"GJLlxj_dtq8", ("en",), None, cookies, False
)
@patch('youtube_transcript_api.YouTubeTranscriptApi.get_transcript') @patch("youtube_transcript_api.YouTubeTranscriptApi.get_transcript")
def test_get_transcripts__with_proxies(self, mock_get_transcript): def test_get_transcripts__with_proxies(self, mock_get_transcript):
proxies = {'http': '', 'https:': ''} proxies = {"http": "", "https:": ""}
YouTubeTranscriptApi.get_transcripts(['GJLlxj_dtq8'], proxies=proxies) YouTubeTranscriptApi.get_transcripts(["GJLlxj_dtq8"], proxies=proxies)
mock_get_transcript.assert_any_call('GJLlxj_dtq8', ('en',), proxies, None, False) mock_get_transcript.assert_any_call(
"GJLlxj_dtq8", ("en",), proxies, None, False
)
def test_load_cookies(self): def test_load_cookies(self):
dirname, filename = os.path.split(os.path.abspath(__file__)) dirname, filename = os.path.split(os.path.abspath(__file__))
cookies = dirname + '/example_cookies.txt' cookies = dirname + "/example_cookies.txt"
session_cookies = YouTubeTranscriptApi._load_cookies(cookies, 'GJLlxj_dtq8') session_cookies = YouTubeTranscriptApi._load_cookies(cookies, "GJLlxj_dtq8")
self.assertEqual({'TEST_FIELD': 'TEST_VALUE'}, requests.utils.dict_from_cookiejar(session_cookies)) self.assertEqual(
{"TEST_FIELD": "TEST_VALUE"},
requests.utils.dict_from_cookiejar(session_cookies),
)
def test_load_cookies__bad_file_path(self): def test_load_cookies__bad_file_path(self):
bad_cookies = 'nonexistent_cookies.txt' bad_cookies = "nonexistent_cookies.txt"
with self.assertRaises(CookiePathInvalid): with self.assertRaises(CookiePathInvalid):
YouTubeTranscriptApi._load_cookies(bad_cookies, 'GJLlxj_dtq8') YouTubeTranscriptApi._load_cookies(bad_cookies, "GJLlxj_dtq8")
def test_load_cookies__no_valid_cookies(self): def test_load_cookies__no_valid_cookies(self):
dirname, filename = os.path.split(os.path.abspath(__file__)) dirname, filename = os.path.split(os.path.abspath(__file__))
expired_cookies = dirname + '/expired_example_cookies.txt' expired_cookies = dirname + "/expired_example_cookies.txt"
with self.assertRaises(CookiesInvalid): with self.assertRaises(CookiesInvalid):
YouTubeTranscriptApi._load_cookies(expired_cookies, 'GJLlxj_dtq8') YouTubeTranscriptApi._load_cookies(expired_cookies, "GJLlxj_dtq8")

View File

@ -10,211 +10,269 @@ from youtube_transcript_api._cli import YouTubeTranscriptCli
class TestYouTubeTranscriptCli(TestCase): class TestYouTubeTranscriptCli(TestCase):
def setUp(self): def setUp(self):
self.transcript_mock = MagicMock() self.transcript_mock = MagicMock()
self.transcript_mock.fetch = MagicMock(return_value=[ self.transcript_mock.fetch = MagicMock(
{'text': 'Hey, this is just a test', 'start': 0.0, 'duration': 1.54}, return_value=[
{'text': 'this is <i>not</i> the original transcript', 'start': 1.54, 'duration': 4.16}, {"text": "Hey, this is just a test", "start": 0.0, "duration": 1.54},
{'text': 'just something shorter, I made up for testing', 'start': 5.7, 'duration': 3.239} {
]) "text": "this is <i>not</i> the original transcript",
"start": 1.54,
"duration": 4.16,
},
{
"text": "just something shorter, I made up for testing",
"start": 5.7,
"duration": 3.239,
},
]
)
self.transcript_mock.translate = MagicMock(return_value=self.transcript_mock) self.transcript_mock.translate = MagicMock(return_value=self.transcript_mock)
self.transcript_list_mock = MagicMock() self.transcript_list_mock = MagicMock()
self.transcript_list_mock.find_generated_transcript = MagicMock(return_value=self.transcript_mock) self.transcript_list_mock.find_generated_transcript = MagicMock(
self.transcript_list_mock.find_manually_created_transcript = MagicMock(return_value=self.transcript_mock) return_value=self.transcript_mock
self.transcript_list_mock.find_transcript = MagicMock(return_value=self.transcript_mock) )
self.transcript_list_mock.find_manually_created_transcript = MagicMock(
return_value=self.transcript_mock
)
self.transcript_list_mock.find_transcript = MagicMock(
return_value=self.transcript_mock
)
YouTubeTranscriptApi.list_transcripts = MagicMock(return_value=self.transcript_list_mock) YouTubeTranscriptApi.list_transcripts = MagicMock(
return_value=self.transcript_list_mock
)
def test_argument_parsing(self): def test_argument_parsing(self):
parsed_args = YouTubeTranscriptCli('v1 v2 --format json --languages de en'.split())._parse_args() parsed_args = YouTubeTranscriptCli(
self.assertEqual(parsed_args.video_ids, ['v1', 'v2']) "v1 v2 --format json --languages de en".split()
self.assertEqual(parsed_args.format, 'json') )._parse_args()
self.assertEqual(parsed_args.languages, ['de', 'en']) self.assertEqual(parsed_args.video_ids, ["v1", "v2"])
self.assertEqual(parsed_args.http_proxy, '') self.assertEqual(parsed_args.format, "json")
self.assertEqual(parsed_args.https_proxy, '') self.assertEqual(parsed_args.languages, ["de", "en"])
self.assertEqual(parsed_args.http_proxy, "")
parsed_args = YouTubeTranscriptCli('v1 v2 --languages de en --format json'.split())._parse_args() self.assertEqual(parsed_args.https_proxy, "")
self.assertEqual(parsed_args.video_ids, ['v1', 'v2'])
self.assertEqual(parsed_args.format, 'json')
self.assertEqual(parsed_args.languages, ['de', 'en'])
self.assertEqual(parsed_args.http_proxy, '')
self.assertEqual(parsed_args.https_proxy, '')
parsed_args = YouTubeTranscriptCli(' --format json v1 v2 --languages de en'.split())._parse_args()
self.assertEqual(parsed_args.video_ids, ['v1', 'v2'])
self.assertEqual(parsed_args.format, 'json')
self.assertEqual(parsed_args.languages, ['de', 'en'])
self.assertEqual(parsed_args.http_proxy, '')
self.assertEqual(parsed_args.https_proxy, '')
parsed_args = YouTubeTranscriptCli( parsed_args = YouTubeTranscriptCli(
'v1 v2 --languages de en --format json ' "v1 v2 --languages de en --format json".split()
'--http-proxy http://user:pass@domain:port '
'--https-proxy https://user:pass@domain:port'.split()
)._parse_args() )._parse_args()
self.assertEqual(parsed_args.video_ids, ['v1', 'v2']) self.assertEqual(parsed_args.video_ids, ["v1", "v2"])
self.assertEqual(parsed_args.format, 'json') self.assertEqual(parsed_args.format, "json")
self.assertEqual(parsed_args.languages, ['de', 'en']) self.assertEqual(parsed_args.languages, ["de", "en"])
self.assertEqual(parsed_args.http_proxy, 'http://user:pass@domain:port') self.assertEqual(parsed_args.http_proxy, "")
self.assertEqual(parsed_args.https_proxy, 'https://user:pass@domain:port') self.assertEqual(parsed_args.https_proxy, "")
parsed_args = YouTubeTranscriptCli( parsed_args = YouTubeTranscriptCli(
'v1 v2 --languages de en --format json --http-proxy http://user:pass@domain:port'.split() " --format json v1 v2 --languages de en".split()
)._parse_args() )._parse_args()
self.assertEqual(parsed_args.video_ids, ['v1', 'v2']) self.assertEqual(parsed_args.video_ids, ["v1", "v2"])
self.assertEqual(parsed_args.format, 'json') self.assertEqual(parsed_args.format, "json")
self.assertEqual(parsed_args.languages, ['de', 'en']) self.assertEqual(parsed_args.languages, ["de", "en"])
self.assertEqual(parsed_args.http_proxy, 'http://user:pass@domain:port') self.assertEqual(parsed_args.http_proxy, "")
self.assertEqual(parsed_args.https_proxy, '') self.assertEqual(parsed_args.https_proxy, "")
parsed_args = YouTubeTranscriptCli( parsed_args = YouTubeTranscriptCli(
'v1 v2 --languages de en --format json --https-proxy https://user:pass@domain:port'.split() "v1 v2 --languages de en --format json "
"--http-proxy http://user:pass@domain:port "
"--https-proxy https://user:pass@domain:port".split()
)._parse_args() )._parse_args()
self.assertEqual(parsed_args.video_ids, ['v1', 'v2']) self.assertEqual(parsed_args.video_ids, ["v1", "v2"])
self.assertEqual(parsed_args.format, 'json') self.assertEqual(parsed_args.format, "json")
self.assertEqual(parsed_args.languages, ['de', 'en']) self.assertEqual(parsed_args.languages, ["de", "en"])
self.assertEqual(parsed_args.https_proxy, 'https://user:pass@domain:port') self.assertEqual(parsed_args.http_proxy, "http://user:pass@domain:port")
self.assertEqual(parsed_args.http_proxy, '') self.assertEqual(parsed_args.https_proxy, "https://user:pass@domain:port")
parsed_args = YouTubeTranscriptCli(
"v1 v2 --languages de en --format json --http-proxy http://user:pass@domain:port".split()
)._parse_args()
self.assertEqual(parsed_args.video_ids, ["v1", "v2"])
self.assertEqual(parsed_args.format, "json")
self.assertEqual(parsed_args.languages, ["de", "en"])
self.assertEqual(parsed_args.http_proxy, "http://user:pass@domain:port")
self.assertEqual(parsed_args.https_proxy, "")
parsed_args = YouTubeTranscriptCli(
"v1 v2 --languages de en --format json --https-proxy https://user:pass@domain:port".split()
)._parse_args()
self.assertEqual(parsed_args.video_ids, ["v1", "v2"])
self.assertEqual(parsed_args.format, "json")
self.assertEqual(parsed_args.languages, ["de", "en"])
self.assertEqual(parsed_args.https_proxy, "https://user:pass@domain:port")
self.assertEqual(parsed_args.http_proxy, "")
def test_argument_parsing__only_video_ids(self): def test_argument_parsing__only_video_ids(self):
parsed_args = YouTubeTranscriptCli('v1 v2'.split())._parse_args() parsed_args = YouTubeTranscriptCli("v1 v2".split())._parse_args()
self.assertEqual(parsed_args.video_ids, ['v1', 'v2']) self.assertEqual(parsed_args.video_ids, ["v1", "v2"])
self.assertEqual(parsed_args.format, 'pretty') self.assertEqual(parsed_args.format, "pretty")
self.assertEqual(parsed_args.languages, ['en']) self.assertEqual(parsed_args.languages, ["en"])
def test_argument_parsing__video_ids_starting_with_dash(self): def test_argument_parsing__video_ids_starting_with_dash(self):
parsed_args = YouTubeTranscriptCli('\-v1 \-\-v2 \--v3'.split())._parse_args() parsed_args = YouTubeTranscriptCli("\-v1 \-\-v2 \--v3".split())._parse_args()
self.assertEqual(parsed_args.video_ids, ['-v1', '--v2', '--v3']) self.assertEqual(parsed_args.video_ids, ["-v1", "--v2", "--v3"])
self.assertEqual(parsed_args.format, 'pretty') self.assertEqual(parsed_args.format, "pretty")
self.assertEqual(parsed_args.languages, ['en']) self.assertEqual(parsed_args.languages, ["en"])
def test_argument_parsing__fail_without_video_ids(self): def test_argument_parsing__fail_without_video_ids(self):
with self.assertRaises(SystemExit): with self.assertRaises(SystemExit):
YouTubeTranscriptCli('--format json'.split())._parse_args() YouTubeTranscriptCli("--format json".split())._parse_args()
def test_argument_parsing__json(self): def test_argument_parsing__json(self):
parsed_args = YouTubeTranscriptCli('v1 v2 --format json'.split())._parse_args() parsed_args = YouTubeTranscriptCli("v1 v2 --format json".split())._parse_args()
self.assertEqual(parsed_args.video_ids, ['v1', 'v2']) self.assertEqual(parsed_args.video_ids, ["v1", "v2"])
self.assertEqual(parsed_args.format, 'json') self.assertEqual(parsed_args.format, "json")
self.assertEqual(parsed_args.languages, ['en']) self.assertEqual(parsed_args.languages, ["en"])
parsed_args = YouTubeTranscriptCli('--format json v1 v2'.split())._parse_args() parsed_args = YouTubeTranscriptCli("--format json v1 v2".split())._parse_args()
self.assertEqual(parsed_args.video_ids, ['v1', 'v2']) self.assertEqual(parsed_args.video_ids, ["v1", "v2"])
self.assertEqual(parsed_args.format, 'json') self.assertEqual(parsed_args.format, "json")
self.assertEqual(parsed_args.languages, ['en']) self.assertEqual(parsed_args.languages, ["en"])
def test_argument_parsing__languages(self): def test_argument_parsing__languages(self):
parsed_args = YouTubeTranscriptCli('v1 v2 --languages de en'.split())._parse_args() parsed_args = YouTubeTranscriptCli(
self.assertEqual(parsed_args.video_ids, ['v1', 'v2']) "v1 v2 --languages de en".split()
self.assertEqual(parsed_args.format, 'pretty') )._parse_args()
self.assertEqual(parsed_args.languages, ['de', 'en']) self.assertEqual(parsed_args.video_ids, ["v1", "v2"])
self.assertEqual(parsed_args.format, "pretty")
self.assertEqual(parsed_args.languages, ["de", "en"])
def test_argument_parsing__proxies(self): def test_argument_parsing__proxies(self):
parsed_args = YouTubeTranscriptCli( parsed_args = YouTubeTranscriptCli(
'v1 v2 --http-proxy http://user:pass@domain:port'.split() "v1 v2 --http-proxy http://user:pass@domain:port".split()
)._parse_args() )._parse_args()
self.assertEqual(parsed_args.http_proxy, 'http://user:pass@domain:port') self.assertEqual(parsed_args.http_proxy, "http://user:pass@domain:port")
parsed_args = YouTubeTranscriptCli( parsed_args = YouTubeTranscriptCli(
'v1 v2 --https-proxy https://user:pass@domain:port'.split() "v1 v2 --https-proxy https://user:pass@domain:port".split()
)._parse_args() )._parse_args()
self.assertEqual(parsed_args.https_proxy, 'https://user:pass@domain:port') self.assertEqual(parsed_args.https_proxy, "https://user:pass@domain:port")
parsed_args = YouTubeTranscriptCli( parsed_args = YouTubeTranscriptCli(
'v1 v2 --http-proxy http://user:pass@domain:port --https-proxy https://user:pass@domain:port'.split() "v1 v2 --http-proxy http://user:pass@domain:port --https-proxy https://user:pass@domain:port".split()
)._parse_args() )._parse_args()
self.assertEqual(parsed_args.http_proxy, 'http://user:pass@domain:port') self.assertEqual(parsed_args.http_proxy, "http://user:pass@domain:port")
self.assertEqual(parsed_args.https_proxy, 'https://user:pass@domain:port') self.assertEqual(parsed_args.https_proxy, "https://user:pass@domain:port")
parsed_args = YouTubeTranscriptCli( parsed_args = YouTubeTranscriptCli("v1 v2".split())._parse_args()
'v1 v2'.split() self.assertEqual(parsed_args.http_proxy, "")
)._parse_args() self.assertEqual(parsed_args.https_proxy, "")
self.assertEqual(parsed_args.http_proxy, '')
self.assertEqual(parsed_args.https_proxy, '')
def test_argument_parsing__list_transcripts(self): def test_argument_parsing__list_transcripts(self):
parsed_args = YouTubeTranscriptCli('--list-transcripts v1 v2'.split())._parse_args() parsed_args = YouTubeTranscriptCli(
self.assertEqual(parsed_args.video_ids, ['v1', 'v2']) "--list-transcripts v1 v2".split()
)._parse_args()
self.assertEqual(parsed_args.video_ids, ["v1", "v2"])
self.assertTrue(parsed_args.list_transcripts) self.assertTrue(parsed_args.list_transcripts)
parsed_args = YouTubeTranscriptCli('v1 v2 --list-transcripts'.split())._parse_args() parsed_args = YouTubeTranscriptCli(
self.assertEqual(parsed_args.video_ids, ['v1', 'v2']) "v1 v2 --list-transcripts".split()
)._parse_args()
self.assertEqual(parsed_args.video_ids, ["v1", "v2"])
self.assertTrue(parsed_args.list_transcripts) self.assertTrue(parsed_args.list_transcripts)
def test_argument_parsing__translate(self): def test_argument_parsing__translate(self):
parsed_args = YouTubeTranscriptCli('v1 v2 --languages de en --translate cz'.split())._parse_args() parsed_args = YouTubeTranscriptCli(
self.assertEqual(parsed_args.video_ids, ['v1', 'v2']) "v1 v2 --languages de en --translate cz".split()
self.assertEqual(parsed_args.format, 'pretty') )._parse_args()
self.assertEqual(parsed_args.languages, ['de', 'en']) self.assertEqual(parsed_args.video_ids, ["v1", "v2"])
self.assertEqual(parsed_args.translate, 'cz') self.assertEqual(parsed_args.format, "pretty")
self.assertEqual(parsed_args.languages, ["de", "en"])
self.assertEqual(parsed_args.translate, "cz")
parsed_args = YouTubeTranscriptCli('v1 v2 --translate cz --languages de en'.split())._parse_args() parsed_args = YouTubeTranscriptCli(
self.assertEqual(parsed_args.video_ids, ['v1', 'v2']) "v1 v2 --translate cz --languages de en".split()
self.assertEqual(parsed_args.format, 'pretty') )._parse_args()
self.assertEqual(parsed_args.languages, ['de', 'en']) self.assertEqual(parsed_args.video_ids, ["v1", "v2"])
self.assertEqual(parsed_args.translate, 'cz') self.assertEqual(parsed_args.format, "pretty")
self.assertEqual(parsed_args.languages, ["de", "en"])
self.assertEqual(parsed_args.translate, "cz")
def test_argument_parsing__manually_or_generated(self): def test_argument_parsing__manually_or_generated(self):
parsed_args = YouTubeTranscriptCli('v1 v2 --exclude-manually-created'.split())._parse_args() parsed_args = YouTubeTranscriptCli(
self.assertEqual(parsed_args.video_ids, ['v1', 'v2']) "v1 v2 --exclude-manually-created".split()
)._parse_args()
self.assertEqual(parsed_args.video_ids, ["v1", "v2"])
self.assertTrue(parsed_args.exclude_manually_created) self.assertTrue(parsed_args.exclude_manually_created)
self.assertFalse(parsed_args.exclude_generated) self.assertFalse(parsed_args.exclude_generated)
parsed_args = YouTubeTranscriptCli('v1 v2 --exclude-generated'.split())._parse_args() parsed_args = YouTubeTranscriptCli(
self.assertEqual(parsed_args.video_ids, ['v1', 'v2']) "v1 v2 --exclude-generated".split()
)._parse_args()
self.assertEqual(parsed_args.video_ids, ["v1", "v2"])
self.assertFalse(parsed_args.exclude_manually_created) self.assertFalse(parsed_args.exclude_manually_created)
self.assertTrue(parsed_args.exclude_generated) self.assertTrue(parsed_args.exclude_generated)
parsed_args = YouTubeTranscriptCli('v1 v2 --exclude-manually-created --exclude-generated'.split())._parse_args() parsed_args = YouTubeTranscriptCli(
self.assertEqual(parsed_args.video_ids, ['v1', 'v2']) "v1 v2 --exclude-manually-created --exclude-generated".split()
)._parse_args()
self.assertEqual(parsed_args.video_ids, ["v1", "v2"])
self.assertTrue(parsed_args.exclude_manually_created) self.assertTrue(parsed_args.exclude_manually_created)
self.assertTrue(parsed_args.exclude_generated) self.assertTrue(parsed_args.exclude_generated)
def test_run(self): def test_run(self):
YouTubeTranscriptCli('v1 v2 --languages de en'.split()).run() YouTubeTranscriptCli("v1 v2 --languages de en".split()).run()
YouTubeTranscriptApi.list_transcripts.assert_any_call('v1', proxies=None, cookies=None) YouTubeTranscriptApi.list_transcripts.assert_any_call(
YouTubeTranscriptApi.list_transcripts.assert_any_call('v2', proxies=None, cookies=None) "v1", proxies=None, cookies=None
)
YouTubeTranscriptApi.list_transcripts.assert_any_call(
"v2", proxies=None, cookies=None
)
self.transcript_list_mock.find_transcript.assert_any_call(['de', 'en']) self.transcript_list_mock.find_transcript.assert_any_call(["de", "en"])
def test_run__failing_transcripts(self): def test_run__failing_transcripts(self):
YouTubeTranscriptApi.list_transcripts = MagicMock(side_effect=VideoUnavailable('video_id')) YouTubeTranscriptApi.list_transcripts = MagicMock(
side_effect=VideoUnavailable("video_id")
)
output = YouTubeTranscriptCli('v1 --languages de en'.split()).run() output = YouTubeTranscriptCli("v1 --languages de en".split()).run()
self.assertEqual(output, str(VideoUnavailable('video_id'))) self.assertEqual(output, str(VideoUnavailable("video_id")))
def test_run__exclude_generated(self): def test_run__exclude_generated(self):
YouTubeTranscriptCli('v1 v2 --languages de en --exclude-generated'.split()).run() YouTubeTranscriptCli(
"v1 v2 --languages de en --exclude-generated".split()
).run()
self.transcript_list_mock.find_manually_created_transcript.assert_any_call(['de', 'en']) self.transcript_list_mock.find_manually_created_transcript.assert_any_call(
["de", "en"]
)
def test_run__exclude_manually_created(self): def test_run__exclude_manually_created(self):
YouTubeTranscriptCli('v1 v2 --languages de en --exclude-manually-created'.split()).run() YouTubeTranscriptCli(
"v1 v2 --languages de en --exclude-manually-created".split()
).run()
self.transcript_list_mock.find_generated_transcript.assert_any_call(['de', 'en']) self.transcript_list_mock.find_generated_transcript.assert_any_call(
["de", "en"]
)
def test_run__exclude_manually_created_and_generated(self): def test_run__exclude_manually_created_and_generated(self):
self.assertEqual( self.assertEqual(
YouTubeTranscriptCli( YouTubeTranscriptCli(
'v1 v2 --languages de en --exclude-manually-created --exclude-generated'.split() "v1 v2 --languages de en --exclude-manually-created --exclude-generated".split()
).run(), ).run(),
'' "",
) )
def test_run__translate(self): def test_run__translate(self):
YouTubeTranscriptCli('v1 v2 --languages de en --translate cz'.split()).run(), (YouTubeTranscriptCli("v1 v2 --languages de en --translate cz".split()).run(),)
self.transcript_mock.translate.assert_any_call('cz') self.transcript_mock.translate.assert_any_call("cz")
def test_run__list_transcripts(self): def test_run__list_transcripts(self):
YouTubeTranscriptCli('--list-transcripts v1 v2'.split()).run() YouTubeTranscriptCli("--list-transcripts v1 v2".split()).run()
YouTubeTranscriptApi.list_transcripts.assert_any_call('v1', proxies=None, cookies=None) YouTubeTranscriptApi.list_transcripts.assert_any_call(
YouTubeTranscriptApi.list_transcripts.assert_any_call('v2', proxies=None, cookies=None) "v1", proxies=None, cookies=None
)
YouTubeTranscriptApi.list_transcripts.assert_any_call(
"v2", proxies=None, cookies=None
)
def test_run__json_output(self): def test_run__json_output(self):
output = YouTubeTranscriptCli('v1 v2 --languages de en --format json'.split()).run() output = YouTubeTranscriptCli(
"v1 v2 --languages de en --format json".split()
).run()
# will fail if output is not valid json # will fail if output is not valid json
json.loads(output) json.loads(output)
@ -222,31 +280,37 @@ class TestYouTubeTranscriptCli(TestCase):
def test_run__proxies(self): def test_run__proxies(self):
YouTubeTranscriptCli( YouTubeTranscriptCli(
( (
'v1 v2 --languages de en ' "v1 v2 --languages de en "
'--http-proxy http://user:pass@domain:port ' "--http-proxy http://user:pass@domain:port "
'--https-proxy https://user:pass@domain:port' "--https-proxy https://user:pass@domain:port"
).split() ).split()
).run() ).run()
YouTubeTranscriptApi.list_transcripts.assert_any_call( YouTubeTranscriptApi.list_transcripts.assert_any_call(
'v1', "v1",
proxies={'http': 'http://user:pass@domain:port', 'https': 'https://user:pass@domain:port'}, proxies={
cookies= None "http": "http://user:pass@domain:port",
"https": "https://user:pass@domain:port",
},
cookies=None,
) )
YouTubeTranscriptApi.list_transcripts.assert_any_call( YouTubeTranscriptApi.list_transcripts.assert_any_call(
'v2', "v2",
proxies={'http': 'http://user:pass@domain:port', 'https': 'https://user:pass@domain:port'}, proxies={
cookies=None "http": "http://user:pass@domain:port",
"https": "https://user:pass@domain:port",
},
cookies=None,
) )
def test_run__cookies(self): def test_run__cookies(self):
YouTubeTranscriptCli( YouTubeTranscriptCli(
( ("v1 v2 --languages de en " "--cookies blahblah.txt").split()
'v1 v2 --languages de en '
'--cookies blahblah.txt'
).split()
).run() ).run()
YouTubeTranscriptApi.list_transcripts.assert_any_call('v1', proxies=None, cookies='blahblah.txt') YouTubeTranscriptApi.list_transcripts.assert_any_call(
YouTubeTranscriptApi.list_transcripts.assert_any_call('v2', proxies=None, cookies='blahblah.txt') "v1", proxies=None, cookies="blahblah.txt"
)
YouTubeTranscriptApi.list_transcripts.assert_any_call(
"v2", proxies=None, cookies="blahblah.txt"
)

View File

@ -10,16 +10,17 @@ from youtube_transcript_api.formatters import (
TextFormatter, TextFormatter,
SRTFormatter, SRTFormatter,
WebVTTFormatter, WebVTTFormatter,
PrettyPrintFormatter, FormatterLoader PrettyPrintFormatter,
FormatterLoader,
) )
class TestFormatters(TestCase): class TestFormatters(TestCase):
def setUp(self): def setUp(self):
self.transcript = [ self.transcript = [
{'text': 'Test line 1', 'start': 0.0, 'duration': 1.50}, {"text": "Test line 1", "start": 0.0, "duration": 1.50},
{'text': 'line between', 'start': 1.5, 'duration': 2.0}, {"text": "line between", "start": 1.5, "duration": 2.0},
{'text': 'testing the end line', 'start': 2.5, 'duration': 3.25} {"text": "testing the end line", "start": 2.5, "duration": 3.25},
] ]
self.transcripts = [self.transcript, self.transcript] self.transcripts = [self.transcript, self.transcript]
@ -31,27 +32,27 @@ class TestFormatters(TestCase):
def test_srt_formatter_starting(self): def test_srt_formatter_starting(self):
content = SRTFormatter().format_transcript(self.transcript) content = SRTFormatter().format_transcript(self.transcript)
lines = content.split('\n') lines = content.split("\n")
# test starting lines # test starting lines
self.assertEqual(lines[0], "1") self.assertEqual(lines[0], "1")
self.assertEqual(lines[1], "00:00:00,000 --> 00:00:01,500") self.assertEqual(lines[1], "00:00:00,000 --> 00:00:01,500")
def test_srt_formatter_middle(self): def test_srt_formatter_middle(self):
content = SRTFormatter().format_transcript(self.transcript) content = SRTFormatter().format_transcript(self.transcript)
lines = content.split('\n') lines = content.split("\n")
# test middle lines # test middle lines
self.assertEqual(lines[4], "2") self.assertEqual(lines[4], "2")
self.assertEqual(lines[5], "00:00:01,500 --> 00:00:02,500") self.assertEqual(lines[5], "00:00:01,500 --> 00:00:02,500")
self.assertEqual(lines[6], self.transcript[1]['text']) self.assertEqual(lines[6], self.transcript[1]["text"])
def test_srt_formatter_ending(self): def test_srt_formatter_ending(self):
content = SRTFormatter().format_transcript(self.transcript) content = SRTFormatter().format_transcript(self.transcript)
lines = content.split('\n') lines = content.split("\n")
# test ending lines # test ending lines
self.assertEqual(lines[-2], self.transcript[-1]['text']) self.assertEqual(lines[-2], self.transcript[-1]["text"])
self.assertEqual(lines[-1], "") self.assertEqual(lines[-1], "")
def test_srt_formatter_many(self): def test_srt_formatter_many(self):
@ -59,22 +60,25 @@ class TestFormatters(TestCase):
content = formatter.format_transcripts(self.transcripts) content = formatter.format_transcripts(self.transcripts)
formatted_single_transcript = formatter.format_transcript(self.transcript) formatted_single_transcript = formatter.format_transcript(self.transcript)
self.assertEqual(content, formatted_single_transcript + '\n\n\n' + formatted_single_transcript) self.assertEqual(
content,
formatted_single_transcript + "\n\n\n" + formatted_single_transcript,
)
def test_webvtt_formatter_starting(self): def test_webvtt_formatter_starting(self):
content = WebVTTFormatter().format_transcript(self.transcript) content = WebVTTFormatter().format_transcript(self.transcript)
lines = content.split('\n') lines = content.split("\n")
# test starting lines # test starting lines
self.assertEqual(lines[0], "WEBVTT") self.assertEqual(lines[0], "WEBVTT")
self.assertEqual(lines[1], "") self.assertEqual(lines[1], "")
def test_webvtt_formatter_ending(self): def test_webvtt_formatter_ending(self):
content = WebVTTFormatter().format_transcript(self.transcript) content = WebVTTFormatter().format_transcript(self.transcript)
lines = content.split('\n') lines = content.split("\n")
# test ending lines # test ending lines
self.assertEqual(lines[-2], self.transcript[-1]['text']) self.assertEqual(lines[-2], self.transcript[-1]["text"])
self.assertEqual(lines[-1], "") self.assertEqual(lines[-1], "")
def test_webvtt_formatter_many(self): def test_webvtt_formatter_many(self):
@ -82,7 +86,10 @@ class TestFormatters(TestCase):
content = formatter.format_transcripts(self.transcripts) content = formatter.format_transcripts(self.transcripts)
formatted_single_transcript = formatter.format_transcript(self.transcript) formatted_single_transcript = formatter.format_transcript(self.transcript)
self.assertEqual(content, formatted_single_transcript + '\n\n\n' + formatted_single_transcript) self.assertEqual(
content,
formatted_single_transcript + "\n\n\n" + formatted_single_transcript,
)
def test_pretty_print_formatter(self): def test_pretty_print_formatter(self):
content = PrettyPrintFormatter().format_transcript(self.transcript) content = PrettyPrintFormatter().format_transcript(self.transcript)
@ -106,7 +113,7 @@ class TestFormatters(TestCase):
def test_text_formatter(self): def test_text_formatter(self):
content = TextFormatter().format_transcript(self.transcript) content = TextFormatter().format_transcript(self.transcript)
lines = content.split('\n') lines = content.split("\n")
self.assertEqual(lines[0], self.transcript[0]["text"]) self.assertEqual(lines[0], self.transcript[0]["text"])
self.assertEqual(lines[-1], self.transcript[-1]["text"]) self.assertEqual(lines[-1], self.transcript[-1]["text"])
@ -116,11 +123,14 @@ class TestFormatters(TestCase):
content = formatter.format_transcripts(self.transcripts) content = formatter.format_transcripts(self.transcripts)
formatted_single_transcript = formatter.format_transcript(self.transcript) formatted_single_transcript = formatter.format_transcript(self.transcript)
self.assertEqual(content, formatted_single_transcript + '\n\n\n' + formatted_single_transcript) self.assertEqual(
content,
formatted_single_transcript + "\n\n\n" + formatted_single_transcript,
)
def test_formatter_loader(self): def test_formatter_loader(self):
loader = FormatterLoader() loader = FormatterLoader()
formatter = loader.load('json') formatter = loader.load("json")
self.assertTrue(isinstance(formatter, JSONFormatter)) self.assertTrue(isinstance(formatter, JSONFormatter))
@ -132,4 +142,4 @@ class TestFormatters(TestCase):
def test_formatter_loader__unknown_format(self): def test_formatter_loader__unknown_format(self):
with self.assertRaises(FormatterLoader.UnknownFormatterType): with self.assertRaises(FormatterLoader.UnknownFormatterType):
FormatterLoader().load('png') FormatterLoader().load("png")