├── .dockerignore ├── .github ├── ISSUE_TEMPLATE │ └── issue-report.md └── workflows │ ├── build.yml │ └── release.yml ├── .gitignore ├── Dockerfile ├── LICENSE.md ├── README.md ├── docker-compose.yml ├── docs ├── banner.png ├── books.png ├── books3.png ├── captcha.gif ├── kobodl.png └── webss.png ├── kobodl ├── __init__.py ├── __main__.py ├── actions.py ├── app.py ├── commands │ ├── __init__.py │ ├── book.py │ └── user.py ├── debug.py ├── globals.py ├── kobo.py ├── koboDrmRemover.py ├── settings.py └── templates │ ├── activation_form.html │ ├── books.j2 │ ├── error.j2 │ ├── footer.html │ ├── header.html │ ├── success.j2 │ └── users.j2 ├── poetry.lock ├── pyproject.toml └── tox.ini /.dockerignore: -------------------------------------------------------------------------------- 1 | /.idea/ 2 | *.pyc 3 | *.egg-info/ 4 | .vscode 5 | .eggs/ 6 | .tox 7 | .mypy_cache 8 | .github 9 | Dockerfile 10 | venv/ 11 | kobo_downloads/ 12 | build/ 13 | dist/ 14 | docs/ 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/issue-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Issue report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Before you open an issue** 11 | Please make sure you've upgraded to the latest version of kobodl on [pypi](https://pypi.org/project/kobodl/) or [docker](https://hub.docker.com/r/subdavis/kobodl). Also read through the [troubleshooting guide](https://github.com/subdavis/kobo-book-downloader#troubleshooting). Try running with `--debug` enabled and see what comes up in the debug log. 12 | 13 | Do not erase this template, it's here to help you. 14 | 15 | **Describe the issue** 16 | A clear and concise description of what the bug is. 17 | 18 | **To Reproduce** 19 | Steps to reproduce the behavior: 20 | 1. Go to '...' 21 | 2. Click on '....' 22 | 3. Scroll down to '....' 23 | 4. See error 24 | 25 | **Expected behavior** 26 | A clear and concise description of what you expected to happen. 27 | 28 | **Screenshots** 29 | If applicable, add screenshots to help explain your problem. 30 | 31 | **Desktop (please complete the following information):** 32 | - OS: [e.g. MacOS] 33 | - Install method and python version: [eg. docker, python 3.9] 34 | - Kobodl Version [e.g. 0.6.0] 35 | 36 | **Additional context** 37 | Add any other context about the problem here. 38 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Publish Docker Container 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | publish: 10 | name: Publish Docker Container 11 | runs-on: ubuntu-latest 12 | steps: 13 | - 14 | name: Set up QEMU 15 | uses: docker/setup-qemu-action@v3 16 | - 17 | name: Set up Docker Buildx 18 | uses: docker/setup-buildx-action@v3 19 | - 20 | name: Login to GitHub Container Registry 21 | uses: docker/login-action@v1 22 | with: 23 | registry: ghcr.io 24 | username: ${{ github.actor }} 25 | password: ${{ secrets.GITHUB_TOKEN }} 26 | - 27 | name: Build and push server 28 | uses: docker/build-push-action@v6 29 | with: 30 | platforms: linux/amd64,linux/arm64 31 | tags: ghcr.io/subdavis/kobodl:latest 32 | push: true 33 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | publish: 9 | name: Publish to PyPI ✨ 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - uses: actions/setup-python@v1 14 | with: 15 | python-version: '3.11' 16 | architecture: 'x64' 17 | 18 | - run: pip install poetry twine 19 | - run: poetry install 20 | - run: poetry build 21 | - run: twine upload dist/* 22 | env: 23 | TWINE_USERNAME: __token__ 24 | TWINE_PASSWORD: ${{ secrets.TWINE_PASSWORD }} 25 | 26 | pyinstaller: 27 | name: Build for multiple platforms 28 | runs-on: ${{ matrix.os }} 29 | strategy: 30 | matrix: 31 | os: ["windows-latest", "macos-latest", "ubuntu-latest"] 32 | python-version: [3.11] 33 | include: 34 | - 35 | os: windows-latest 36 | name: windows 37 | - 38 | os: macos-latest 39 | name: macos 40 | - 41 | os: ubuntu-latest 42 | name: ubuntu 43 | 44 | steps: 45 | - 46 | uses: actions/checkout@v2 47 | - 48 | name: Set up Python ${{ matrix.python-version }} 49 | uses: actions/setup-python@v2 50 | with: 51 | python-version: ${{ matrix.python-version }} 52 | - 53 | name: Install tox 54 | run: | 55 | python -m pip install --upgrade pip; 56 | pip install tox; 57 | - 58 | run: tox -e buildcli -- --name kobodl-${{ matrix.name }} 59 | - 60 | name: Upload Release Asset 61 | uses: alexellis/upload-assets@0.3.0 62 | env: 63 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 64 | with: 65 | asset_paths: '["./dist/kobodl*"]' 66 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea/ 2 | *.pyc 3 | *.egg-info/ 4 | .eggs/ 5 | .vscode/ 6 | .python-version 7 | loginpage_error.html 8 | venv/ 9 | kobo_downloads/ 10 | build/ 11 | dist/ 12 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.9-alpine AS builder 2 | WORKDIR /opt/kobodl/src 3 | 4 | ENV PATH="/opt/kobodl/local/venv/bin:$PATH" 5 | ENV VIRTUAL_ENV="/opt/kobodl/local/venv" 6 | 7 | RUN apk add --no-cache gcc libc-dev libffi-dev 8 | ADD https://install.python-poetry.org /install-poetry.py 9 | RUN POETRY_VERSION=2.1.1 POETRY_HOME=/opt/kobodl/local python /install-poetry.py 10 | 11 | COPY . . 12 | 13 | RUN poetry env use system 14 | RUN poetry config virtualenvs.create false 15 | RUN poetry debug info 16 | RUN poetry install --without dev 17 | 18 | # Distributable Stage 19 | FROM python:3.9-alpine 20 | WORKDIR /opt/kobodl/src 21 | 22 | ENV PATH="/opt/kobodl/local/venv/bin:$PATH" 23 | 24 | RUN apk add --no-cache tini 25 | 26 | COPY --from=builder /opt/kobodl /opt/kobodl 27 | 28 | ENTRYPOINT ["/sbin/tini", "--", "kobodl"] 29 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Alternatives to kobodl 2 | 3 | I used to prefer `kobodl` because it's **standalone**, which means you don't need other proprietary software like Adobe Digial Editions or Kindle for PC (that I can't use on Linux). However, I have since discoverd a way to do this with [Calibre](https://github.com/kovidgoyal/calibre) and 2 plugins: 4 | 5 | * [Leseratte10/acsm-calibre-plugin](https://github.com/Leseratte10/acsm-calibre-plugin) - A plugin that can read Adobe Digital Editions files that Kobo web download produces. 6 | * [noDRM/DeDRM_tools](https://github.com/noDRM/DeDRM_tools) - The popular DRM removal plugin. 7 | 8 | Now you can just download the `.acm` file from your book list on Kobo.com and load it into Calibre desktop! 9 | 10 | It doesn't work with audiobooks and is a little harder to set up, but I think it's overall a better solution and I am not using kobodl personally much anymore. If anyone is interested in becoming a new maintainer of kobodl please let me know by opening a [discussion](https://github.com/subdavis/kobo-book-downloader/discussions). I will still try to keep it functioning as long as I can, and bug reports are still appreciated. 11 | 12 | --- 13 | 14 | ![kobodl logo](docs/kobodl.png) 15 | 16 | ![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/subdavis/kobo-book-downloader/build.yml?branch=main&style=for-the-badge) 17 | ![GitHub release (latest by date)](https://img.shields.io/github/v/release/subdavis/kobo-book-downloader?style=for-the-badge) 18 | ![PyPI - Downloads](https://img.shields.io/pypi/dm/kobodl?style=for-the-badge) 19 | ![PyPI - License](https://img.shields.io/pypi/l/kobodl?style=for-the-badge) 20 | ![PyPI](https://img.shields.io/pypi/v/kobodl?style=for-the-badge) 21 | 22 | # kobodl 23 | 24 | This is a hard fork of [kobo-book-downloader](https://github.com/TnS-hun/kobo-book-downloader), a command line tool to download and remove Digital Rights Management (DRM) protection from media legally purchased from [Rakuten Kobo](https://www.kobo.com/). The resulting [EPUB](https://en.wikipedia.org/wiki/EPUB) files can be read with, amongst others, [KOReader](https://github.com/koreader/koreader). 25 | 26 | > **NOTE:** You must have a kobo email login. See "I can't log in" in the troubleshooting section for how to workaround this requirement. 27 | 28 | ## Features 29 | 30 | kobodl preserves the features from [TnS-hun/kobo-book-downloader](https://github.com/TnS-hun/kobo-book-downloader). 31 | 32 | * stand-alone; no need to run other software or pre-download through an e-reader. 33 | * downloads `.epub` formatted books 34 | 35 | It adds several new features. 36 | 37 | * **audiobook support**; command-line only for now. 38 | * Use `kobodl book get`. There will not be a download button in the webpage for audiobooks because they consist of many large files. 39 | * **multi-user support**; fetch books for multiple accounts. 40 | * **web interface**; adds new browser gui (with flask) 41 | * [docker image](https://github.com/subdavis/kobodl/pkgs/container/kobodl) 42 | * [pypi package](https://pypi.org/project/kobodl/) 43 | * [pyinstaller bundles](https://github.com/subdavis/kobo-book-downloader/releases/latest) 44 | 45 | ## Web UI 46 | 47 | WebUI provides most of the same functions of the CLI. It was added to allow other members of a household to add their accounts to kobodl and access their books without having to set up python. 48 | 49 | ### User page 50 | 51 | ![Example of User page](docs/webss.png) 52 | 53 | ### Book list page 54 | 55 | ![Example of book list page](docs/books3.png) 56 | 57 | ## Installation 58 | 59 | ### pipx 60 | 61 | ``` bash 62 | pipx install kobodl 63 | ``` 64 | 65 | ### pip 66 | 67 | ``` bash 68 | pip install kobodl 69 | ``` 70 | 71 | ### Pre-built bundles 72 | 73 | No python installation necessary. Simply download the appropriate executable from [the latest release assets](https://github.com/subdavis/kobo-book-downloader/releases/latest) and run it from the command line. Pre-built bundles are CLI-only (no web server) so use a different install option if you want that feature. 74 | 75 | ``` bash 76 | # Linux 77 | wget https://github.com/subdavis/kobo-book-downloader/releases/latest/download/kobodl-ubuntu 78 | chmod +x kobodl-ubuntu 79 | ./kobodl-ubuntu 80 | ``` 81 | 82 | ``` bash 83 | # MacOS (Catalina 10.15 or newer required. For older versions, use the pip or docker install option) 84 | wget https://github.com/subdavis/kobo-book-downloader/releases/latest/download/kobodl-macos 85 | chmod +x kobodl-macos 86 | ./kobodl-macos 87 | ``` 88 | 89 | ``` powershell 90 | # Windows Powershell example 91 | wget "https://github.com/subdavis/kobo-book-downloader/releases/latest/download/kobodl-windows.exe" -outfile "kobodl.exe" 92 | ./kobodl.exe 93 | ``` 94 | 95 | ### docker 96 | 97 | > *Note*: for rootless docker installations (uncommon), omit the `--user` argument. 98 | 99 | ``` bash 100 | # list users 101 | docker run --rm -it --user $(id -u):$(id -g) \ 102 | -v ${HOME}/.config:/home/config \ 103 | ghcr.io/subdavis/kobodl \ 104 | --config /home/config/kobodl.json user list 105 | 106 | # run http server 107 | docker run --rm -it --user $(id -u):$(id -g) \ 108 | -p 5000:5000 \ 109 | -v ${HOME}/.config:/home/config \ 110 | -v ${PWD}:/home/downloads \ 111 | ghcr.io/subdavis/kobodl \ 112 | --config /home/config/kobodl.json \ 113 | serve \ 114 | --host 0.0.0.0 \ 115 | --output-dir /home/downloads/kobodl_downloads 116 | ``` 117 | 118 | [Also see the **docker-compose** example file.](./docker-compose.yml) 119 | 120 | ## Usage 121 | 122 | General usage documentation. 123 | 124 | > **Note**: These are commands you type into a shell prompt like Terminal (Ubuntu, MacOS) or Powershell or CMD (Windows). You may need to replace `kobodl` with `./kobodl.exe`, `./kobodl-macos-latest`, or something else, depending on which installation method you chose. 125 | 126 | ``` bash 127 | # Get started by adding one or more users 128 | kobodl user add 129 | 130 | # List users 131 | kobodl user list 132 | 133 | # Remove a user 134 | kobodl user rm email@domain.com 135 | 136 | # List books 137 | kobodl book list 138 | 139 | # List books for a single user 140 | kobodl book list --user email@domain.com 141 | 142 | # List all books, including those marked as read 143 | kobodl book list --read 144 | 145 | # Show book list help 146 | kobodl book list --help 147 | 148 | # Download a single book with default options when only 1 user exists 149 | # default output directory is `./kobo_downloads` 150 | kobodl book get c1db3f5c-82da-4dda-9d81-fa718d5d1d16 151 | 152 | # Download a single book with advanced options 153 | kobodl book get \ 154 | --user email@domain.com \ 155 | --output-dir /path/to/download_directory \ 156 | --format-str '{Title}' \ 157 | c1db3f5c-82da-4dda-9d81-fa718d5d1d16 158 | 159 | # Download ALL books with default options when only 1 user exists 160 | kobodl book get --get-all 161 | 162 | # Download ALL books with advanced options 163 | kobodl book get \ 164 | --user email@domain.com \ 165 | --output-dir /path/to/download_directory \ 166 | --format-str '{Title}' \ 167 | --get-all 168 | ``` 169 | 170 | Running the web UI 171 | 172 | ``` bash 173 | kobodl serve 174 | * Serving Flask app "kobodl.app" (lazy loading) 175 | * Environment: production 176 | WARNING: This is a development server. Do not use it in a production deployment. 177 | Use a production WSGI server instead. 178 | * Debug mode: off 179 | * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit) 180 | ``` 181 | 182 | Global options 183 | 184 | ``` bash 185 | # argument format 186 | kobodl [OPTIONS] COMMAND [ARGS]... 187 | 188 | # set python tabulate formatting style. 189 | kobodl --fmt "pretty" COMMAND [ARGS]... 190 | 191 | # set config path if different than ~/.config/kobodl.json 192 | kobodl --config /path/to/kobodl.json COMMAND [ARGS]... 193 | 194 | # get version 195 | kobodl --version 196 | 197 | # enable debugging, prints to "debug.log" 198 | kobodl --debug [OPTIONS] COMMAND [ARGS]... 199 | ``` 200 | 201 | ## Troubleshooting 202 | 203 | > Some of my books are missing! 204 | 205 | Try `kobodl book list --read` to show all "finished" and "archived" books. You can manage your book status on [the library page](https://kobo.com/library). Try changing the status using the "..." button. 206 | 207 | > I see a mesage about "skipping _____" when I download all. 208 | 209 | Try to download the book individually using `kobodl book get `, replacing `revision-id` with the UUID from the list table. 210 | 211 | > Something else is going wrong! 212 | 213 | Try enabling debugging. Run `kobodl --debug book get` (for example), which will dump a lot of data into a file called `debug.log`. Email me this file. Do not post it in public on an issue because it will contain information about your account. My email address can be found on my [github profile page](https://github.com/subdavis). 214 | 215 | ## Development 216 | 217 | This project uses [Python Poetry](https://python-poetry.org/). I also personally like `pyenv` and the pyenv-virtualenv addon. I install these with homebrew (MacOS). 218 | 219 | ```bash 220 | # Optional if you use pyenv 221 | pyenv install 3.11 222 | pyenv virtualenv 3.11 kobo-book-downloader 223 | echo "kobo-book-downloader" >> .python-version 224 | 225 | git clone https://github.com/subdavis/kobo-book-downloader 226 | cd kobo-book-downloader 227 | poetry install 228 | 229 | # Run command line app 230 | poetry run kobodl 231 | 232 | # Run linting 233 | poetry run tox -e lint 234 | 235 | # Run standalone bundle generation 236 | poetry run tox -e buildcli 237 | 238 | # Run type checks 239 | poetry run tox -e type 240 | ``` 241 | 242 | ## Notes 243 | 244 | kobo-book-downloader uses the same web-based activation method to login as the Kobo e-readers. You will have to open an activation link—that uses the official [Kobo](https://www.kobo.com/) site—in your browser and enter the code. You might need to login if kobo.com asks you to. Once kobo-book-downloader has successfully logged in, it won't ask for the activation again. kobo-book-downloader doesn't store your Kobo password in any form; it works with access tokens. 245 | 246 | Credit recursively to [kobo-book-downloader](https://github.com/TnS-hun/kobo-book-downloader) and the projects that lead to it. 247 | 248 | ## FAQ 249 | 250 | **How does this work?** 251 | 252 | kobodl works by pretending to be an Android Kobo e-reader. It initializes a device, fetches your library, and downloads books as a "fake" Android app. 253 | 254 | **Why does this download KEPUB formatted books?** 255 | 256 | Kobo has different formats that it serves to different platforms. For example, Desktop users generally get `EPUB3` books with `AdobeDrm` DRM. Android users typically get `KEPUB` books with `KDRM` DRM, which is fairly easy to remove, so that's what you get when you use this tool. 257 | 258 | **Is this tool safe and okay to use?** 259 | 260 | I'm not a lawyer, and the discussion below is strictly academic. 261 | 262 | The author(s) of `kobodl` don't collect any information about you or your account aside from what is made available through metrics from GitHub, PyPi, Docker Hub, etc. See `LICENSE.md` for further info. 263 | 264 | Kobo would probably claim that this tool violates its [Terms of Use](https://authorize.kobo.com/terms/termsofuse) but I'm not able to conclusively determine that it does so. Some relevant sections are reproduced here. 265 | 266 | > The download of, and access to any Digital Content is available only to Customers and is intended only for such Customers’ personal and non-commercial use. Any other use of Digital Content downloaded or accessed from the Service is strictly prohibited 267 | 268 | This tool should only be used to download books for personal use. 269 | 270 | > You may not obscure or misrepresent your geographical location, forge headers, use proxies, use IP spoofing or otherwise manipulate identifiers in order to disguise the origin of any message or transmittal you send on or through the Service. You may not pretend that you are, or that you represent, someone else, or impersonate any other individual or entity. 271 | 272 | This might be a violation. This client announces itself to Kobo servers as an Android device, which can safely be construed as "manipulating identifiers", but whether or not the purpose is to "disguise the origin" is unclear. 273 | 274 | > Kobo may also take steps to prevent fraud, such as restricting the number of titles that may be accessed at one time, and monitoring Customer accounts for any activity that may violate these Terms. If Kobo discovers any type of fraud, Kobo reserves the right to take enforcement action including the termination or suspension of a User’s account. 275 | 276 | In other words, you could have your account suspended for using `kobodl`. **Please open an issue on the issue tracker if this happens to you.** 277 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | services: 3 | kobodl: 4 | image: ghcr.io/subdavis/kobodl:latest 5 | build: 6 | context: . 7 | dockerfile: Dockerfile 8 | container_name: kobodl 9 | restart: unless-stopped 10 | user: ${PUID:-1000} 11 | ports: 12 | - "5000:5000" 13 | volumes: 14 | # These mappings will be the same for command line and web server 15 | - ${HOME}/.config/kobodl.json:/home/kobodl.json 16 | - ${HOME}/kobodl/downloads:/home/downloads 17 | command: --config /home/kobodl.json serve -h 0.0.0.0 -p 5000 --output-dir /home/downloads 18 | -------------------------------------------------------------------------------- /docs/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subdavis/kobo-book-downloader/a6236ffa4bedc96a58f60425c6407f6ee9c1fa51/docs/banner.png -------------------------------------------------------------------------------- /docs/books.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subdavis/kobo-book-downloader/a6236ffa4bedc96a58f60425c6407f6ee9c1fa51/docs/books.png -------------------------------------------------------------------------------- /docs/books3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subdavis/kobo-book-downloader/a6236ffa4bedc96a58f60425c6407f6ee9c1fa51/docs/books3.png -------------------------------------------------------------------------------- /docs/captcha.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subdavis/kobo-book-downloader/a6236ffa4bedc96a58f60425c6407f6ee9c1fa51/docs/captcha.gif -------------------------------------------------------------------------------- /docs/kobodl.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subdavis/kobo-book-downloader/a6236ffa4bedc96a58f60425c6407f6ee9c1fa51/docs/kobodl.png -------------------------------------------------------------------------------- /docs/webss.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subdavis/kobo-book-downloader/a6236ffa4bedc96a58f60425c6407f6ee9c1fa51/docs/webss.png -------------------------------------------------------------------------------- /kobodl/__init__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import click 4 | 5 | from kobodl.app import app 6 | from kobodl.debug import debug_data 7 | from kobodl.globals import Globals 8 | from kobodl.settings import Settings 9 | 10 | 11 | @click.group() 12 | @click.option( 13 | '--fmt', 14 | type=click.STRING, 15 | default='simple', 16 | help='python-tabulate table format string', 17 | ) 18 | @click.option( 19 | '--config', 20 | type=click.Path(dir_okay=False, file_okay=True, writable=True), 21 | help='path to kobodl.json config file', 22 | ) 23 | @click.option( 24 | '--debug', 25 | is_flag=True, 26 | help="enable the debug log", 27 | ) 28 | @click.version_option() 29 | @click.pass_context 30 | def cli(ctx, fmt, config, debug): 31 | Globals.Settings = Settings(config) 32 | Globals.Debug = debug 33 | ctx.obj = { 34 | 'fmt': fmt, 35 | 'debug': debug, 36 | } 37 | debug_data(sys.argv) 38 | 39 | 40 | @click.command(name='serve', short_help='start an http server') 41 | @click.option('-h', '--host', type=click.STRING) 42 | @click.option('-p', '--port', type=click.INT) 43 | @click.option('--debug', is_flag=True) 44 | @click.option( 45 | '-o', 46 | '--output-dir', 47 | type=click.Path(file_okay=False, dir_okay=True, writable=True), 48 | default='kobo_downloads', 49 | ) 50 | def serve(host, port, debug, output_dir): 51 | Globals.Debug = debug 52 | app.config['output_dir'] = output_dir 53 | app.run(host, port, debug) 54 | 55 | 56 | cli.add_command(serve) 57 | 58 | from kobodl.commands import book, user # isort:skip noqa: F401 E402 59 | -------------------------------------------------------------------------------- /kobodl/__main__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from kobodl import cli 4 | 5 | cli(sys.argv[1:]) 6 | -------------------------------------------------------------------------------- /kobodl/actions.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import platform 4 | from typing import List, TextIO, Tuple, Union, Generator 5 | 6 | import click 7 | 8 | from kobodl.globals import Globals 9 | from kobodl.kobo import Book, BookType, Kobo, KoboException, NotAuthenticatedException 10 | from kobodl.settings import User 11 | 12 | SUPPORTED_BOOK_TYPES = [ 13 | BookType.EBOOK, 14 | BookType.AUDIOBOOK, 15 | ] 16 | 17 | 18 | def __GetBookAuthor(book: dict) -> str: 19 | contributors = book.get('ContributorRoles') 20 | 21 | authors = [] 22 | for contributor in contributors: 23 | role = contributor.get('Role') 24 | if role == 'Author': 25 | authors.append(contributor['Name']) 26 | 27 | # Unfortunately the role field is not filled out in the data returned by the 'library_sync' endpoint, so we only 28 | # use the first author and hope for the best. Otherwise we would get non-main authors too. For example Christopher 29 | # Buckley beside Joseph Heller for the -- terrible -- novel Catch-22. 30 | if len(authors) == 0 and len(contributors) > 0: 31 | authors.append(contributors[0]['Name']) 32 | 33 | return ' & '.join(authors) 34 | 35 | 36 | def __SanitizeString(string: str) -> str: 37 | result = '' 38 | for c in string: 39 | if c.isalnum() or ' ,;.!(){}[]#$\'-+@_'.find(c) >= 0: 40 | result += c 41 | 42 | result = result.strip(' .') 43 | if platform.system() == 'Windows': 44 | # Limit the length -- mostly because of Windows. It would be better to do it on the full path using MAX_PATH. 45 | result = result[:100] 46 | return result 47 | 48 | 49 | def __MakeFileNameForBook(bookMetadata: dict, formatStr: str) -> str: 50 | '''filename without extension''' 51 | fileName = '' 52 | author = __SanitizeString(__GetBookAuthor(bookMetadata)) 53 | title = __SanitizeString(bookMetadata['Title']) 54 | 55 | return formatStr.format_map( 56 | { 57 | **bookMetadata, 58 | 'Author': author, 59 | 'Title': title, 60 | # Append a portion of revisionId to prevent name collisions. 61 | 'ShortRevisionId': bookMetadata['RevisionId'][:8], 62 | } 63 | ) 64 | 65 | 66 | def __GetBookMetadata(entitlement: dict) -> Tuple[dict, BookType]: 67 | keys = entitlement.keys() 68 | if 'BookMetadata' in keys: 69 | return entitlement['BookMetadata'], BookType.EBOOK 70 | if 'AudiobookMetadata' in keys: 71 | return entitlement['AudiobookMetadata'], BookType.AUDIOBOOK 72 | if 'BookSubscriptionEntitlement' in keys: 73 | return entitlement['BookSubscriptionEntitlement'], BookType.SUBSCRIPTION 74 | print(f'WARNING: unsupported object detected with contents {entitlement}') 75 | print('Please open an issue at https://github.com/subdavis/kobo-book-downloader/issues') 76 | return None, None 77 | 78 | 79 | def __IsBookArchived(newEntitlement: dict) -> bool: 80 | keys = newEntitlement.keys() 81 | bookEntitlement: dict = {} 82 | if 'BookEntitlement' in keys: 83 | bookEntitlement = newEntitlement['BookEntitlement'] 84 | if 'AudiobookEntitlement' in keys: 85 | bookEntitlement = newEntitlement['AudiobookEntitlement'] 86 | return bookEntitlement.get('IsRemoved', False) 87 | 88 | 89 | def __IsBookRead(newEntitlement: dict) -> bool: 90 | readingState = newEntitlement.get('ReadingState') 91 | if readingState is None: 92 | return False 93 | 94 | statusInfo = readingState.get('StatusInfo') 95 | if statusInfo is None: 96 | return False 97 | 98 | status = statusInfo.get('Status') 99 | return status == 'Finished' 100 | 101 | 102 | def __GetBookList(kobo: Kobo, listAll: bool, exportFile: Union[TextIO, None]) -> list: 103 | bookList = kobo.GetMyBookList() 104 | rows = [] 105 | 106 | if exportFile: 107 | exportFile.write(json.dumps(bookList, indent=2)) 108 | 109 | for entitlement in bookList: 110 | newEntitlement = entitlement.get('NewEntitlement') 111 | if newEntitlement is None: 112 | continue 113 | 114 | bookEntitlement = newEntitlement.get('BookEntitlement') 115 | if bookEntitlement is not None: 116 | # Skip saved previews. 117 | if bookEntitlement.get('Accessibility') == 'Preview': 118 | continue 119 | 120 | # Skip refunded books. 121 | if bookEntitlement.get('IsLocked'): 122 | continue 123 | 124 | if (not listAll) and __IsBookRead(newEntitlement): 125 | continue 126 | 127 | bookMetadata, book_type = __GetBookMetadata(newEntitlement) 128 | 129 | if book_type is None: 130 | click.echo('Skipping book of unknown type') 131 | continue 132 | 133 | elif book_type in SUPPORTED_BOOK_TYPES: 134 | book = [ 135 | bookMetadata['RevisionId'], 136 | bookMetadata['Title'], 137 | __GetBookAuthor(bookMetadata), 138 | __IsBookArchived(newEntitlement), 139 | book_type == BookType.AUDIOBOOK, 140 | ] 141 | rows.append(book) 142 | 143 | rows = sorted(rows, key=lambda columns: columns[1].lower()) 144 | return rows 145 | 146 | 147 | def ListBooks(users: List[User], listAll: bool, exportFile: Union[TextIO, None]) -> List[Book]: 148 | '''list all books currently in the account''' 149 | for user in users: 150 | kobo = Kobo(user) 151 | kobo.LoadInitializationSettings() 152 | rows = __GetBookList(kobo, listAll, exportFile) 153 | for columns in rows: 154 | yield Book( 155 | RevisionId=columns[0], 156 | Title=columns[1], 157 | Author=columns[2], 158 | Archived=columns[3], 159 | Audiobook=columns[4], 160 | Owner=user, 161 | ) 162 | 163 | # Wishlist item response example 164 | # {'DateAdded': '2020-05-12T00:51:32.8860172Z', 'CrossRevisionId': '4dc63ad1-0b4d-3e52-a8bb-704e632963e8', 'IsPurchaseable': True, 'IsSupportedOnCurrentPlatform': True, 'ProductMetadata': {'Book': {'Contributors': 'Mohsin Hamid', 'WorkId': '75952d21-3893-40a6-a6a2-088ae9337c8a', 'Subtitle': 'A Novel', 'IsFree': False, 'ISBN': '9780735212183', 'PublicationDate': '2017-03-07T00:00:00.0000000Z', 'ExternalIds': ['od_2814358'], 'ContributorRoles': [{'Name': 'Mohsin Hamid', 'Role': 'Author'}], 'IsInternetArchive': False, 'IsRecommendation': False, 'CrossRevisionId': '4dc63ad1-0b4d-3e52-a8bb-704e632963e8', 'Title': 'Exit West', 'Description': '

**One of The New York Times’s 100 Best Books of the 21st Century

FINALIST FOR THE BOOKER PRIZE & WINNER OF THE L.A. TIMES BOOK PRIZE FOR FICTION and THE ASPEN WORDS LITERARY PRIZE**

“It was as if Hamid knew what was going to happen to America and the world, and gave us a road map to our future… At once terrifying and … oddly hopeful.” —Ayelet Waldman, The New York Times Book Review

“Moving, audacious, and indelibly hu...', 'Language': 'en', 'Locale': {'LanguageCode': 'eng', 'ScriptCode': '', 'CountryCode': ''}, 'ImageId': '573021a8-715d-465a-890f-b24207ab06c1', 'PublisherName': 'Penguin Publishing Group', 'Rating': 4.047826, 'TotalRating': 230, 'RatingHistogram': {'1': 5, '2': 10, '3': 41, '4': 87, '5': 0}, 'Slug': 'exit-west', 'IsContentSharingEnabled': True, 'RedirectPreviewUrls': [{'DrmType': 'None', 'Format': 'EPUB3_SAMPLE', 'Url': 'https://storedownloads.kobo.com/download?downloadToken=eyJ0eXAiOjIsInZlciI6bnVsbCwicHR5cCI6IlByZXZpZXdEb3dubG9hZFRva2VuIn0.cWoOja1aXK_bQjhEf72K0w.iEKSnYBwnUrYuNfhuMBUHMoAzbeXVrLetDoYjZ-9X9iWeePtNPb3J8Qwr4677v-BaUC6jb9RuBIVqb5eEb-fvB7ATEVKYUw-eRUVK0PzRtW323wKWN_VVRbyhzhnXmPcwZytK6V3MwI4DRkY7nD6IOdVZaZxfbkutyykmBY2fOYTGMke0UioXu4tYrTM65G6N2cw15UTDg8-7i0_WRlrNRAOCf8R4cRmvblfySKmZWT7V2grysKnMNPginyNX2YSkgCRfLVZgSwxOrtqiBi8ukUNjFJj4OSU6RvOwSPvu_R37cDnEW8Vft6tilrgc10nhXRKgUllGP8kN9DJXX6UbvhOKlrKCKzHJnkn4G272PQLFymSPSS_2frfbszEq_DuPiB-vNZgsgfP0B9ylHMx_oN497GSYfp8Kg9fiv8A9KZBz3DAU6r6Lgji5U5U0Dr0y4WBQ8hz2dVzKDTffwKkXDJFpbmd495Bb57BUf0JtsWt19n0aRALYJcjEQ3zKREpgKqLcHX4SpmBqrv1PkPr8tNwi-CINd1JXyll9SwSjmhfmHVcq7Lykgz4WCQ1oGwjGup3nEzHwpFwPq3RITFCZkUy41mc0QqZZ83PpWA3dqSNK-nsp5uZ84gw024C0CuUUq0GmefN3YD73fxBT2ASrA', 'Platform': 'Generic', 'Size': 742692}], 'HasPreview': True, 'Price': {'Currency': 'USD', 'Price': 13.99}, 'PromoCodeAllowed': False, 'EligibleForKoboLoveDiscount': False, 'IsPreOrder': False, 'RelatedGroupId': '180cc678-2429-c8da-0000-000000000000', 'AgeVerificationRequired': False, 'AccessibilityDetails': {'IsFixedLayout': False, 'IsTextToSpeechAllowed': False}, 'Id': 'ba03ec06-e024-46bb-b7fb-56b20c04f598'}}} 165 | def GetWishList(users: List[User]) -> List[Book]: 166 | for user in users: 167 | kobo = Kobo(user) 168 | kobo.LoadInitializationSettings() 169 | wishList = kobo.GetMyWishList() 170 | for item in wishList: 171 | yield Book( 172 | RevisionId=item['CrossRevisionId'], 173 | Title=item['ProductMetadata']['Book']['Title'], 174 | Author=item['ProductMetadata']['Book']['Contributors'], 175 | Archived=False, 176 | Audiobook=False, 177 | Owner=user, 178 | Price=f"{item['ProductMetadata']['Book']['Price']['Price']} {item['ProductMetadata']['Book']['Price']['Currency']}", 179 | ) 180 | 181 | 182 | def Login(user: User) -> None: 183 | '''perform device initialization and get token''' 184 | kobo = Kobo(user) 185 | kobo.AuthenticateDevice() 186 | kobo.LoadInitializationSettings() 187 | kobo.Login() 188 | 189 | 190 | def InitiateLogin(user: User) -> Tuple[str, str]: 191 | """Start the login process and return activation details""" 192 | kobo = Kobo(user) 193 | return kobo._Kobo__ActivateOnWeb() 194 | 195 | 196 | def CheckActivation(user: User, check_url: str) -> bool: 197 | """Check if activation is complete and setup user if so""" 198 | kobo = Kobo(user) 199 | try: 200 | email, user_id, user_key = kobo._Kobo__CheckActivation(check_url) 201 | user.Email = email 202 | user.UserId = user_id 203 | kobo.AuthenticateDevice(user_key) 204 | kobo.LoadInitializationSettings() 205 | return True 206 | except Exception: 207 | return False 208 | 209 | 210 | def GetBookOrBooks( 211 | user: User, 212 | outputPath: str, 213 | formatStr: str = r'{Author} - {Title} {ShortRevisionId}', 214 | productId: str = '', 215 | ) -> Union[None, str]: 216 | """ 217 | download 1 or all books to file 218 | returns output filepath if identifier is passed, otherwise returns None 219 | """ 220 | outputPath = os.path.abspath(outputPath) 221 | kobo = Kobo(user) 222 | kobo.LoadInitializationSettings() 223 | 224 | # Must call GetBookList every time, even if you're only getting 1 book, 225 | # because it invokes a library sync endpoint. 226 | # This is the only known endpoint that returns 227 | # download URLs along with book metadata. 228 | bookList = kobo.GetMyBookList() 229 | 230 | for entitlement in bookList: 231 | newEntitlement = entitlement.get('NewEntitlement') 232 | if newEntitlement is None: 233 | continue 234 | 235 | bookMetadata, book_type = __GetBookMetadata(newEntitlement) 236 | if book_type is None: 237 | click.echo('Skipping book of unknown type') 238 | continue 239 | 240 | elif book_type == BookType.SUBSCRIPTION: 241 | click.echo('Skipping subscribtion entity') 242 | continue 243 | 244 | fileName = __MakeFileNameForBook(bookMetadata, formatStr) 245 | if book_type == BookType.EBOOK: 246 | # Audiobooks go in sub-directories 247 | # but epub files go directly in outputPath 248 | fileName += '.epub' 249 | outputFilePath = os.path.join(outputPath, fileName) 250 | 251 | if not productId and os.path.exists(outputFilePath): 252 | # when downloading ALL books, skip books we've downloaded before 253 | click.echo(f'Skipping already downloaded book {outputFilePath}') 254 | continue 255 | 256 | currentProductId = Kobo.GetProductId(bookMetadata) 257 | if productId and productId != currentProductId: 258 | # user only asked for a single title, 259 | # and this is not the book they want 260 | continue 261 | 262 | # Skip archived books. 263 | if __IsBookArchived(newEntitlement): 264 | click.echo(f'Skipping archived book {fileName}') 265 | continue 266 | 267 | try: 268 | click.echo(f'Downloading {currentProductId} to {outputFilePath}', err=True) 269 | kobo.Download(bookMetadata, book_type == BookType.AUDIOBOOK, outputFilePath) 270 | except KoboException as e: 271 | if productId: 272 | raise e 273 | else: 274 | click.echo( 275 | ( 276 | f'Skipping failed download for {currentProductId}: {str(e)}' 277 | '\n -- Try downloading it as a single book to get the complete exception details' 278 | ' and open an issue on the project GitHub page: https://github.com/subdavis/kobo-book-downloader/issues' 279 | ), 280 | err=True, 281 | ) 282 | 283 | if productId: 284 | # TODO: support audiobook downloads from web 285 | return outputFilePath 286 | 287 | return None 288 | -------------------------------------------------------------------------------- /kobodl/app.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from flask import Flask, abort, jsonify, redirect, render_template, request, send_from_directory 4 | 5 | from kobodl import actions 6 | from kobodl.globals import Globals 7 | from kobodl.settings import User 8 | 9 | app = Flask(__name__) 10 | 11 | 12 | @app.route('/') 13 | def index(): 14 | return redirect('/user') 15 | 16 | 17 | @app.route('/user', methods=['GET', 'POST']) 18 | def users(): 19 | error = None 20 | if request.method == 'POST': 21 | email = request.form.get('email') 22 | if email: 23 | user = User(Email=email) 24 | try: 25 | activation_url, activation_code = actions.InitiateLogin(user) 26 | return jsonify( 27 | { 28 | 'activation_url': 'https://www.kobo.com/activate', 29 | 'activation_code': activation_code, 30 | 'check_url': activation_url, 31 | 'email': email, 32 | } 33 | ) 34 | except Exception as err: 35 | error = str(err) 36 | else: 37 | error = 'email is required' 38 | users = Globals.Settings.UserList.users 39 | return render_template('users.j2', users=users, error=error) 40 | 41 | 42 | @app.route('/user/check-activation', methods=['POST']) 43 | def check_activation(): 44 | data = request.get_json() 45 | check_url = data.get('check_url') 46 | email = data.get('email') 47 | 48 | if not check_url or not email: 49 | return jsonify({'error': 'Missing required parameters'}), 400 50 | 51 | user = User(Email=email) 52 | try: 53 | if actions.CheckActivation(user, check_url): 54 | Globals.Settings.UserList.users.append(user) 55 | Globals.Settings.Save() 56 | return jsonify({'success': True}) 57 | return jsonify({'success': False}) 58 | except Exception as err: 59 | return jsonify({'error': str(err)}), 400 60 | 61 | 62 | @app.route('/user//remove', methods=['POST']) 63 | def deleteUser(userid): 64 | user = Globals.Settings.UserList.getUser(userid) 65 | if not user: 66 | abort(404) 67 | Globals.Settings.UserList.users.remove(user) 68 | Globals.Settings.Save() 69 | return redirect('/user') 70 | 71 | 72 | @app.route('/user//book', methods=['GET']) 73 | def getUserBooks(userid, error=None, success=None): 74 | user = Globals.Settings.UserList.getUser(userid) 75 | if not user: 76 | abort(404) 77 | books = actions.ListBooks([user], False, None) 78 | return render_template( 79 | 'books.j2', 80 | books=books, 81 | error=error, 82 | success=success, 83 | ) 84 | 85 | 86 | @app.route('/user//book/', methods=['GET']) 87 | def downloadBook(userid, productid): 88 | user = Globals.Settings.UserList.getUser(userid) 89 | if not user: 90 | abort(404) 91 | outputDir = app.config.get('output_dir') 92 | os.makedirs(outputDir, exist_ok=True) 93 | # GetBookOrBooks always returns an absolute path 94 | outputFileName = actions.GetBookOrBooks(user, outputDir, productId=productid) 95 | absOutputDir, tail = os.path.split(outputFileName) 96 | # send_from_directory must be given an absolute path to avoid confusion 97 | # (relative paths are relative to root_path, not working dir) 98 | return send_from_directory(absOutputDir, tail, as_attachment=True, download_name=tail) 99 | 100 | 101 | @app.route('/book', methods=['GET']) 102 | def books(): 103 | userlist = Globals.Settings.UserList.users 104 | books = actions.ListBooks(userlist, False, None) 105 | return render_template('books.j2', books=books) 106 | -------------------------------------------------------------------------------- /kobodl/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subdavis/kobo-book-downloader/a6236ffa4bedc96a58f60425c6407f6ee9c1fa51/kobodl/commands/__init__.py -------------------------------------------------------------------------------- /kobodl/commands/book.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | from typing import List 4 | 5 | import click 6 | from tabulate import tabulate 7 | 8 | from kobodl import actions, cli 9 | from kobodl.globals import Globals 10 | 11 | 12 | def decorators(book): 13 | append = '' 14 | if book.Audiobook: 15 | append += ' (🎧 Audiobook)' 16 | if book.Archived: 17 | append += ' (🗄️ Archived)' 18 | return append 19 | 20 | 21 | @click.group(name='book', short_help='list and download books') 22 | def book(): 23 | pass 24 | 25 | 26 | @book.command(name='get', short_help='download book') 27 | @click.option( 28 | '-u', 29 | '--user', 30 | type=click.STRING, 31 | help='Required when multiple accounts exist. Use either Email or UserKey', 32 | ) 33 | @click.option( 34 | '-o', 35 | '--output-dir', 36 | type=click.Path(file_okay=False, dir_okay=True, writable=True), 37 | default='kobo_downloads', 38 | help='default: kobo_downloads', 39 | ) 40 | @click.option('-a', '--get-all', is_flag=True) 41 | @click.option( 42 | '-f', 43 | '--format-str', 44 | type=click.STRING, 45 | default=r'{Author} - {Title} {ShortRevisionId}', 46 | help=r"default: '{Author} - {Title} {ShortRevisionId}'", 47 | ) 48 | @click.argument('product-id', nargs=-1, type=click.STRING) 49 | @click.pass_obj 50 | def get(ctx, user, output_dir: Path, get_all: bool, format_str: str, product_id: List[str]): 51 | if len(Globals.Settings.UserList.users) == 0: 52 | click.echo('error: no users found. Did you `kobodl user add`?', err=True) 53 | exit(1) 54 | 55 | if not user: 56 | if len(Globals.Settings.UserList.users) > 1: 57 | click.echo('error: must provide --user option when more than 1 user exists.') 58 | exit(1) 59 | # Exactly 1 user account exists 60 | usercls = Globals.Settings.UserList.users[0] 61 | else: 62 | # A user was passed 63 | usercls = Globals.Settings.UserList.getUser(user) 64 | if not usercls: 65 | click.echo(f'error: could not find user with name or id {user}') 66 | exit(1) 67 | 68 | if get_all and len(product_id): 69 | click.echo( 70 | 'error: cannot pass product IDs when --get-all is used. Use one or the other.', 71 | err=True, 72 | ) 73 | exit(1) 74 | if not get_all and len(product_id) == 0: 75 | click.echo('error: must pass at least one Product ID, or use --get-all', err=True) 76 | exit(1) 77 | 78 | os.makedirs(output_dir, exist_ok=True) 79 | if get_all: 80 | actions.GetBookOrBooks(usercls, output_dir, formatStr=format_str) 81 | else: 82 | for pid in product_id: 83 | actions.GetBookOrBooks(usercls, output_dir, formatStr=format_str, productId=pid) 84 | 85 | 86 | @book.command(name='list', help='list books') 87 | @click.option( 88 | '-u', 89 | '--user', 90 | type=click.STRING, 91 | required=False, 92 | help='Limit list to a single user. Use either Email or UserKey', 93 | ) 94 | @click.option('--read', is_flag=True, help='include books marked as read') 95 | @click.option( 96 | '--export-library', 97 | type=click.File(mode='w'), 98 | help='filepath to write raw JSON library data to.', 99 | ) 100 | @click.pass_obj 101 | def list(ctx, user, read, export_library): 102 | userlist = Globals.Settings.UserList.users 103 | if user: 104 | userlist = [Globals.Settings.UserList.getUser(user)] 105 | books = actions.ListBooks(userlist, read, export_library) 106 | headers = ['Title', 'Author', 'RevisionId', 'Owner'] 107 | data = sorted( 108 | [ 109 | ( 110 | book.Title + decorators(book), 111 | book.Author, 112 | book.RevisionId, 113 | book.Owner.Email, 114 | ) 115 | for book in books 116 | ] 117 | ) 118 | click.echo(tabulate(data, headers, tablefmt=ctx['fmt'])) 119 | 120 | 121 | @book.command(name='wishlist', help='list wishlist') 122 | @click.option( 123 | '-u', 124 | '--user', 125 | type=click.STRING, 126 | required=False, 127 | help='Limit list to a single user. Use either Email or UserKey', 128 | ) 129 | @click.pass_obj 130 | def wishlist(ctx, user): 131 | userlist = Globals.Settings.UserList.users 132 | 133 | userlist = Globals.Settings.UserList.users 134 | if user: 135 | userlist = [Globals.Settings.UserList.getUser(user)] 136 | books = actions.GetWishList(userlist) 137 | headers = ['Title', 'Author', 'RevisionId', 'Owner', 'Price'] 138 | data = sorted( 139 | [ 140 | ( 141 | book.Title + decorators(book), 142 | book.Author, 143 | book.RevisionId, 144 | book.Owner.Email, 145 | book.Price, 146 | ) 147 | for book in books 148 | ] 149 | ) 150 | click.echo(tabulate(data, headers, tablefmt=ctx['fmt'])) 151 | 152 | cli.add_command(book) 153 | -------------------------------------------------------------------------------- /kobodl/commands/user.py: -------------------------------------------------------------------------------- 1 | import click 2 | from tabulate import tabulate 3 | 4 | from kobodl import actions, cli 5 | from kobodl.globals import Globals 6 | from kobodl.kobo import Kobo 7 | from kobodl.settings import User 8 | 9 | 10 | @click.group(name='user', short_help='show and create users') 11 | def user(): 12 | pass 13 | 14 | 15 | @user.command(name='list', help='list all users') 16 | @click.pass_obj 17 | def list(ctx): 18 | userlist = Globals.Settings.UserList.users 19 | headers = ['Email', 'UserKey', 'DeviceId'] 20 | data = sorted( 21 | [ 22 | ( 23 | user.Email, 24 | user.UserKey, 25 | user.DeviceId, 26 | ) 27 | for user in userlist 28 | ] 29 | ) 30 | click.echo(tabulate(data, headers, tablefmt=ctx['fmt'])) 31 | 32 | 33 | @user.command(name='rm', help='remove user by Email, UserKey, or DeviceID') 34 | @click.argument('identifier', type=click.STRING) 35 | @click.pass_obj 36 | def list(ctx, identifier): 37 | removed = Globals.Settings.UserList.removeUser(identifier) 38 | if removed: 39 | Globals.Settings.Save() 40 | click.echo(f'Removed {removed.Email}') 41 | else: 42 | click.echo(f'No user with email, key, or device id that matches "{identifier}"') 43 | 44 | 45 | @user.command(name='add', help='add new user') 46 | @click.pass_obj 47 | def add(ctx): 48 | user = User() 49 | actions.Login(user) 50 | Globals.Settings.UserList.users.append(user) 51 | Globals.Settings.Save() 52 | click.echo('Login Success. Try to list your books with `kobodl book list`') 53 | 54 | 55 | cli.add_command(user) 56 | -------------------------------------------------------------------------------- /kobodl/debug.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from kobodl.globals import Globals 4 | 5 | 6 | def debug_data(*args): 7 | if Globals.Debug: 8 | with open('./debug.log', 'a', encoding='utf-8') as debuglog: 9 | debuglog.write(str(datetime.now())) 10 | debuglog.write('\n') 11 | for stringable in args: 12 | debuglog.write(str(stringable)) 13 | debuglog.write('\n') 14 | -------------------------------------------------------------------------------- /kobodl/globals.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | from kobodl.settings import Settings 4 | 5 | 6 | class Globals: 7 | Settings: Union[Settings, None] = None 8 | Debug = False 9 | -------------------------------------------------------------------------------- /kobodl/kobo.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import dataclasses 3 | import html 4 | import os 5 | import re 6 | import secrets 7 | import string 8 | import json 9 | import sys 10 | import time 11 | import urllib 12 | from enum import Enum 13 | from shutil import copyfile 14 | from typing import Dict, Tuple, Union, Optional 15 | 16 | import requests 17 | from bs4 import BeautifulSoup 18 | from dataclasses_json import dataclass_json 19 | 20 | from kobodl.debug import debug_data 21 | from kobodl.globals import Globals 22 | from kobodl.koboDrmRemover import KoboDrmRemover 23 | from kobodl.settings import User 24 | 25 | 26 | @dataclass_json 27 | @dataclasses.dataclass 28 | class Book: 29 | RevisionId: str 30 | Title: str 31 | Author: str 32 | Archived: bool 33 | Audiobook: bool 34 | Owner: User 35 | Price: Optional[str] = None 36 | 37 | 38 | class BookType(Enum): 39 | EBOOK = 1 40 | AUDIOBOOK = 2 41 | SUBSCRIPTION = 3 42 | 43 | 44 | class NotAuthenticatedException(Exception): 45 | pass 46 | 47 | 48 | class KoboException(Exception): 49 | pass 50 | 51 | 52 | class Kobo: 53 | Affiliate = "Kobo" 54 | ApplicationVersion = "4.38.23171" 55 | DefaultPlatformId = "00000000-0000-0000-0000-000000000373" 56 | DisplayProfile = "Android" 57 | DeviceModel = "Kobo Aura ONE" 58 | DeviceOs = "3.0.35+" 59 | DeviceOsVersion = "NA" 60 | # Use the user agent of the Kobo e-readers 61 | UserAgent = "Mozilla/5.0 (Linux; U; Android 2.0; en-us;) AppleWebKit/538.1 (KHTML, like Gecko) Version/4.0 Mobile Safari/538.1 (Kobo Touch 0373/4.38.23171)" 62 | 63 | def __init__(self, user: User): 64 | self.InitializationSettings = {} 65 | self.Session = requests.session() 66 | self.Session.headers.update({"User-Agent": Kobo.UserAgent}) 67 | self.user = user 68 | 69 | # PRIVATE METHODS 70 | 71 | # This could be added to the session but then we would need to add { "Authorization": None } headers to all other 72 | # functions that doesn't need authorization. 73 | def __GetHeaderWithAccessToken(self) -> dict: 74 | authorization = "Bearer " + self.user.AccessToken 75 | headers = {"Authorization": authorization} 76 | return headers 77 | 78 | def __CheckActivation(self, activationCheckUrl) -> Union[Tuple[str, str, str], None]: 79 | response = self.Session.post(activationCheckUrl) 80 | response.raise_for_status() 81 | jsonResponse = None 82 | 83 | try: 84 | jsonResponse = response.json() 85 | except: 86 | debug_data(f"Activation check response: {response.text}") 87 | raise KoboException(f"Error checking the activation status. The response format was unexpected.") 88 | 89 | if jsonResponse["Status"] == "Complete": 90 | debug_data(f"Activation check response", {json.dumps(jsonResponse)}) 91 | redirectUrl = jsonResponse[ "RedirectUrl" ] 92 | parsed = urllib.parse.urlparse( redirectUrl ) 93 | parsedQueries = urllib.parse.parse_qs( parsed.query ) 94 | userId = parsedQueries[ "userId" ][ 0 ] 95 | userKey = parsedQueries[ "userKey" ][ 0 ] 96 | userEmail = parsedQueries[ "email" ][ 0 ] 97 | return userEmail, userId, userKey 98 | 99 | return None 100 | 101 | def __WaitTillActivation(self, activationCheckUrl) -> Tuple[str, str]: 102 | while True: 103 | print("Waiting for you to finish the activation...") 104 | time.sleep(5) 105 | response = self.__CheckActivation(activationCheckUrl) 106 | if response: 107 | return response 108 | 109 | def __ActivateOnWeb(self) -> Tuple[str, str]: 110 | print("Initiating web-based activation") 111 | 112 | params = { 113 | "pwspid": Kobo.DefaultPlatformId, 114 | "wsa": Kobo.Affiliate, 115 | "pwsdid": self.user.DeviceId, 116 | "pwsav": Kobo.ApplicationVersion, 117 | "pwsdm": Kobo.DefaultPlatformId, # In the Android app this is the device model but Nickel sends the platform ID... 118 | "pwspos": Kobo.DeviceOs, 119 | "pwspov": Kobo.DeviceOsVersion, 120 | } 121 | 122 | response = self.Session.get("https://auth.kobobooks.com/ActivateOnWeb", params=params) 123 | response.raise_for_status() 124 | htmlResponse = response.text 125 | 126 | match = re.search('data-poll-endpoint="([^"]+)"', htmlResponse) 127 | if match is None: 128 | raise KoboException( 129 | "Can't find the activation poll endpoint in the response. The page format might have changed." 130 | ) 131 | activationCheckUrl = "https://auth.kobobooks.com" + html.unescape(match.group(1)) 132 | 133 | match = re.search(r"""qrcodegenerator/generate.+?%26code%3D(\d+)""", htmlResponse) 134 | if match is None: 135 | raise KoboException( 136 | "Can't find the activation code in the response. The page format might have changed." 137 | ) 138 | activationCode = match.group(1) 139 | 140 | return activationCheckUrl, activationCode 141 | 142 | def __RefreshAuthentication(self) -> None: 143 | headers = self.__GetHeaderWithAccessToken() 144 | 145 | postData = { 146 | "AppVersion": Kobo.ApplicationVersion, 147 | "ClientKey": base64.b64encode(Kobo.DefaultPlatformId.encode()).decode(), 148 | "PlatformId": Kobo.DefaultPlatformId, 149 | "RefreshToken": self.user.RefreshToken, 150 | } 151 | 152 | # The reauthentication hook is intentionally not set. 153 | response = self.Session.post( 154 | "https://storeapi.kobo.com/v1/auth/refresh", json=postData, headers=headers 155 | ) 156 | debug_data("RefreshAuth", postData, response.text) 157 | response.raise_for_status() 158 | jsonResponse = response.json() 159 | 160 | if jsonResponse["TokenType"] != "Bearer": 161 | raise KoboException( 162 | "Authentication refresh returned with an unsupported token type: '%s'" 163 | % jsonResponse["TokenType"] 164 | ) 165 | 166 | self.user.AccessToken = jsonResponse["AccessToken"] 167 | self.user.RefreshToken = jsonResponse["RefreshToken"] 168 | if not self.user.AreAuthenticationSettingsSet(): 169 | raise KoboException("Authentication settings are not set after authentication refresh.") 170 | 171 | Globals.Settings.Save() 172 | 173 | # This could be added to the session too. See the comment at GetHeaderWithAccessToken. 174 | def __GetReauthenticationHook(self) -> dict: 175 | # The hook's workflow is based on this: 176 | # https://github.com/requests/toolbelt/blob/master/requests_toolbelt/auth/http_proxy_digest.py 177 | def ReauthenticationHook(r, *args, **kwargs): 178 | debug_data("Reauth hook response", r.text) 179 | if r.status_code != requests.codes.unauthorized: # 401 180 | return 181 | 182 | print("Refreshing expired authentication token...", file=sys.stderr) 183 | 184 | # Consume content and release the original connection to allow our new request to reuse the same one. 185 | r.content 186 | r.close() 187 | 188 | prep = r.request.copy() 189 | 190 | # Refresh the authentication token and use it. 191 | self.__RefreshAuthentication() 192 | headers = self.__GetHeaderWithAccessToken() 193 | prep.headers["Authorization"] = headers["Authorization"] 194 | 195 | # Don't retry to reauthenticate this request again. 196 | prep.deregister_hook("response", ReauthenticationHook) 197 | 198 | # Resend the failed request. 199 | _r = r.connection.send(prep, **kwargs) 200 | _r.history.append(r) 201 | _r.request = prep 202 | return _r 203 | 204 | return {"response": ReauthenticationHook} 205 | 206 | def __GetMyBookListPage(self, syncToken: str) -> Tuple[list, str]: 207 | url = self.InitializationSettings["library_sync"] 208 | headers = self.__GetHeaderWithAccessToken() 209 | hooks = self.__GetReauthenticationHook() 210 | 211 | if len(syncToken) > 0: 212 | headers["x-kobo-synctoken"] = syncToken 213 | 214 | debug_data("GetMyBookListPage") 215 | response = self.Session.get(url, headers=headers, hooks=hooks) 216 | response.raise_for_status() 217 | bookList = response.json() 218 | 219 | syncToken = "" 220 | syncResult = response.headers.get("x-kobo-sync") 221 | if syncResult == "continue": 222 | syncToken = response.headers.get("x-kobo-synctoken", "") 223 | 224 | return bookList, syncToken 225 | 226 | def __GetContentAccessBook(self, productId: str, displayProfile: str) -> dict: 227 | url = self.InitializationSettings["content_access_book"].replace("{ProductId}", productId) 228 | params = {"DisplayProfile": displayProfile} 229 | headers = self.__GetHeaderWithAccessToken() 230 | hooks = self.__GetReauthenticationHook() 231 | 232 | debug_data("GetContentAccessBook") 233 | response = self.Session.get(url, params=params, headers=headers, hooks=hooks) 234 | response.raise_for_status() 235 | jsonResponse = response.json() 236 | return jsonResponse 237 | 238 | @staticmethod 239 | def __GetContentKeys(contentAccessBookResponse: dict) -> Dict[str, str]: 240 | jsonContentKeys = contentAccessBookResponse.get("ContentKeys") 241 | if jsonContentKeys is None: 242 | return {} 243 | 244 | contentKeys = {} 245 | for contentKey in jsonContentKeys: 246 | contentKeys[contentKey["Name"]] = contentKey["Value"] 247 | return contentKeys 248 | 249 | @staticmethod 250 | def __getContentUrls(bookMetadata: dict) -> str: 251 | keys = bookMetadata.keys() 252 | jsonContentUrls = None 253 | if 'ContentUrls' in keys: 254 | jsonContentUrls = bookMetadata.get("ContentUrls") 255 | if 'DownloadUrls' in keys: 256 | jsonContentUrls = bookMetadata.get('DownloadUrls') 257 | return jsonContentUrls 258 | 259 | def __GetDownloadInfo( 260 | self, bookMetadata: dict, isAudiobook: bool, displayProfile: str = None 261 | ) -> Tuple[str, bool]: 262 | displayProfile = displayProfile or Kobo.DisplayProfile 263 | productId = Kobo.GetProductId(bookMetadata) 264 | 265 | if not isAudiobook: 266 | jsonResponse = self.__GetContentAccessBook(productId, displayProfile) 267 | jsonContentUrls = Kobo.__getContentUrls(jsonResponse) 268 | else: 269 | jsonContentUrls = Kobo.__getContentUrls(bookMetadata) 270 | 271 | if jsonContentUrls is None: 272 | raise KoboException(f"Download URL can't be found for product {productId}.") 273 | 274 | if len(jsonContentUrls) == 0: 275 | raise KoboException( 276 | f"Download URL list is empty for product '{productId}'. If this is an archived book then it must be unarchived first on the Kobo website (https://www.kobo.com/help/en-US/article/1799/restoring-deleted-books-or-magazines)." 277 | ) 278 | 279 | for jsonContentUrl in jsonContentUrls: 280 | drm_keys = ['DrmType', 'DRMType'] 281 | drm_types = ["KDRM", "AdobeDrm"] 282 | # will be empty (falsey) if the drm listed doesn't match one of the drm_types 283 | hasDrm = [ 284 | jsonContentUrl.get(key) 285 | for key in drm_keys 286 | if (jsonContentUrl.get(key) in drm_types) 287 | ] 288 | 289 | download_keys = ['DownloadUrl', 'Url'] 290 | for key in download_keys: 291 | download_url = jsonContentUrl.get(key, None) 292 | if download_url: 293 | parsed = urllib.parse.urlparse(download_url) 294 | parsedQueries = urllib.parse.parse_qs(parsed.query) 295 | parsedQueries.pop( 296 | "b", None 297 | ) # https://github.com/TnS-hun/kobo-book-downloader/commit/54a7f464c7fdf552e62c209fb9c3e7e106dabd85 298 | download_url = parsed._replace( 299 | query=urllib.parse.urlencode(parsedQueries, doseq=True) 300 | ).geturl() 301 | return download_url, hasDrm 302 | 303 | message = f"Download URL for supported formats can't be found for product '{productId}'.\n" 304 | message += "Available formats:" 305 | for jsonContentUrl in jsonContentUrls: 306 | message += f'\nDRMType: \'{jsonContentUrl["DRMType"]}\', UrlFormat: \'{jsonContentUrl["UrlFormat"]}\'' 307 | raise KoboException(message) 308 | 309 | def __DownloadToFile(self, url, outputPath: str) -> None: 310 | response = self.Session.get(url, stream=True) 311 | response.raise_for_status() 312 | with open(outputPath, "wb") as f: 313 | for chunk in response.iter_content(chunk_size=1024 * 256): 314 | f.write(chunk) 315 | 316 | def __DownloadAudiobook(self, url, outputPath: str) -> None: 317 | response = self.Session.get(url) 318 | 319 | response.raise_for_status() 320 | if not os.path.isdir(outputPath): 321 | os.mkdir(outputPath) 322 | data = response.json() 323 | 324 | for item in data['Spine']: 325 | fileNum = int(item['Id']) + 1 326 | response = self.Session.get(item['Url'], stream=True) 327 | filePath = os.path.join(outputPath, str(fileNum) + '.' + item['FileExtension']) 328 | with open(filePath, "wb") as f: 329 | for chunk in response.iter_content(chunk_size=1024 * 256): 330 | f.write(chunk) 331 | 332 | @staticmethod 333 | def __GenerateRandomHexDigitString(length: int) -> str: 334 | id = "".join(secrets.choice(string.hexdigits) for _ in range(length)) 335 | return id.lower() 336 | 337 | # PUBLIC METHODS: 338 | @staticmethod 339 | def GetProductId(bookMetadata: dict) -> str: 340 | revisionId = bookMetadata.get('RevisionId') 341 | Id = bookMetadata.get('Id') 342 | return revisionId or Id 343 | 344 | # The initial device authentication request for a non-logged in user doesn't require a user key, and the returned 345 | # user key can't be used for anything. 346 | def AuthenticateDevice(self, userKey: str = "") -> None: 347 | if len(self.user.DeviceId) == 0: 348 | self.user.DeviceId = Kobo.__GenerateRandomHexDigitString(64) 349 | self.user.SerialNumber = Kobo.__GenerateRandomHexDigitString(32) 350 | self.user.AccessToken = "" 351 | self.user.RefreshToken = "" 352 | 353 | postData = { 354 | "AffiliateName": Kobo.Affiliate, 355 | "AppVersion": Kobo.ApplicationVersion, 356 | "ClientKey": base64.b64encode(Kobo.DefaultPlatformId.encode()).decode(), 357 | "DeviceId": self.user.DeviceId, 358 | "PlatformId": Kobo.DefaultPlatformId, 359 | "SerialNumber": self.user.SerialNumber, 360 | } 361 | 362 | if len(userKey) > 0: 363 | postData["UserKey"] = userKey 364 | 365 | response = self.Session.post("https://storeapi.kobo.com/v1/auth/device", json=postData) 366 | debug_data("AuthenticateDevice", response.text) 367 | response.raise_for_status() 368 | jsonResponse = response.json() 369 | 370 | if jsonResponse["TokenType"] != "Bearer": 371 | raise KoboException( 372 | "Device authentication returned with an unsupported token type: '%s'" 373 | % jsonResponse["TokenType"] 374 | ) 375 | 376 | self.user.AccessToken = jsonResponse["AccessToken"] 377 | self.user.RefreshToken = jsonResponse["RefreshToken"] 378 | if not self.user.AreAuthenticationSettingsSet(): 379 | raise KoboException("Authentication settings are not set after device authentication.") 380 | 381 | if len(userKey) > 0: 382 | self.user.UserKey = jsonResponse["UserKey"] 383 | 384 | Globals.Settings.Save() 385 | 386 | # Downloading archived books is not possible, the "content_access_book" API endpoint returns with empty ContentKeys 387 | # and ContentUrls for them. 388 | def Download(self, bookMetadata: dict, isAudiobook: bool, outputPath: str) -> None: 389 | downloadUrl, hasDrm = self.__GetDownloadInfo(bookMetadata, isAudiobook) 390 | revisionId = Kobo.GetProductId(bookMetadata) 391 | temporaryOutputPath = outputPath + ".downloading" 392 | 393 | try: 394 | if isAudiobook: 395 | self.__DownloadAudiobook(downloadUrl, outputPath) 396 | else: 397 | self.__DownloadToFile(downloadUrl, temporaryOutputPath) 398 | 399 | if hasDrm: 400 | if hasDrm[0] == 'AdobeDrm': 401 | print( 402 | "WARNING: Unable to parse the Adobe Digital Editions DRM. Saving it as an encrypted 'ade' file.", 403 | "Try https://github.com/apprenticeharper/DeDRM_tools", 404 | ) 405 | copyfile(temporaryOutputPath, outputPath + ".ade") 406 | else: 407 | contentAccessBook = self.__GetContentAccessBook(revisionId, self.DisplayProfile) 408 | contentKeys = Kobo.__GetContentKeys(contentAccessBook) 409 | drmRemover = KoboDrmRemover(self.user.DeviceId, self.user.UserId) 410 | drmRemover.RemoveDrm(temporaryOutputPath, outputPath, contentKeys) 411 | os.remove(temporaryOutputPath) 412 | else: 413 | if not isAudiobook: 414 | os.rename(temporaryOutputPath, outputPath) 415 | except: 416 | if os.path.isfile(temporaryOutputPath): 417 | os.remove(temporaryOutputPath) 418 | if os.path.isfile(outputPath): 419 | os.remove(outputPath) 420 | 421 | raise 422 | 423 | # The "library_sync" name and the synchronization tokens make it somewhat suspicious that we should use 424 | # "library_items" instead to get the My Books list, but "library_items" gives back less info (even with the 425 | # embed=ProductMetadata query parameter set). 426 | def GetMyBookList(self) -> list: 427 | if not self.user.AreAuthenticationSettingsSet(): 428 | raise NotAuthenticatedException(f'User {self.user.Email} is not authenticated') 429 | 430 | fullBookList = [] 431 | syncToken = "" 432 | while True: 433 | bookList, syncToken = self.__GetMyBookListPage(syncToken) 434 | fullBookList += bookList 435 | if len(syncToken) == 0: 436 | break 437 | 438 | return fullBookList 439 | 440 | def GetMyWishList(self) -> list: 441 | items = [] 442 | currentPageIndex = 0 443 | 444 | while True: 445 | url = self.InitializationSettings["user_wishlist"] 446 | headers = self.__GetHeaderWithAccessToken() 447 | hooks = self.__GetReauthenticationHook() 448 | 449 | params = { 450 | "PageIndex": currentPageIndex, 451 | "PageSize": 100, # 100 is the default if PageSize is not specified. 452 | } 453 | 454 | debug_data("GetMyWishList") 455 | response = self.Session.get(url, params=params, headers=headers, hooks=hooks) 456 | response.raise_for_status() 457 | wishList = response.json() 458 | 459 | items.extend(wishList["Items"]) 460 | 461 | currentPageIndex += 1 462 | if currentPageIndex >= wishList["TotalPageCount"]: 463 | break 464 | 465 | return items 466 | 467 | def GetBookInfo(self, productId: str) -> dict: 468 | audiobook_url = self.InitializationSettings["audiobook"].replace("{ProductId}", productId) 469 | ebook_url = self.InitializationSettings["book"].replace("{ProductId}", productId) 470 | headers = self.__GetHeaderWithAccessToken() 471 | hooks = self.__GetReauthenticationHook() 472 | debug_data("GetBookInfo") 473 | try: 474 | response = self.Session.get(ebook_url, headers=headers, hooks=hooks) 475 | response.raise_for_status() 476 | except requests.HTTPError as err: 477 | response = self.Session.get(audiobook_url, headers=headers, hooks=hooks) 478 | response.raise_for_status() 479 | jsonResponse = response.json() 480 | return jsonResponse 481 | 482 | def LoadInitializationSettings(self) -> None: 483 | """ 484 | to be called when authentication has been done 485 | """ 486 | headers = self.__GetHeaderWithAccessToken() 487 | hooks = self.__GetReauthenticationHook() 488 | debug_data("LoadInitializationSettings") 489 | response = self.Session.get( 490 | "https://storeapi.kobo.com/v1/initialization", headers=headers, hooks=hooks 491 | ) 492 | try: 493 | response.raise_for_status() 494 | jsonResponse = response.json() 495 | self.InitializationSettings = jsonResponse["Resources"] 496 | except requests.HTTPError as err: 497 | print(response.reason, response.text) 498 | raise err 499 | 500 | def Login(self) -> None: 501 | activationCheckUrl, activationCode = self.__ActivateOnWeb() 502 | 503 | print("") 504 | print("kobo-book-downloader uses the same web-based activation method to log in as the") 505 | print("Kobo e-readers. You will have to open the link below in your browser and enter") 506 | print("the code. You might need to login if kobo.com asks you to.") 507 | print("") 508 | print(f"Open https://www.kobo.com/activate and enter {activationCode}.") 509 | print("") 510 | print( 511 | "kobo-book-downloader will wait now and periodically check for the activation to complete." 512 | ) 513 | print("") 514 | 515 | userEmail, userId, userKey = self.__WaitTillActivation(activationCheckUrl) 516 | print("") 517 | 518 | # We don't call Settings.Save here, AuthenticateDevice will do that if it succeeds. 519 | self.user.Email = userEmail 520 | self.user.UserId = userId 521 | self.AuthenticateDevice(userKey) 522 | -------------------------------------------------------------------------------- /kobodl/koboDrmRemover.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import binascii 3 | import hashlib 4 | import zipfile 5 | from typing import Dict 6 | 7 | from Crypto.Cipher import AES 8 | from Crypto.Util import Padding 9 | 10 | 11 | # Based on obok.py by Physisticated. 12 | class KoboDrmRemover: 13 | def __init__(self, deviceId: str, userId: str): 14 | self.DeviceIdUserIdKey = KoboDrmRemover.__MakeDeviceIdUserIdKey(deviceId, userId) 15 | 16 | @staticmethod 17 | def __MakeDeviceIdUserIdKey(deviceId: str, userId: str) -> bytes: 18 | deviceIdUserId = (deviceId + userId).encode() 19 | key = hashlib.sha256(deviceIdUserId).hexdigest() 20 | return binascii.a2b_hex(key[32:]) 21 | 22 | def __DecryptContents(self, contents: bytes, contentKeyBase64: str) -> bytes: 23 | contentKey = base64.b64decode(contentKeyBase64) 24 | keyAes = AES.new(self.DeviceIdUserIdKey, AES.MODE_ECB) 25 | decryptedContentKey = keyAes.decrypt(contentKey) 26 | 27 | contentAes = AES.new(decryptedContentKey, AES.MODE_ECB) 28 | decryptedContents = contentAes.decrypt(contents) 29 | return Padding.unpad(decryptedContents, AES.block_size, "pkcs7") 30 | 31 | def RemoveDrm(self, inputPath: str, outputPath: str, contentKeys: Dict[str, str]) -> None: 32 | with zipfile.ZipFile(inputPath, "r") as inputZip: 33 | with zipfile.ZipFile(outputPath, "w", zipfile.ZIP_DEFLATED) as outputZip: 34 | for filename in inputZip.namelist(): 35 | contents = inputZip.read(filename) 36 | contentKeyBase64 = contentKeys.get(filename, None) 37 | if contentKeyBase64 is not None: 38 | contents = self.__DecryptContents(contents, contentKeyBase64) 39 | if filename == "mimetype": 40 | outputZip.writestr(filename, contents, compress_type=zipfile.ZIP_STORED) 41 | else: 42 | outputZip.writestr(filename, contents) 43 | -------------------------------------------------------------------------------- /kobodl/settings.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | import os 3 | from typing import List, Union 4 | 5 | from dataclasses_json import dataclass_json 6 | 7 | 8 | @dataclass_json 9 | @dataclasses.dataclass 10 | class User: 11 | Email: str = "" 12 | DeviceId: str = "" 13 | SerialNumber: str = "" 14 | AccessToken: str = "" 15 | RefreshToken: str = "" 16 | UserId: str = "" 17 | UserKey: str = "" 18 | 19 | def AreAuthenticationSettingsSet(self) -> bool: 20 | return len(self.DeviceId) > 0 and len(self.AccessToken) > 0 and len(self.RefreshToken) > 0 21 | 22 | def IsLoggedIn(self) -> bool: 23 | return len(self.UserId) > 0 and len(self.UserKey) > 0 24 | 25 | 26 | @dataclass_json 27 | @dataclasses.dataclass 28 | class UserList: 29 | users: List[User] = dataclasses.field(default_factory=list) 30 | 31 | def getUser(self, identifier: str) -> Union[User, None]: 32 | for user in self.users: 33 | if ( 34 | user.Email == identifier 35 | or user.UserKey == identifier 36 | or user.DeviceId == identifier 37 | ): 38 | return user 39 | return None 40 | 41 | def removeUser(self, identifier: str) -> Union[User, None]: 42 | """returns the removed user""" 43 | user = self.getUser(identifier) 44 | if user: 45 | i = self.users.index(user) 46 | return self.users.pop(i) 47 | return None 48 | 49 | 50 | class Settings: 51 | def __init__(self, configpath=None): 52 | self.SettingsFilePath = configpath or Settings.__GetCacheFilePath() 53 | self.UserList = self.Load() 54 | 55 | def Load(self) -> UserList: 56 | if not os.path.isfile(self.SettingsFilePath): 57 | return UserList() 58 | with open(self.SettingsFilePath, "r") as f: 59 | jsonText = f.read() 60 | return UserList.from_json(jsonText) 61 | 62 | def Save(self) -> None: 63 | with open(self.SettingsFilePath, "w") as f: 64 | f.write(self.UserList.to_json(indent=4)) 65 | 66 | @staticmethod 67 | def __GetCacheFilePath() -> str: 68 | cacheHome = os.environ.get("XDG_CONFIG_HOME") 69 | if (cacheHome is None) or (not os.path.isdir(cacheHome)): 70 | home = os.path.expanduser("~") 71 | cacheHome = os.path.join(home, ".config") 72 | if not os.path.isdir(cacheHome): 73 | cacheHome = home 74 | 75 | return os.path.join(cacheHome, "kobodl.json") 76 | -------------------------------------------------------------------------------- /kobodl/templates/activation_form.html: -------------------------------------------------------------------------------- 1 |

2 | 3 | 4 |
5 | 13 | 19 |
20 | 21 | 22 | 36 |
37 | 38 | 105 | -------------------------------------------------------------------------------- /kobodl/templates/books.j2: -------------------------------------------------------------------------------- 1 | 2 | {% include "header.html" %} 3 |

Books

4 | {% include "error.j2" %} 5 | {% include "success.j2" %} 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | {% for book in books %} 16 | 17 | 32 | 33 | 34 | 35 | {% endfor %} 36 | 37 |
Title Author Owner
18 | {% if not book.Audiobook %} 19 | 23 | {{ book.Title }} 24 | 25 | {% else %} 26 | {{ book.Title }} 🎧 27 | {% endif %} 28 | {% if book.Archived %} 29 | 🗃️ 30 | {% endif %} 31 | {{ book.Author }}{{ book.Owner.Email }}
38 | {% include "footer.html" %} 39 | -------------------------------------------------------------------------------- /kobodl/templates/error.j2: -------------------------------------------------------------------------------- 1 | {% if error %} 2 | 6 | {% endif %} 7 | -------------------------------------------------------------------------------- /kobodl/templates/footer.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /kobodl/templates/header.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | kobo downloader 4 | 5 | 6 | 7 | 31 |
32 | -------------------------------------------------------------------------------- /kobodl/templates/success.j2: -------------------------------------------------------------------------------- 1 | {% if success %} 2 | 6 | {% endif %} 7 | -------------------------------------------------------------------------------- /kobodl/templates/users.j2: -------------------------------------------------------------------------------- 1 | {% include "header.html" %} 2 | 3 | {% if users %} 4 |

Users

5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | {% for user in users %} 15 | 16 | 17 | 18 | 21 | 26 | 27 | {% endfor %} 28 | 29 |
Email DeviceId Books
{{ user.Email }}{{ user.DeviceId }} 19 | books 20 | 22 |
23 | 24 |
25 |
30 | {% endif %} 31 | 32 |

Add User

33 | {% include "error.j2" %} 34 | {% include "activation_form.html" %} 35 | 36 | {% include "footer.html" %} 37 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "beautifulsoup4" 5 | version = "4.13.3" 6 | description = "Screen-scraping library" 7 | optional = false 8 | python-versions = ">=3.7.0" 9 | groups = ["main"] 10 | files = [ 11 | {file = "beautifulsoup4-4.13.3-py3-none-any.whl", hash = "sha256:99045d7d3f08f91f0d656bc9b7efbae189426cd913d830294a15eefa0ea4df16"}, 12 | {file = "beautifulsoup4-4.13.3.tar.gz", hash = "sha256:1bd32405dacc920b42b83ba01644747ed77456a65760e285fbc47633ceddaf8b"}, 13 | ] 14 | 15 | [package.dependencies] 16 | soupsieve = ">1.2" 17 | typing-extensions = ">=4.0.0" 18 | 19 | [package.extras] 20 | cchardet = ["cchardet"] 21 | chardet = ["chardet"] 22 | charset-normalizer = ["charset-normalizer"] 23 | html5lib = ["html5lib"] 24 | lxml = ["lxml"] 25 | 26 | [[package]] 27 | name = "blinker" 28 | version = "1.9.0" 29 | description = "Fast, simple object-to-object and broadcast signaling" 30 | optional = false 31 | python-versions = ">=3.9" 32 | groups = ["main"] 33 | files = [ 34 | {file = "blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc"}, 35 | {file = "blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf"}, 36 | ] 37 | 38 | [[package]] 39 | name = "certifi" 40 | version = "2025.1.31" 41 | description = "Python package for providing Mozilla's CA Bundle." 42 | optional = false 43 | python-versions = ">=3.6" 44 | groups = ["main"] 45 | files = [ 46 | {file = "certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe"}, 47 | {file = "certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651"}, 48 | ] 49 | 50 | [[package]] 51 | name = "charset-normalizer" 52 | version = "3.4.1" 53 | description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." 54 | optional = false 55 | python-versions = ">=3.7" 56 | groups = ["main"] 57 | files = [ 58 | {file = "charset_normalizer-3.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:91b36a978b5ae0ee86c394f5a54d6ef44db1de0815eb43de826d41d21e4af3de"}, 59 | {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7461baadb4dc00fd9e0acbe254e3d7d2112e7f92ced2adc96e54ef6501c5f176"}, 60 | {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e218488cd232553829be0664c2292d3af2eeeb94b32bea483cf79ac6a694e037"}, 61 | {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:80ed5e856eb7f30115aaf94e4a08114ccc8813e6ed1b5efa74f9f82e8509858f"}, 62 | {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b010a7a4fd316c3c484d482922d13044979e78d1861f0e0650423144c616a46a"}, 63 | {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4532bff1b8421fd0a320463030c7520f56a79c9024a4e88f01c537316019005a"}, 64 | {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d973f03c0cb71c5ed99037b870f2be986c3c05e63622c017ea9816881d2dd247"}, 65 | {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a3bd0dcd373514dcec91c411ddb9632c0d7d92aed7093b8c3bbb6d69ca74408"}, 66 | {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d9c3cdf5390dcd29aa8056d13e8e99526cda0305acc038b96b30352aff5ff2bb"}, 67 | {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2bdfe3ac2e1bbe5b59a1a63721eb3b95fc9b6817ae4a46debbb4e11f6232428d"}, 68 | {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:eab677309cdb30d047996b36d34caeda1dc91149e4fdca0b1a039b3f79d9a807"}, 69 | {file = "charset_normalizer-3.4.1-cp310-cp310-win32.whl", hash = "sha256:c0429126cf75e16c4f0ad00ee0eae4242dc652290f940152ca8c75c3a4b6ee8f"}, 70 | {file = "charset_normalizer-3.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:9f0b8b1c6d84c8034a44893aba5e767bf9c7a211e313a9605d9c617d7083829f"}, 71 | {file = "charset_normalizer-3.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8bfa33f4f2672964266e940dd22a195989ba31669bd84629f05fab3ef4e2d125"}, 72 | {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28bf57629c75e810b6ae989f03c0828d64d6b26a5e205535585f96093e405ed1"}, 73 | {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f08ff5e948271dc7e18a35641d2f11a4cd8dfd5634f55228b691e62b37125eb3"}, 74 | {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:234ac59ea147c59ee4da87a0c0f098e9c8d169f4dc2a159ef720f1a61bbe27cd"}, 75 | {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd4ec41f914fa74ad1b8304bbc634b3de73d2a0889bd32076342a573e0779e00"}, 76 | {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eea6ee1db730b3483adf394ea72f808b6e18cf3cb6454b4d86e04fa8c4327a12"}, 77 | {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c96836c97b1238e9c9e3fe90844c947d5afbf4f4c92762679acfe19927d81d77"}, 78 | {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4d86f7aff21ee58f26dcf5ae81a9addbd914115cdebcbb2217e4f0ed8982e146"}, 79 | {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:09b5e6733cbd160dcc09589227187e242a30a49ca5cefa5a7edd3f9d19ed53fd"}, 80 | {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5777ee0881f9499ed0f71cc82cf873d9a0ca8af166dfa0af8ec4e675b7df48e6"}, 81 | {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:237bdbe6159cff53b4f24f397d43c6336c6b0b42affbe857970cefbb620911c8"}, 82 | {file = "charset_normalizer-3.4.1-cp311-cp311-win32.whl", hash = "sha256:8417cb1f36cc0bc7eaba8ccb0e04d55f0ee52df06df3ad55259b9a323555fc8b"}, 83 | {file = "charset_normalizer-3.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7f50a1f8c450f3925cb367d011448c39239bb3eb4117c36a6d354794de4ce76"}, 84 | {file = "charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545"}, 85 | {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7"}, 86 | {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757"}, 87 | {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa"}, 88 | {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d"}, 89 | {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616"}, 90 | {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b"}, 91 | {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d"}, 92 | {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a"}, 93 | {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9"}, 94 | {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1"}, 95 | {file = "charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35"}, 96 | {file = "charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f"}, 97 | {file = "charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda"}, 98 | {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313"}, 99 | {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9"}, 100 | {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b"}, 101 | {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11"}, 102 | {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f"}, 103 | {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd"}, 104 | {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2"}, 105 | {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886"}, 106 | {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601"}, 107 | {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd"}, 108 | {file = "charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407"}, 109 | {file = "charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971"}, 110 | {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f30bf9fd9be89ecb2360c7d94a711f00c09b976258846efe40db3d05828e8089"}, 111 | {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:97f68b8d6831127e4787ad15e6757232e14e12060bec17091b85eb1486b91d8d"}, 112 | {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7974a0b5ecd505609e3b19742b60cee7aa2aa2fb3151bc917e6e2646d7667dcf"}, 113 | {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc54db6c8593ef7d4b2a331b58653356cf04f67c960f584edb7c3d8c97e8f39e"}, 114 | {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:311f30128d7d333eebd7896965bfcfbd0065f1716ec92bd5638d7748eb6f936a"}, 115 | {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:7d053096f67cd1241601111b698f5cad775f97ab25d81567d3f59219b5f1adbd"}, 116 | {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:807f52c1f798eef6cf26beb819eeb8819b1622ddfeef9d0977a8502d4db6d534"}, 117 | {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:dccbe65bd2f7f7ec22c4ff99ed56faa1e9f785482b9bbd7c717e26fd723a1d1e"}, 118 | {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:2fb9bd477fdea8684f78791a6de97a953c51831ee2981f8e4f583ff3b9d9687e"}, 119 | {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:01732659ba9b5b873fc117534143e4feefecf3b2078b0a6a2e925271bb6f4cfa"}, 120 | {file = "charset_normalizer-3.4.1-cp37-cp37m-win32.whl", hash = "sha256:7a4f97a081603d2050bfaffdefa5b02a9ec823f8348a572e39032caa8404a487"}, 121 | {file = "charset_normalizer-3.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:7b1bef6280950ee6c177b326508f86cad7ad4dff12454483b51d8b7d673a2c5d"}, 122 | {file = "charset_normalizer-3.4.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ecddf25bee22fe4fe3737a399d0d177d72bc22be6913acfab364b40bce1ba83c"}, 123 | {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c60ca7339acd497a55b0ea5d506b2a2612afb2826560416f6894e8b5770d4a9"}, 124 | {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b7b2d86dd06bfc2ade3312a83a5c364c7ec2e3498f8734282c6c3d4b07b346b8"}, 125 | {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd78cfcda14a1ef52584dbb008f7ac81c1328c0f58184bf9a84c49c605002da6"}, 126 | {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e27f48bcd0957c6d4cb9d6fa6b61d192d0b13d5ef563e5f2ae35feafc0d179c"}, 127 | {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01ad647cdd609225c5350561d084b42ddf732f4eeefe6e678765636791e78b9a"}, 128 | {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:619a609aa74ae43d90ed2e89bdd784765de0a25ca761b93e196d938b8fd1dbbd"}, 129 | {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:89149166622f4db9b4b6a449256291dc87a99ee53151c74cbd82a53c8c2f6ccd"}, 130 | {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:7709f51f5f7c853f0fb938bcd3bc59cdfdc5203635ffd18bf354f6967ea0f824"}, 131 | {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:345b0426edd4e18138d6528aed636de7a9ed169b4aaf9d61a8c19e39d26838ca"}, 132 | {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0907f11d019260cdc3f94fbdb23ff9125f6b5d1039b76003b5b0ac9d6a6c9d5b"}, 133 | {file = "charset_normalizer-3.4.1-cp38-cp38-win32.whl", hash = "sha256:ea0d8d539afa5eb2728aa1932a988a9a7af94f18582ffae4bc10b3fbdad0626e"}, 134 | {file = "charset_normalizer-3.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:329ce159e82018d646c7ac45b01a430369d526569ec08516081727a20e9e4af4"}, 135 | {file = "charset_normalizer-3.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b97e690a2118911e39b4042088092771b4ae3fc3aa86518f84b8cf6888dbdb41"}, 136 | {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78baa6d91634dfb69ec52a463534bc0df05dbd546209b79a3880a34487f4b84f"}, 137 | {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1a2bc9f351a75ef49d664206d51f8e5ede9da246602dc2d2726837620ea034b2"}, 138 | {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75832c08354f595c760a804588b9357d34ec00ba1c940c15e31e96d902093770"}, 139 | {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0af291f4fe114be0280cdd29d533696a77b5b49cfde5467176ecab32353395c4"}, 140 | {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0167ddc8ab6508fe81860a57dd472b2ef4060e8d378f0cc555707126830f2537"}, 141 | {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2a75d49014d118e4198bcee5ee0a6f25856b29b12dbf7cd012791f8a6cc5c496"}, 142 | {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:363e2f92b0f0174b2f8238240a1a30142e3db7b957a5dd5689b0e75fb717cc78"}, 143 | {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ab36c8eb7e454e34e60eb55ca5d241a5d18b2c6244f6827a30e451c42410b5f7"}, 144 | {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:4c0907b1928a36d5a998d72d64d8eaa7244989f7aaaf947500d3a800c83a3fd6"}, 145 | {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:04432ad9479fa40ec0f387795ddad4437a2b50417c69fa275e212933519ff294"}, 146 | {file = "charset_normalizer-3.4.1-cp39-cp39-win32.whl", hash = "sha256:3bed14e9c89dcb10e8f3a29f9ccac4955aebe93c71ae803af79265c9ca5644c5"}, 147 | {file = "charset_normalizer-3.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:49402233c892a461407c512a19435d1ce275543138294f7ef013f0b63d5d3765"}, 148 | {file = "charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85"}, 149 | {file = "charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3"}, 150 | ] 151 | 152 | [[package]] 153 | name = "click" 154 | version = "8.1.8" 155 | description = "Composable command line interface toolkit" 156 | optional = false 157 | python-versions = ">=3.7" 158 | groups = ["main"] 159 | files = [ 160 | {file = "click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2"}, 161 | {file = "click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a"}, 162 | ] 163 | 164 | [package.dependencies] 165 | colorama = {version = "*", markers = "platform_system == \"Windows\""} 166 | 167 | [[package]] 168 | name = "colorama" 169 | version = "0.4.6" 170 | description = "Cross-platform colored terminal text." 171 | optional = false 172 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 173 | groups = ["main", "dev"] 174 | markers = "platform_system == \"Windows\"" 175 | files = [ 176 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 177 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 178 | ] 179 | 180 | [[package]] 181 | name = "dataclasses" 182 | version = "0.6" 183 | description = "A backport of the dataclasses module for Python 3.6" 184 | optional = false 185 | python-versions = "*" 186 | groups = ["main"] 187 | files = [ 188 | {file = "dataclasses-0.6-py3-none-any.whl", hash = "sha256:454a69d788c7fda44efd71e259be79577822f5e3f53f029a22d08004e951dc9f"}, 189 | {file = "dataclasses-0.6.tar.gz", hash = "sha256:6988bd2b895eef432d562370bb707d540f32f7360ab13da45340101bc2307d84"}, 190 | ] 191 | 192 | [[package]] 193 | name = "dataclasses-json" 194 | version = "0.5.9" 195 | description = "Easily serialize dataclasses to and from JSON" 196 | optional = false 197 | python-versions = ">=3.6" 198 | groups = ["main"] 199 | files = [ 200 | {file = "dataclasses-json-0.5.9.tar.gz", hash = "sha256:e9ac87b73edc0141aafbce02b44e93553c3123ad574958f0fe52a534b6707e8e"}, 201 | {file = "dataclasses_json-0.5.9-py3-none-any.whl", hash = "sha256:1280542631df1c375b7bc92e5b86d39e06c44760d7e3571a537b3b8acabf2f0c"}, 202 | ] 203 | 204 | [package.dependencies] 205 | marshmallow = ">=3.3.0,<4.0.0" 206 | marshmallow-enum = ">=1.5.1,<2.0.0" 207 | typing-inspect = ">=0.4.0" 208 | 209 | [package.extras] 210 | dev = ["flake8", "hypothesis", "ipython", "mypy (>=0.710)", "portray", "pytest (>=7.2.0)", "setuptools", "simplejson", "twine", "types-dataclasses ; python_version == \"3.6\"", "wheel"] 211 | 212 | [[package]] 213 | name = "distlib" 214 | version = "0.3.9" 215 | description = "Distribution utilities" 216 | optional = false 217 | python-versions = "*" 218 | groups = ["dev"] 219 | files = [ 220 | {file = "distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87"}, 221 | {file = "distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403"}, 222 | ] 223 | 224 | [[package]] 225 | name = "filelock" 226 | version = "3.18.0" 227 | description = "A platform independent file lock." 228 | optional = false 229 | python-versions = ">=3.9" 230 | groups = ["dev"] 231 | files = [ 232 | {file = "filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de"}, 233 | {file = "filelock-3.18.0.tar.gz", hash = "sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2"}, 234 | ] 235 | 236 | [package.extras] 237 | docs = ["furo (>=2024.8.6)", "sphinx (>=8.1.3)", "sphinx-autodoc-typehints (>=3)"] 238 | testing = ["covdefaults (>=2.3)", "coverage (>=7.6.10)", "diff-cover (>=9.2.1)", "pytest (>=8.3.4)", "pytest-asyncio (>=0.25.2)", "pytest-cov (>=6)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv (>=20.28.1)"] 239 | typing = ["typing-extensions (>=4.12.2) ; python_version < \"3.11\""] 240 | 241 | [[package]] 242 | name = "flask" 243 | version = "3.1.1" 244 | description = "A simple framework for building complex web applications." 245 | optional = false 246 | python-versions = ">=3.9" 247 | groups = ["main"] 248 | files = [ 249 | {file = "flask-3.1.1-py3-none-any.whl", hash = "sha256:07aae2bb5eaf77993ef57e357491839f5fd9f4dc281593a81a9e4d79a24f295c"}, 250 | {file = "flask-3.1.1.tar.gz", hash = "sha256:284c7b8f2f58cb737f0cf1c30fd7eaf0ccfcde196099d24ecede3fc2005aa59e"}, 251 | ] 252 | 253 | [package.dependencies] 254 | blinker = ">=1.9.0" 255 | click = ">=8.1.3" 256 | importlib-metadata = {version = ">=3.6.0", markers = "python_version < \"3.10\""} 257 | itsdangerous = ">=2.2.0" 258 | jinja2 = ">=3.1.2" 259 | markupsafe = ">=2.1.1" 260 | werkzeug = ">=3.1.0" 261 | 262 | [package.extras] 263 | async = ["asgiref (>=3.2)"] 264 | dotenv = ["python-dotenv"] 265 | 266 | [[package]] 267 | name = "idna" 268 | version = "3.10" 269 | description = "Internationalized Domain Names in Applications (IDNA)" 270 | optional = false 271 | python-versions = ">=3.6" 272 | groups = ["main"] 273 | files = [ 274 | {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, 275 | {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, 276 | ] 277 | 278 | [package.extras] 279 | all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] 280 | 281 | [[package]] 282 | name = "importlib-metadata" 283 | version = "8.6.1" 284 | description = "Read metadata from Python packages" 285 | optional = false 286 | python-versions = ">=3.9" 287 | groups = ["main"] 288 | markers = "python_version == \"3.9\"" 289 | files = [ 290 | {file = "importlib_metadata-8.6.1-py3-none-any.whl", hash = "sha256:02a89390c1e15fdfdc0d7c6b25cb3e62650d0494005c97d6f148bf5b9787525e"}, 291 | {file = "importlib_metadata-8.6.1.tar.gz", hash = "sha256:310b41d755445d74569f993ccfc22838295d9fe005425094fad953d7f15c8580"}, 292 | ] 293 | 294 | [package.dependencies] 295 | zipp = ">=3.20" 296 | 297 | [package.extras] 298 | check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] 299 | cover = ["pytest-cov"] 300 | doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] 301 | enabler = ["pytest-enabler (>=2.2)"] 302 | perf = ["ipython"] 303 | test = ["flufl.flake8", "importlib_resources (>=1.3) ; python_version < \"3.9\"", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] 304 | type = ["pytest-mypy"] 305 | 306 | [[package]] 307 | name = "itsdangerous" 308 | version = "2.2.0" 309 | description = "Safely pass data to untrusted environments and back." 310 | optional = false 311 | python-versions = ">=3.8" 312 | groups = ["main"] 313 | files = [ 314 | {file = "itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef"}, 315 | {file = "itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173"}, 316 | ] 317 | 318 | [[package]] 319 | name = "jinja2" 320 | version = "3.1.6" 321 | description = "A very fast and expressive template engine." 322 | optional = false 323 | python-versions = ">=3.7" 324 | groups = ["main"] 325 | files = [ 326 | {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, 327 | {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, 328 | ] 329 | 330 | [package.dependencies] 331 | MarkupSafe = ">=2.0" 332 | 333 | [package.extras] 334 | i18n = ["Babel (>=2.7)"] 335 | 336 | [[package]] 337 | name = "markupsafe" 338 | version = "3.0.2" 339 | description = "Safely add untrusted strings to HTML/XML markup." 340 | optional = false 341 | python-versions = ">=3.9" 342 | groups = ["main"] 343 | files = [ 344 | {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8"}, 345 | {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158"}, 346 | {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579"}, 347 | {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d"}, 348 | {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb"}, 349 | {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b"}, 350 | {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c"}, 351 | {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171"}, 352 | {file = "MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50"}, 353 | {file = "MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a"}, 354 | {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d"}, 355 | {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93"}, 356 | {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832"}, 357 | {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84"}, 358 | {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca"}, 359 | {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798"}, 360 | {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e"}, 361 | {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4"}, 362 | {file = "MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d"}, 363 | {file = "MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b"}, 364 | {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf"}, 365 | {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225"}, 366 | {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028"}, 367 | {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8"}, 368 | {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c"}, 369 | {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557"}, 370 | {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22"}, 371 | {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48"}, 372 | {file = "MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30"}, 373 | {file = "MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87"}, 374 | {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd"}, 375 | {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430"}, 376 | {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094"}, 377 | {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396"}, 378 | {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79"}, 379 | {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a"}, 380 | {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca"}, 381 | {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c"}, 382 | {file = "MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1"}, 383 | {file = "MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f"}, 384 | {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c"}, 385 | {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb"}, 386 | {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c"}, 387 | {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d"}, 388 | {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe"}, 389 | {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5"}, 390 | {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a"}, 391 | {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9"}, 392 | {file = "MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6"}, 393 | {file = "MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f"}, 394 | {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a"}, 395 | {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff"}, 396 | {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13"}, 397 | {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144"}, 398 | {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29"}, 399 | {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0"}, 400 | {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0"}, 401 | {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178"}, 402 | {file = "MarkupSafe-3.0.2-cp39-cp39-win32.whl", hash = "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f"}, 403 | {file = "MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a"}, 404 | {file = "markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0"}, 405 | ] 406 | 407 | [[package]] 408 | name = "marshmallow" 409 | version = "3.26.1" 410 | description = "A lightweight library for converting complex datatypes to and from native Python datatypes." 411 | optional = false 412 | python-versions = ">=3.9" 413 | groups = ["main"] 414 | files = [ 415 | {file = "marshmallow-3.26.1-py3-none-any.whl", hash = "sha256:3350409f20a70a7e4e11a27661187b77cdcaeb20abca41c1454fe33636bea09c"}, 416 | {file = "marshmallow-3.26.1.tar.gz", hash = "sha256:e6d8affb6cb61d39d26402096dc0aee12d5a26d490a121f118d2e81dc0719dc6"}, 417 | ] 418 | 419 | [package.dependencies] 420 | packaging = ">=17.0" 421 | 422 | [package.extras] 423 | dev = ["marshmallow[tests]", "pre-commit (>=3.5,<5.0)", "tox"] 424 | docs = ["autodocsumm (==0.2.14)", "furo (==2024.8.6)", "sphinx (==8.1.3)", "sphinx-copybutton (==0.5.2)", "sphinx-issues (==5.0.0)", "sphinxext-opengraph (==0.9.1)"] 425 | tests = ["pytest", "simplejson"] 426 | 427 | [[package]] 428 | name = "marshmallow-enum" 429 | version = "1.5.1" 430 | description = "Enum field for Marshmallow" 431 | optional = false 432 | python-versions = "*" 433 | groups = ["main"] 434 | files = [ 435 | {file = "marshmallow-enum-1.5.1.tar.gz", hash = "sha256:38e697e11f45a8e64b4a1e664000897c659b60aa57bfa18d44e226a9920b6e58"}, 436 | {file = "marshmallow_enum-1.5.1-py2.py3-none-any.whl", hash = "sha256:57161ab3dbfde4f57adeb12090f39592e992b9c86d206d02f6bd03ebec60f072"}, 437 | ] 438 | 439 | [package.dependencies] 440 | marshmallow = ">=2.0.0" 441 | 442 | [[package]] 443 | name = "mypy-extensions" 444 | version = "1.0.0" 445 | description = "Type system extensions for programs checked with the mypy type checker." 446 | optional = false 447 | python-versions = ">=3.5" 448 | groups = ["main"] 449 | files = [ 450 | {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, 451 | {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, 452 | ] 453 | 454 | [[package]] 455 | name = "packaging" 456 | version = "24.2" 457 | description = "Core utilities for Python packages" 458 | optional = false 459 | python-versions = ">=3.8" 460 | groups = ["main", "dev"] 461 | files = [ 462 | {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, 463 | {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, 464 | ] 465 | 466 | [[package]] 467 | name = "platformdirs" 468 | version = "4.3.7" 469 | description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." 470 | optional = false 471 | python-versions = ">=3.9" 472 | groups = ["dev"] 473 | files = [ 474 | {file = "platformdirs-4.3.7-py3-none-any.whl", hash = "sha256:a03875334331946f13c549dbd8f4bac7a13a50a895a0eb1e8c6a8ace80d40a94"}, 475 | {file = "platformdirs-4.3.7.tar.gz", hash = "sha256:eb437d586b6a0986388f0d6f74aa0cde27b48d0e3d66843640bfb6bdcdb6e351"}, 476 | ] 477 | 478 | [package.extras] 479 | docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.1.3)", "sphinx-autodoc-typehints (>=3)"] 480 | test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.4)", "pytest-cov (>=6)", "pytest-mock (>=3.14)"] 481 | type = ["mypy (>=1.14.1)"] 482 | 483 | [[package]] 484 | name = "pluggy" 485 | version = "1.5.0" 486 | description = "plugin and hook calling mechanisms for python" 487 | optional = false 488 | python-versions = ">=3.8" 489 | groups = ["dev"] 490 | files = [ 491 | {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, 492 | {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, 493 | ] 494 | 495 | [package.extras] 496 | dev = ["pre-commit", "tox"] 497 | testing = ["pytest", "pytest-benchmark"] 498 | 499 | [[package]] 500 | name = "py" 501 | version = "1.11.0" 502 | description = "library with cross-python path, ini-parsing, io, code, log facilities" 503 | optional = false 504 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 505 | groups = ["dev"] 506 | files = [ 507 | {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, 508 | {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, 509 | ] 510 | 511 | [[package]] 512 | name = "pycryptodome" 513 | version = "3.22.0" 514 | description = "Cryptographic library for Python" 515 | optional = false 516 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 517 | groups = ["main"] 518 | files = [ 519 | {file = "pycryptodome-3.22.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:96e73527c9185a3d9b4c6d1cfb4494f6ced418573150be170f6580cb975a7f5a"}, 520 | {file = "pycryptodome-3.22.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:9e1bb165ea1dc83a11e5dbbe00ef2c378d148f3a2d3834fb5ba4e0f6fd0afe4b"}, 521 | {file = "pycryptodome-3.22.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:d4d1174677855c266eed5c4b4e25daa4225ad0c9ffe7584bb1816767892545d0"}, 522 | {file = "pycryptodome-3.22.0-cp27-cp27m-win32.whl", hash = "sha256:9dbb749cef71c28271484cbef684f9b5b19962153487735411e1020ca3f59cb1"}, 523 | {file = "pycryptodome-3.22.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:f1ae7beb64d4fc4903a6a6cca80f1f448e7a8a95b77d106f8a29f2eb44d17547"}, 524 | {file = "pycryptodome-3.22.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:a26bcfee1293b7257c83b0bd13235a4ee58165352be4f8c45db851ba46996dc6"}, 525 | {file = "pycryptodome-3.22.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:009e1c80eea42401a5bd5983c4bab8d516aef22e014a4705622e24e6d9d703c6"}, 526 | {file = "pycryptodome-3.22.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:3b76fa80daeff9519d7e9f6d9e40708f2fce36b9295a847f00624a08293f4f00"}, 527 | {file = "pycryptodome-3.22.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a31fa5914b255ab62aac9265654292ce0404f6b66540a065f538466474baedbc"}, 528 | {file = "pycryptodome-3.22.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0092fd476701eeeb04df5cc509d8b739fa381583cda6a46ff0a60639b7cd70d"}, 529 | {file = "pycryptodome-3.22.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:18d5b0ddc7cf69231736d778bd3ae2b3efb681ae33b64b0c92fb4626bb48bb89"}, 530 | {file = "pycryptodome-3.22.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f6cf6aa36fcf463e622d2165a5ad9963b2762bebae2f632d719dfb8544903cf5"}, 531 | {file = "pycryptodome-3.22.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:aec7b40a7ea5af7c40f8837adf20a137d5e11a6eb202cde7e588a48fb2d871a8"}, 532 | {file = "pycryptodome-3.22.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:d21c1eda2f42211f18a25db4eaf8056c94a8563cd39da3683f89fe0d881fb772"}, 533 | {file = "pycryptodome-3.22.0-cp37-abi3-win32.whl", hash = "sha256:f02baa9f5e35934c6e8dcec91fcde96612bdefef6e442813b8ea34e82c84bbfb"}, 534 | {file = "pycryptodome-3.22.0-cp37-abi3-win_amd64.whl", hash = "sha256:d086aed307e96d40c23c42418cbbca22ecc0ab4a8a0e24f87932eeab26c08627"}, 535 | {file = "pycryptodome-3.22.0-pp27-pypy_73-manylinux2010_x86_64.whl", hash = "sha256:98fd9da809d5675f3a65dcd9ed384b9dc67edab6a4cda150c5870a8122ec961d"}, 536 | {file = "pycryptodome-3.22.0-pp27-pypy_73-win32.whl", hash = "sha256:37ddcd18284e6b36b0a71ea495a4c4dca35bb09ccc9bfd5b91bfaf2321f131c1"}, 537 | {file = "pycryptodome-3.22.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b4bdce34af16c1dcc7f8c66185684be15f5818afd2a82b75a4ce6b55f9783e13"}, 538 | {file = "pycryptodome-3.22.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2988ffcd5137dc2d27eb51cd18c0f0f68e5b009d5fec56fbccb638f90934f333"}, 539 | {file = "pycryptodome-3.22.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e653519dedcd1532788547f00eeb6108cc7ce9efdf5cc9996abce0d53f95d5a9"}, 540 | {file = "pycryptodome-3.22.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f5810bc7494e4ac12a4afef5a32218129e7d3890ce3f2b5ec520cc69eb1102ad"}, 541 | {file = "pycryptodome-3.22.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e7514a1aebee8e85802d154fdb261381f1cb9b7c5a54594545145b8ec3056ae6"}, 542 | {file = "pycryptodome-3.22.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:56c6f9342fcb6c74e205fbd2fee568ec4cdbdaa6165c8fde55dbc4ba5f584464"}, 543 | {file = "pycryptodome-3.22.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87a88dc543b62b5c669895caf6c5a958ac7abc8863919e94b7a6cafd2f64064f"}, 544 | {file = "pycryptodome-3.22.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f7a683bc9fa585c0dfec7fa4801c96a48d30b30b096e3297f9374f40c2fedafc"}, 545 | {file = "pycryptodome-3.22.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8f4f6f47a7f411f2c157e77bbbda289e0c9f9e1e9944caa73c1c2e33f3f92d6e"}, 546 | {file = "pycryptodome-3.22.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a6cf9553b29624961cab0785a3177a333e09e37ba62ad22314ebdbb01ca79840"}, 547 | {file = "pycryptodome-3.22.0.tar.gz", hash = "sha256:fd7ab568b3ad7b77c908d7c3f7e167ec5a8f035c64ff74f10d47a4edd043d723"}, 548 | ] 549 | 550 | [[package]] 551 | name = "requests" 552 | version = "2.32.3" 553 | description = "Python HTTP for Humans." 554 | optional = false 555 | python-versions = ">=3.8" 556 | groups = ["main"] 557 | files = [ 558 | {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, 559 | {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, 560 | ] 561 | 562 | [package.dependencies] 563 | certifi = ">=2017.4.17" 564 | charset-normalizer = ">=2,<4" 565 | idna = ">=2.5,<4" 566 | urllib3 = ">=1.21.1,<3" 567 | 568 | [package.extras] 569 | socks = ["PySocks (>=1.5.6,!=1.5.7)"] 570 | use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] 571 | 572 | [[package]] 573 | name = "setuptools" 574 | version = "78.1.1" 575 | description = "Easily download, build, install, upgrade, and uninstall Python packages" 576 | optional = false 577 | python-versions = ">=3.9" 578 | groups = ["main"] 579 | files = [ 580 | {file = "setuptools-78.1.1-py3-none-any.whl", hash = "sha256:c3a9c4211ff4c309edb8b8c4f1cbfa7ae324c4ba9f91ff254e3d305b9fd54561"}, 581 | {file = "setuptools-78.1.1.tar.gz", hash = "sha256:fcc17fd9cd898242f6b4adfaca46137a9edef687f43e6f78469692a5e70d851d"}, 582 | ] 583 | 584 | [package.extras] 585 | check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.8.0) ; sys_platform != \"cygwin\""] 586 | core = ["importlib_metadata (>=6) ; python_version < \"3.10\"", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging (>=24.2)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"] 587 | cover = ["pytest-cov"] 588 | doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] 589 | enabler = ["pytest-enabler (>=2.2)"] 590 | test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] 591 | type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.14.*)", "pytest-mypy"] 592 | 593 | [[package]] 594 | name = "six" 595 | version = "1.17.0" 596 | description = "Python 2 and 3 compatibility utilities" 597 | optional = false 598 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" 599 | groups = ["dev"] 600 | files = [ 601 | {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, 602 | {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, 603 | ] 604 | 605 | [[package]] 606 | name = "soupsieve" 607 | version = "2.6" 608 | description = "A modern CSS selector implementation for Beautiful Soup." 609 | optional = false 610 | python-versions = ">=3.8" 611 | groups = ["main"] 612 | files = [ 613 | {file = "soupsieve-2.6-py3-none-any.whl", hash = "sha256:e72c4ff06e4fb6e4b5a9f0f55fe6e81514581fca1515028625d0f299c602ccc9"}, 614 | {file = "soupsieve-2.6.tar.gz", hash = "sha256:e2e68417777af359ec65daac1057404a3c8a5455bb8abc36f1a9866ab1a51abb"}, 615 | ] 616 | 617 | [[package]] 618 | name = "tabulate" 619 | version = "0.8.10" 620 | description = "Pretty-print tabular data" 621 | optional = false 622 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 623 | groups = ["main"] 624 | files = [ 625 | {file = "tabulate-0.8.10-py3-none-any.whl", hash = "sha256:0ba055423dbaa164b9e456abe7920c5e8ed33fcc16f6d1b2f2d152c8e1e8b4fc"}, 626 | {file = "tabulate-0.8.10.tar.gz", hash = "sha256:6c57f3f3dd7ac2782770155f3adb2db0b1a269637e42f27599925e64b114f519"}, 627 | ] 628 | 629 | [package.extras] 630 | widechars = ["wcwidth"] 631 | 632 | [[package]] 633 | name = "tomli" 634 | version = "2.2.1" 635 | description = "A lil' TOML parser" 636 | optional = false 637 | python-versions = ">=3.8" 638 | groups = ["dev"] 639 | markers = "python_version < \"3.11\"" 640 | files = [ 641 | {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, 642 | {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, 643 | {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"}, 644 | {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"}, 645 | {file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"}, 646 | {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"}, 647 | {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"}, 648 | {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"}, 649 | {file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"}, 650 | {file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"}, 651 | {file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"}, 652 | {file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"}, 653 | {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"}, 654 | {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"}, 655 | {file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"}, 656 | {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"}, 657 | {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"}, 658 | {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"}, 659 | {file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"}, 660 | {file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"}, 661 | {file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"}, 662 | {file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"}, 663 | {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"}, 664 | {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"}, 665 | {file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"}, 666 | {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"}, 667 | {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"}, 668 | {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"}, 669 | {file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"}, 670 | {file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"}, 671 | {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"}, 672 | {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, 673 | ] 674 | 675 | [[package]] 676 | name = "tox" 677 | version = "3.28.0" 678 | description = "tox is a generic virtualenv management and test command line tool" 679 | optional = false 680 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" 681 | groups = ["dev"] 682 | files = [ 683 | {file = "tox-3.28.0-py2.py3-none-any.whl", hash = "sha256:57b5ab7e8bb3074edc3c0c0b4b192a4f3799d3723b2c5b76f1fa9f2d40316eea"}, 684 | {file = "tox-3.28.0.tar.gz", hash = "sha256:d0d28f3fe6d6d7195c27f8b054c3e99d5451952b54abdae673b71609a581f640"}, 685 | ] 686 | 687 | [package.dependencies] 688 | colorama = {version = ">=0.4.1", markers = "platform_system == \"Windows\""} 689 | filelock = ">=3.0.0" 690 | packaging = ">=14" 691 | pluggy = ">=0.12.0" 692 | py = ">=1.4.17" 693 | six = ">=1.14.0" 694 | tomli = {version = ">=2.0.1", markers = "python_version >= \"3.7\" and python_version < \"3.11\""} 695 | virtualenv = ">=16.0.0,<20.0.0 || >20.0.0,<20.0.1 || >20.0.1,<20.0.2 || >20.0.2,<20.0.3 || >20.0.3,<20.0.4 || >20.0.4,<20.0.5 || >20.0.5,<20.0.6 || >20.0.6,<20.0.7 || >20.0.7" 696 | 697 | [package.extras] 698 | docs = ["pygments-github-lexers (>=0.0.5)", "sphinx (>=2.0.0)", "sphinxcontrib-autoprogram (>=0.1.5)", "towncrier (>=18.5.0)"] 699 | testing = ["flaky (>=3.4.0)", "freezegun (>=0.3.11)", "pathlib2 (>=2.3.3) ; python_version < \"3.4\"", "psutil (>=5.6.1) ; platform_python_implementation == \"cpython\"", "pytest (>=4.0.0)", "pytest-cov (>=2.5.1)", "pytest-mock (>=1.10.0)", "pytest-randomly (>=1.0.0)"] 700 | 701 | [[package]] 702 | name = "typing-extensions" 703 | version = "4.12.2" 704 | description = "Backported and Experimental Type Hints for Python 3.8+" 705 | optional = false 706 | python-versions = ">=3.8" 707 | groups = ["main"] 708 | files = [ 709 | {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, 710 | {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, 711 | ] 712 | 713 | [[package]] 714 | name = "typing-inspect" 715 | version = "0.9.0" 716 | description = "Runtime inspection utilities for typing module." 717 | optional = false 718 | python-versions = "*" 719 | groups = ["main"] 720 | files = [ 721 | {file = "typing_inspect-0.9.0-py3-none-any.whl", hash = "sha256:9ee6fc59062311ef8547596ab6b955e1b8aa46242d854bfc78f4f6b0eff35f9f"}, 722 | {file = "typing_inspect-0.9.0.tar.gz", hash = "sha256:b23fc42ff6f6ef6954e4852c1fb512cdd18dbea03134f91f856a95ccc9461f78"}, 723 | ] 724 | 725 | [package.dependencies] 726 | mypy-extensions = ">=0.3.0" 727 | typing-extensions = ">=3.7.4" 728 | 729 | [[package]] 730 | name = "urllib3" 731 | version = "2.3.0" 732 | description = "HTTP library with thread-safe connection pooling, file post, and more." 733 | optional = false 734 | python-versions = ">=3.9" 735 | groups = ["main"] 736 | files = [ 737 | {file = "urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df"}, 738 | {file = "urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d"}, 739 | ] 740 | 741 | [package.extras] 742 | brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""] 743 | h2 = ["h2 (>=4,<5)"] 744 | socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] 745 | zstd = ["zstandard (>=0.18.0)"] 746 | 747 | [[package]] 748 | name = "virtualenv" 749 | version = "20.29.3" 750 | description = "Virtual Python Environment builder" 751 | optional = false 752 | python-versions = ">=3.8" 753 | groups = ["dev"] 754 | files = [ 755 | {file = "virtualenv-20.29.3-py3-none-any.whl", hash = "sha256:3e3d00f5807e83b234dfb6122bf37cfadf4be216c53a49ac059d02414f819170"}, 756 | {file = "virtualenv-20.29.3.tar.gz", hash = "sha256:95e39403fcf3940ac45bc717597dba16110b74506131845d9b687d5e73d947ac"}, 757 | ] 758 | 759 | [package.dependencies] 760 | distlib = ">=0.3.7,<1" 761 | filelock = ">=3.12.2,<4" 762 | platformdirs = ">=3.9.1,<5" 763 | 764 | [package.extras] 765 | docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] 766 | test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8) ; platform_python_implementation == \"PyPy\" or platform_python_implementation == \"CPython\" and sys_platform == \"win32\" and python_version >= \"3.13\"", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10) ; platform_python_implementation == \"CPython\""] 767 | 768 | [[package]] 769 | name = "werkzeug" 770 | version = "3.1.3" 771 | description = "The comprehensive WSGI web application library." 772 | optional = false 773 | python-versions = ">=3.9" 774 | groups = ["main"] 775 | files = [ 776 | {file = "werkzeug-3.1.3-py3-none-any.whl", hash = "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e"}, 777 | {file = "werkzeug-3.1.3.tar.gz", hash = "sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746"}, 778 | ] 779 | 780 | [package.dependencies] 781 | MarkupSafe = ">=2.1.1" 782 | 783 | [package.extras] 784 | watchdog = ["watchdog (>=2.3)"] 785 | 786 | [[package]] 787 | name = "zipp" 788 | version = "3.21.0" 789 | description = "Backport of pathlib-compatible object wrapper for zip files" 790 | optional = false 791 | python-versions = ">=3.9" 792 | groups = ["main"] 793 | markers = "python_version == \"3.9\"" 794 | files = [ 795 | {file = "zipp-3.21.0-py3-none-any.whl", hash = "sha256:ac1bbe05fd2991f160ebce24ffbac5f6d11d83dc90891255885223d42b3cd931"}, 796 | {file = "zipp-3.21.0.tar.gz", hash = "sha256:2c9958f6430a2040341a52eb608ed6dd93ef4392e02ffe219417c1b28b5dd1f4"}, 797 | ] 798 | 799 | [package.extras] 800 | check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] 801 | cover = ["pytest-cov"] 802 | doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] 803 | enabler = ["pytest-enabler (>=2.2)"] 804 | test = ["big-O", "importlib-resources ; python_version < \"3.9\"", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] 805 | type = ["pytest-mypy"] 806 | 807 | [metadata] 808 | lock-version = "2.1" 809 | python-versions = ">=3.9,<3.14" 810 | content-hash = "800fdb2e041296df336f6a1c1ef5026f337f33aaec1eb01d06ebc6957e196660" 811 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 100 3 | skip-string-normalization = true 4 | target-version = ['py39'] 5 | exclude = '\.eggs|\.git|\.mypy_cache|\.tox|\.env|\.venv|env|venv|_build|buck-out|build|dist' 6 | 7 | [tool.isort] 8 | line_length = 100 9 | use_parentheses = true 10 | include_trailing_comma = true 11 | multi_line_output = 3 12 | 13 | [tool.poetry] 14 | name = "kobodl" 15 | version = "0.12.0" 16 | description = "Kobo Book Downloader" 17 | authors = ["Brandon Davis "] 18 | license = "Unlicense" 19 | include = [ 20 | "kobodl/templates", 21 | "kobodl/commands", 22 | ] 23 | readme = "README.md" 24 | repository = "https://github.com/subdavis/kobo-book-downloader.git" 25 | keywords = ["Kobo", "eBook", "Audiobook", "Downloader", "DRM"] 26 | 27 | [tool.poetry.scripts] 28 | kobodl = "kobodl:cli" 29 | 30 | [tool.poetry.dependencies] 31 | python = ">=3.9,<3.14" 32 | beautifulsoup4 = "<5.0.0" 33 | click = "<9" 34 | dataclasses = "<1.0.0" 35 | dataclasses-json = "<0.6.0" 36 | flask = "3.1.1" 37 | pycryptodome = "<4" 38 | requests = "^2.25" 39 | tabulate = "<0.9.0" 40 | setuptools = ">=75.8,<79.0" 41 | 42 | [tool.poetry.group.dev.dependencies] 43 | tox = "^3.24.4" 44 | 45 | [build-system] 46 | requires = ["poetry-core>=1.0.0"] 47 | build-backend = "poetry.core.masonry.api" 48 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | isolated_build = True 3 | 4 | [testenv:type] 5 | skipsdist = true 6 | skip_install = true 7 | deps = 8 | mypy 9 | commands = 10 | mypy --install-types --non-interactive {posargs:.} 11 | 12 | [testenv:format] 13 | skipsdist = true 14 | skip_install = true 15 | deps = 16 | black 17 | isort 18 | commands = 19 | black {posargs:.} 20 | isort {posargs:.} 21 | 22 | [testenv:buildcli] 23 | deps = 24 | pyinstaller 25 | # a temporary pin for Jinja/Sphinx 26 | # because MarkupSafe 2.1.0 is a breaking change 27 | MarkupSafe==2.0.1 28 | commands = 29 | pyinstaller --onefile kobodl/__main__.py {posargs} 30 | --------------------------------------------------------------------------------