1
0
Fork 0

Adding upstream version 1.4.0.

Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
Daniel Baumann 2025-05-16 09:20:44 +02:00
parent cef31edaff
commit 1933d2d9f9
Signed by: daniel
GPG key ID: FBB4F0E80A80222F
24 changed files with 4114 additions and 0 deletions

160
.gitignore vendored Normal file
View file

@ -0,0 +1,160 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Will McGugan
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

192
README.md Normal file
View file

@ -0,0 +1,192 @@
<p align="center">
<img src="https://github.com/Textualize/toolong/assets/554369/07f286c9-ac8d-44cd-905a-062a26060821" alt="A Kookaburra sitting on a scroll" width="300" >
</p>
[![Discord](https://img.shields.io/discord/1026214085173461072)](https://discord.gg/Enf6Z3qhVr)
# Toolong
A terminal application to view, tail, merge, and search log files (plus JSONL).
<details>
<summary> 🎬 Viewing a single file </summary>
&nbsp;
<div align="center">
<video src="https://github.com/Textualize/tailless/assets/554369/a434d427-fa9a-44bf-bafb-1cfef32d65b9" width="400" />
</div>
</details>
## Keep calm and log files
See [Toolong on Calmcode.io](https://calmcode.io/shorts/toolong.py) for a calming introduction to Toolong.
## What?
<img width="40%" align="right" alt="Screenshot 2024-02-08 at 13 47 28" src="https://github.com/Textualize/toolong/assets/554369/1595e8e0-f5bf-428b-9b84-f0b5c7f506a1">
- Live tailing of log files.
- Syntax highlights common web server log formats.
- As fast to open a multiple-gigabyte file as it is to open a tiny text file.
- Support for JSONL files: lines are pretty printed.
- Opens .bz and .bz2 files automatically.
- Merges log files by auto detecting timestamps.
## Why?
I spent a lot of time in my past life as a web developer working with logs, typically on web servers via ssh.
I would use a variety of tools, but my goto method of analyzing logs was directly on the server with *nix tools like as `tail`, `less`, and `grep` etc.
As useful as these tools are, they are not without friction.
I built `toolong` to be the tool I would have wanted back then.
It is snappy, straightforward to use, and does a lot of the *grunt work* for you.
### Screenshots
<table>
<tr>
<td>
<img width="100%" alt="Screenshot 2024-02-08 at 13 47 28" src="https://github.com/Textualize/toolong/assets/554369/1595e8e0-f5bf-428b-9b84-f0b5c7f506a1">
</td>
<td>
<img width="100%" alt="Screenshot 2024-02-08 at 13 48 04" src="https://github.com/Textualize/toolong/assets/554369/c95f0cf4-426d-4d25-b270-eec0f4cfc86f">
</td>
</tr>
<tr>
<td>
<img width="100%" alt="Screenshot 2024-02-08 at 13 49 22" src="https://github.com/Textualize/toolong/assets/554369/45e7509c-ffed-44cc-b3e6-f2a7a276bbe5">
</td>
<td>
<img width="100%" alt="Screenshot 2024-02-08 at 13 50 04" src="https://github.com/Textualize/toolong/assets/554369/6840b626-539f-4ef9-88d9-25e0b96036b7">
</td>
</tr>
</table>
### Videos
<details>
<summary> 🎬 Merging multiple (compressed) files </summary>
&nbsp;
<div align="center">
<video src="https://github.com/Textualize/tailless/assets/554369/efbbde11-bebf-44ff-8d2b-72a84b542b75" />
</div>
</details>
<details>
<summary> 🎬 Viewing JSONL files </summary>
&nbsp;
<div align="center">
<video src="https://github.com/Textualize/tailless/assets/554369/38936600-34ee-4fe1-9fd3-b1581fc3fa37" />
</div>
</details>
<details>
<summary> 🎬 Live Tailing a file </summary>
&nbsp;
<div align="center">
<video src="https://github.com/Textualize/tailless/assets/554369/7eea6a0e-b30d-4a94-bb45-c5bff0e329ca" />
</div>
</details>
## How?
Toolong is currently best installed with [pipx](https://github.com/pypa/pipx).
```bash
pipx install toolong
```
You could also install Toolong with Pip:
```bash
pip install toolong
```
> [!NOTE]
> If you use pip, you should ideally create a virtual environment to avoid potential dependancy conflicts.
However you install Toolong, the `tl` command will be added to your path:
```bash
tl
```
In the near future there will be more install methods, and hopefully your favorite package manager.
### Compatibility
Toolong works on Linux, macOS, and Windows.
### Opening files
To open a file with Toolong, add the file name(s) as arguments to the command:
```bash
tl mylogfile.log
```
If you add multiple filenames, they will open in tabs.
Add the `--merge` switch to open multiple files and combine them in to a single view:
```bash
tl access.log* --merge
```
In the app, press **f1** for additional help.
### Piping
In addition to specifying files, you can also pipe directly into `tl`.
This means that you can tail data that comes from another process, and not neccesarily a file.
Here's an example of piping output from the `tree` command in to Toolong:
```bash
tree / | tl
```
## Who?
This [guy](https://github.com/willmcgugan). An ex web developer who somehow makes a living writing terminal apps.
---
## History
If you [follow me](https://twitter.com/willmcgugan) on Twitter, you may have seen me refer to this app as *Tailless*, because it was intended to be a replacement for a `tail` + `less` combo.
I settled on the name "Toolong" because it is a bit more apt, and still had the same initials.
## Development
Toolong v1.0.0 has a solid feature set, which covers most of my requirements.
However, there is a tonne of features which could be added to something like this, and I will likely implement some of them in the future.
If you want to talk about Toolong, find me on the [Textualize Discord Server](https://discord.gg/Enf6Z3qhVr).
## Thanks
I am grateful for the [LogMerger](https://github.com/ptmcg/logmerger) project which I referenced (and borrowed regexes from) when building Toolong.
## Alternatives
Toolong is not the first TUI for working with log files. See [lnav](https://lnav.org/) as a more mature alternative.

707
poetry.lock generated Normal file
View file

@ -0,0 +1,707 @@
# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand.
[[package]]
name = "aiohttp"
version = "3.9.3"
description = "Async http client/server framework (asyncio)"
optional = false
python-versions = ">=3.8"
files = [
{file = "aiohttp-3.9.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:939677b61f9d72a4fa2a042a5eee2a99a24001a67c13da113b2e30396567db54"},
{file = "aiohttp-3.9.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1f5cd333fcf7590a18334c90f8c9147c837a6ec8a178e88d90a9b96ea03194cc"},
{file = "aiohttp-3.9.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:82e6aa28dd46374f72093eda8bcd142f7771ee1eb9d1e223ff0fa7177a96b4a5"},
{file = "aiohttp-3.9.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f56455b0c2c7cc3b0c584815264461d07b177f903a04481dfc33e08a89f0c26b"},
{file = "aiohttp-3.9.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bca77a198bb6e69795ef2f09a5f4c12758487f83f33d63acde5f0d4919815768"},
{file = "aiohttp-3.9.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e083c285857b78ee21a96ba1eb1b5339733c3563f72980728ca2b08b53826ca5"},
{file = "aiohttp-3.9.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab40e6251c3873d86ea9b30a1ac6d7478c09277b32e14745d0d3c6e76e3c7e29"},
{file = "aiohttp-3.9.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:df822ee7feaaeffb99c1a9e5e608800bd8eda6e5f18f5cfb0dc7eeb2eaa6bbec"},
{file = "aiohttp-3.9.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:acef0899fea7492145d2bbaaaec7b345c87753168589cc7faf0afec9afe9b747"},
{file = "aiohttp-3.9.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:cd73265a9e5ea618014802ab01babf1940cecb90c9762d8b9e7d2cc1e1969ec6"},
{file = "aiohttp-3.9.3-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:a78ed8a53a1221393d9637c01870248a6f4ea5b214a59a92a36f18151739452c"},
{file = "aiohttp-3.9.3-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:6b0e029353361f1746bac2e4cc19b32f972ec03f0f943b390c4ab3371840aabf"},
{file = "aiohttp-3.9.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7cf5c9458e1e90e3c390c2639f1017a0379a99a94fdfad3a1fd966a2874bba52"},
{file = "aiohttp-3.9.3-cp310-cp310-win32.whl", hash = "sha256:3e59c23c52765951b69ec45ddbbc9403a8761ee6f57253250c6e1536cacc758b"},
{file = "aiohttp-3.9.3-cp310-cp310-win_amd64.whl", hash = "sha256:055ce4f74b82551678291473f66dc9fb9048a50d8324278751926ff0ae7715e5"},
{file = "aiohttp-3.9.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6b88f9386ff1ad91ace19d2a1c0225896e28815ee09fc6a8932fded8cda97c3d"},
{file = "aiohttp-3.9.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c46956ed82961e31557b6857a5ca153c67e5476972e5f7190015018760938da2"},
{file = "aiohttp-3.9.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:07b837ef0d2f252f96009e9b8435ec1fef68ef8b1461933253d318748ec1acdc"},
{file = "aiohttp-3.9.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad46e6f620574b3b4801c68255492e0159d1712271cc99d8bdf35f2043ec266"},
{file = "aiohttp-3.9.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ed3e046ea7b14938112ccd53d91c1539af3e6679b222f9469981e3dac7ba1ce"},
{file = "aiohttp-3.9.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:039df344b45ae0b34ac885ab5b53940b174530d4dd8a14ed8b0e2155b9dddccb"},
{file = "aiohttp-3.9.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7943c414d3a8d9235f5f15c22ace69787c140c80b718dcd57caaade95f7cd93b"},
{file = "aiohttp-3.9.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:84871a243359bb42c12728f04d181a389718710129b36b6aad0fc4655a7647d4"},
{file = "aiohttp-3.9.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5eafe2c065df5401ba06821b9a054d9cb2848867f3c59801b5d07a0be3a380ae"},
{file = "aiohttp-3.9.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:9d3c9b50f19704552f23b4eaea1fc082fdd82c63429a6506446cbd8737823da3"},
{file = "aiohttp-3.9.3-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:f033d80bc6283092613882dfe40419c6a6a1527e04fc69350e87a9df02bbc283"},
{file = "aiohttp-3.9.3-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:2c895a656dd7e061b2fd6bb77d971cc38f2afc277229ce7dd3552de8313a483e"},
{file = "aiohttp-3.9.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1f5a71d25cd8106eab05f8704cd9167b6e5187bcdf8f090a66c6d88b634802b4"},
{file = "aiohttp-3.9.3-cp311-cp311-win32.whl", hash = "sha256:50fca156d718f8ced687a373f9e140c1bb765ca16e3d6f4fe116e3df7c05b2c5"},
{file = "aiohttp-3.9.3-cp311-cp311-win_amd64.whl", hash = "sha256:5fe9ce6c09668063b8447f85d43b8d1c4e5d3d7e92c63173e6180b2ac5d46dd8"},
{file = "aiohttp-3.9.3-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:38a19bc3b686ad55804ae931012f78f7a534cce165d089a2059f658f6c91fa60"},
{file = "aiohttp-3.9.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:770d015888c2a598b377bd2f663adfd947d78c0124cfe7b959e1ef39f5b13869"},
{file = "aiohttp-3.9.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ee43080e75fc92bf36219926c8e6de497f9b247301bbf88c5c7593d931426679"},
{file = "aiohttp-3.9.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:52df73f14ed99cee84865b95a3d9e044f226320a87af208f068ecc33e0c35b96"},
{file = "aiohttp-3.9.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc9b311743a78043b26ffaeeb9715dc360335e5517832f5a8e339f8a43581e4d"},
{file = "aiohttp-3.9.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b955ed993491f1a5da7f92e98d5dad3c1e14dc175f74517c4e610b1f2456fb11"},
{file = "aiohttp-3.9.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:504b6981675ace64c28bf4a05a508af5cde526e36492c98916127f5a02354d53"},
{file = "aiohttp-3.9.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a6fe5571784af92b6bc2fda8d1925cccdf24642d49546d3144948a6a1ed58ca5"},
{file = "aiohttp-3.9.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ba39e9c8627edc56544c8628cc180d88605df3892beeb2b94c9bc857774848ca"},
{file = "aiohttp-3.9.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:e5e46b578c0e9db71d04c4b506a2121c0cb371dd89af17a0586ff6769d4c58c1"},
{file = "aiohttp-3.9.3-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:938a9653e1e0c592053f815f7028e41a3062e902095e5a7dc84617c87267ebd5"},
{file = "aiohttp-3.9.3-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:c3452ea726c76e92f3b9fae4b34a151981a9ec0a4847a627c43d71a15ac32aa6"},
{file = "aiohttp-3.9.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ff30218887e62209942f91ac1be902cc80cddb86bf00fbc6783b7a43b2bea26f"},
{file = "aiohttp-3.9.3-cp312-cp312-win32.whl", hash = "sha256:38f307b41e0bea3294a9a2a87833191e4bcf89bb0365e83a8be3a58b31fb7f38"},
{file = "aiohttp-3.9.3-cp312-cp312-win_amd64.whl", hash = "sha256:b791a3143681a520c0a17e26ae7465f1b6f99461a28019d1a2f425236e6eedb5"},
{file = "aiohttp-3.9.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:0ed621426d961df79aa3b963ac7af0d40392956ffa9be022024cd16297b30c8c"},
{file = "aiohttp-3.9.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:7f46acd6a194287b7e41e87957bfe2ad1ad88318d447caf5b090012f2c5bb528"},
{file = "aiohttp-3.9.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:feeb18a801aacb098220e2c3eea59a512362eb408d4afd0c242044c33ad6d542"},
{file = "aiohttp-3.9.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f734e38fd8666f53da904c52a23ce517f1b07722118d750405af7e4123933511"},
{file = "aiohttp-3.9.3-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b40670ec7e2156d8e57f70aec34a7216407848dfe6c693ef131ddf6e76feb672"},
{file = "aiohttp-3.9.3-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fdd215b7b7fd4a53994f238d0f46b7ba4ac4c0adb12452beee724ddd0743ae5d"},
{file = "aiohttp-3.9.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:017a21b0df49039c8f46ca0971b3a7fdc1f56741ab1240cb90ca408049766168"},
{file = "aiohttp-3.9.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e99abf0bba688259a496f966211c49a514e65afa9b3073a1fcee08856e04425b"},
{file = "aiohttp-3.9.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:648056db9a9fa565d3fa851880f99f45e3f9a771dd3ff3bb0c048ea83fb28194"},
{file = "aiohttp-3.9.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:8aacb477dc26797ee089721536a292a664846489c49d3ef9725f992449eda5a8"},
{file = "aiohttp-3.9.3-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:522a11c934ea660ff8953eda090dcd2154d367dec1ae3c540aff9f8a5c109ab4"},
{file = "aiohttp-3.9.3-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:5bce0dc147ca85caa5d33debc4f4d65e8e8b5c97c7f9f660f215fa74fc49a321"},
{file = "aiohttp-3.9.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:4b4af9f25b49a7be47c0972139e59ec0e8285c371049df1a63b6ca81fdd216a2"},
{file = "aiohttp-3.9.3-cp38-cp38-win32.whl", hash = "sha256:298abd678033b8571995650ccee753d9458dfa0377be4dba91e4491da3f2be63"},
{file = "aiohttp-3.9.3-cp38-cp38-win_amd64.whl", hash = "sha256:69361bfdca5468c0488d7017b9b1e5ce769d40b46a9f4a2eed26b78619e9396c"},
{file = "aiohttp-3.9.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:0fa43c32d1643f518491d9d3a730f85f5bbaedcbd7fbcae27435bb8b7a061b29"},
{file = "aiohttp-3.9.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:835a55b7ca49468aaaac0b217092dfdff370e6c215c9224c52f30daaa735c1c1"},
{file = "aiohttp-3.9.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:06a9b2c8837d9a94fae16c6223acc14b4dfdff216ab9b7202e07a9a09541168f"},
{file = "aiohttp-3.9.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:abf151955990d23f84205286938796c55ff11bbfb4ccfada8c9c83ae6b3c89a3"},
{file = "aiohttp-3.9.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59c26c95975f26e662ca78fdf543d4eeaef70e533a672b4113dd888bd2423caa"},
{file = "aiohttp-3.9.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f95511dd5d0e05fd9728bac4096319f80615aaef4acbecb35a990afebe953b0e"},
{file = "aiohttp-3.9.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:595f105710293e76b9dc09f52e0dd896bd064a79346234b521f6b968ffdd8e58"},
{file = "aiohttp-3.9.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7c8b816c2b5af5c8a436df44ca08258fc1a13b449393a91484225fcb7545533"},
{file = "aiohttp-3.9.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:f1088fa100bf46e7b398ffd9904f4808a0612e1d966b4aa43baa535d1b6341eb"},
{file = "aiohttp-3.9.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f59dfe57bb1ec82ac0698ebfcdb7bcd0e99c255bd637ff613760d5f33e7c81b3"},
{file = "aiohttp-3.9.3-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:361a1026c9dd4aba0109e4040e2aecf9884f5cfe1b1b1bd3d09419c205e2e53d"},
{file = "aiohttp-3.9.3-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:363afe77cfcbe3a36353d8ea133e904b108feea505aa4792dad6585a8192c55a"},
{file = "aiohttp-3.9.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8e2c45c208c62e955e8256949eb225bd8b66a4c9b6865729a786f2aa79b72e9d"},
{file = "aiohttp-3.9.3-cp39-cp39-win32.whl", hash = "sha256:f7217af2e14da0856e082e96ff637f14ae45c10a5714b63c77f26d8884cf1051"},
{file = "aiohttp-3.9.3-cp39-cp39-win_amd64.whl", hash = "sha256:27468897f628c627230dba07ec65dc8d0db566923c48f29e084ce382119802bc"},
{file = "aiohttp-3.9.3.tar.gz", hash = "sha256:90842933e5d1ff760fae6caca4b2b3edba53ba8f4b71e95dacf2818a2aca06f7"},
]
[package.dependencies]
aiosignal = ">=1.1.2"
async-timeout = {version = ">=4.0,<5.0", markers = "python_version < \"3.11\""}
attrs = ">=17.3.0"
frozenlist = ">=1.1.1"
multidict = ">=4.5,<7.0"
yarl = ">=1.0,<2.0"
[package.extras]
speedups = ["Brotli", "aiodns", "brotlicffi"]
[[package]]
name = "aiosignal"
version = "1.3.1"
description = "aiosignal: a list of registered asynchronous callbacks"
optional = false
python-versions = ">=3.7"
files = [
{file = "aiosignal-1.3.1-py3-none-any.whl", hash = "sha256:f8376fb07dd1e86a584e4fcdec80b36b7f81aac666ebc724e2c090300dd83b17"},
{file = "aiosignal-1.3.1.tar.gz", hash = "sha256:54cd96e15e1649b75d6c87526a6ff0b6c1b0dd3459f43d9ca11d48c339b68cfc"},
]
[package.dependencies]
frozenlist = ">=1.1.0"
[[package]]
name = "async-timeout"
version = "4.0.3"
description = "Timeout context manager for asyncio programs"
optional = false
python-versions = ">=3.7"
files = [
{file = "async-timeout-4.0.3.tar.gz", hash = "sha256:4640d96be84d82d02ed59ea2b7105a0f7b33abe8703703cd0ab0bf87c427522f"},
{file = "async_timeout-4.0.3-py3-none-any.whl", hash = "sha256:7405140ff1230c310e51dc27b3145b9092d659ce68ff733fb0cefe3ee42be028"},
]
[[package]]
name = "attrs"
version = "23.2.0"
description = "Classes Without Boilerplate"
optional = false
python-versions = ">=3.7"
files = [
{file = "attrs-23.2.0-py3-none-any.whl", hash = "sha256:99b87a485a5820b23b879f04c2305b44b951b502fd64be915879d77a7e8fc6f1"},
{file = "attrs-23.2.0.tar.gz", hash = "sha256:935dc3b529c262f6cf76e50877d35a4bd3c1de194fd41f47a2b7ae8f19971f30"},
]
[package.extras]
cov = ["attrs[tests]", "coverage[toml] (>=5.3)"]
dev = ["attrs[tests]", "pre-commit"]
docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope-interface"]
tests = ["attrs[tests-no-zope]", "zope-interface"]
tests-mypy = ["mypy (>=1.6)", "pytest-mypy-plugins"]
tests-no-zope = ["attrs[tests-mypy]", "cloudpickle", "hypothesis", "pympler", "pytest (>=4.3.0)", "pytest-xdist[psutil]"]
[[package]]
name = "click"
version = "8.1.7"
description = "Composable command line interface toolkit"
optional = false
python-versions = ">=3.7"
files = [
{file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"},
{file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"},
]
[package.dependencies]
colorama = {version = "*", markers = "platform_system == \"Windows\""}
[[package]]
name = "colorama"
version = "0.4.6"
description = "Cross-platform colored terminal text."
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
files = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
]
[[package]]
name = "frozenlist"
version = "1.4.1"
description = "A list-like structure which implements collections.abc.MutableSequence"
optional = false
python-versions = ">=3.8"
files = [
{file = "frozenlist-1.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f9aa1878d1083b276b0196f2dfbe00c9b7e752475ed3b682025ff20c1c1f51ac"},
{file = "frozenlist-1.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:29acab3f66f0f24674b7dc4736477bcd4bc3ad4b896f5f45379a67bce8b96868"},
{file = "frozenlist-1.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:74fb4bee6880b529a0c6560885fce4dc95936920f9f20f53d99a213f7bf66776"},
{file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:590344787a90ae57d62511dd7c736ed56b428f04cd8c161fcc5e7232c130c69a"},
{file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:068b63f23b17df8569b7fdca5517edef76171cf3897eb68beb01341131fbd2ad"},
{file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c849d495bf5154cd8da18a9eb15db127d4dba2968d88831aff6f0331ea9bd4c"},
{file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9750cc7fe1ae3b1611bb8cfc3f9ec11d532244235d75901fb6b8e42ce9229dfe"},
{file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a9b2de4cf0cdd5bd2dee4c4f63a653c61d2408055ab77b151c1957f221cabf2a"},
{file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0633c8d5337cb5c77acbccc6357ac49a1770b8c487e5b3505c57b949b4b82e98"},
{file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:27657df69e8801be6c3638054e202a135c7f299267f1a55ed3a598934f6c0d75"},
{file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:f9a3ea26252bd92f570600098783d1371354d89d5f6b7dfd87359d669f2109b5"},
{file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:4f57dab5fe3407b6c0c1cc907ac98e8a189f9e418f3b6e54d65a718aaafe3950"},
{file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:e02a0e11cf6597299b9f3bbd3f93d79217cb90cfd1411aec33848b13f5c656cc"},
{file = "frozenlist-1.4.1-cp310-cp310-win32.whl", hash = "sha256:a828c57f00f729620a442881cc60e57cfcec6842ba38e1b19fd3e47ac0ff8dc1"},
{file = "frozenlist-1.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:f56e2333dda1fe0f909e7cc59f021eba0d2307bc6f012a1ccf2beca6ba362439"},
{file = "frozenlist-1.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a0cb6f11204443f27a1628b0e460f37fb30f624be6051d490fa7d7e26d4af3d0"},
{file = "frozenlist-1.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b46c8ae3a8f1f41a0d2ef350c0b6e65822d80772fe46b653ab6b6274f61d4a49"},
{file = "frozenlist-1.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fde5bd59ab5357e3853313127f4d3565fc7dad314a74d7b5d43c22c6a5ed2ced"},
{file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:722e1124aec435320ae01ee3ac7bec11a5d47f25d0ed6328f2273d287bc3abb0"},
{file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2471c201b70d58a0f0c1f91261542a03d9a5e088ed3dc6c160d614c01649c106"},
{file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c757a9dd70d72b076d6f68efdbb9bc943665ae954dad2801b874c8c69e185068"},
{file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f146e0911cb2f1da549fc58fc7bcd2b836a44b79ef871980d605ec392ff6b0d2"},
{file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f9c515e7914626b2a2e1e311794b4c35720a0be87af52b79ff8e1429fc25f19"},
{file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:c302220494f5c1ebeb0912ea782bcd5e2f8308037b3c7553fad0e48ebad6ad82"},
{file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:442acde1e068288a4ba7acfe05f5f343e19fac87bfc96d89eb886b0363e977ec"},
{file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:1b280e6507ea8a4fa0c0a7150b4e526a8d113989e28eaaef946cc77ffd7efc0a"},
{file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:fe1a06da377e3a1062ae5fe0926e12b84eceb8a50b350ddca72dc85015873f74"},
{file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:db9e724bebd621d9beca794f2a4ff1d26eed5965b004a97f1f1685a173b869c2"},
{file = "frozenlist-1.4.1-cp311-cp311-win32.whl", hash = "sha256:e774d53b1a477a67838a904131c4b0eef6b3d8a651f8b138b04f748fccfefe17"},
{file = "frozenlist-1.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:fb3c2db03683b5767dedb5769b8a40ebb47d6f7f45b1b3e3b4b51ec8ad9d9825"},
{file = "frozenlist-1.4.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:1979bc0aeb89b33b588c51c54ab0161791149f2461ea7c7c946d95d5f93b56ae"},
{file = "frozenlist-1.4.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:cc7b01b3754ea68a62bd77ce6020afaffb44a590c2289089289363472d13aedb"},
{file = "frozenlist-1.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c9c92be9fd329ac801cc420e08452b70e7aeab94ea4233a4804f0915c14eba9b"},
{file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c3894db91f5a489fc8fa6a9991820f368f0b3cbdb9cd8849547ccfab3392d86"},
{file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ba60bb19387e13597fb059f32cd4d59445d7b18b69a745b8f8e5db0346f33480"},
{file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8aefbba5f69d42246543407ed2461db31006b0f76c4e32dfd6f42215a2c41d09"},
{file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:780d3a35680ced9ce682fbcf4cb9c2bad3136eeff760ab33707b71db84664e3a"},
{file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9acbb16f06fe7f52f441bb6f413ebae6c37baa6ef9edd49cdd567216da8600cd"},
{file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:23b701e65c7b36e4bf15546a89279bd4d8675faabc287d06bbcfac7d3c33e1e6"},
{file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:3e0153a805a98f5ada7e09826255ba99fb4f7524bb81bf6b47fb702666484ae1"},
{file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:dd9b1baec094d91bf36ec729445f7769d0d0cf6b64d04d86e45baf89e2b9059b"},
{file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:1a4471094e146b6790f61b98616ab8e44f72661879cc63fa1049d13ef711e71e"},
{file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5667ed53d68d91920defdf4035d1cdaa3c3121dc0b113255124bcfada1cfa1b8"},
{file = "frozenlist-1.4.1-cp312-cp312-win32.whl", hash = "sha256:beee944ae828747fd7cb216a70f120767fc9f4f00bacae8543c14a6831673f89"},
{file = "frozenlist-1.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:64536573d0a2cb6e625cf309984e2d873979709f2cf22839bf2d61790b448ad5"},
{file = "frozenlist-1.4.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:20b51fa3f588ff2fe658663db52a41a4f7aa6c04f6201449c6c7c476bd255c0d"},
{file = "frozenlist-1.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:410478a0c562d1a5bcc2f7ea448359fcb050ed48b3c6f6f4f18c313a9bdb1826"},
{file = "frozenlist-1.4.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c6321c9efe29975232da3bd0af0ad216800a47e93d763ce64f291917a381b8eb"},
{file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48f6a4533887e189dae092f1cf981f2e3885175f7a0f33c91fb5b7b682b6bab6"},
{file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6eb73fa5426ea69ee0e012fb59cdc76a15b1283d6e32e4f8dc4482ec67d1194d"},
{file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fbeb989b5cc29e8daf7f976b421c220f1b8c731cbf22b9130d8815418ea45887"},
{file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:32453c1de775c889eb4e22f1197fe3bdfe457d16476ea407472b9442e6295f7a"},
{file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:693945278a31f2086d9bf3df0fe8254bbeaef1fe71e1351c3bd730aa7d31c41b"},
{file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:1d0ce09d36d53bbbe566fe296965b23b961764c0bcf3ce2fa45f463745c04701"},
{file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:3a670dc61eb0d0eb7080890c13de3066790f9049b47b0de04007090807c776b0"},
{file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:dca69045298ce5c11fd539682cff879cc1e664c245d1c64da929813e54241d11"},
{file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:a06339f38e9ed3a64e4c4e43aec7f59084033647f908e4259d279a52d3757d09"},
{file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b7f2f9f912dca3934c1baec2e4585a674ef16fe00218d833856408c48d5beee7"},
{file = "frozenlist-1.4.1-cp38-cp38-win32.whl", hash = "sha256:e7004be74cbb7d9f34553a5ce5fb08be14fb33bc86f332fb71cbe5216362a497"},
{file = "frozenlist-1.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:5a7d70357e7cee13f470c7883a063aae5fe209a493c57d86eb7f5a6f910fae09"},
{file = "frozenlist-1.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:bfa4a17e17ce9abf47a74ae02f32d014c5e9404b6d9ac7f729e01562bbee601e"},
{file = "frozenlist-1.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b7e3ed87d4138356775346e6845cccbe66cd9e207f3cd11d2f0b9fd13681359d"},
{file = "frozenlist-1.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c99169d4ff810155ca50b4da3b075cbde79752443117d89429595c2e8e37fed8"},
{file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:edb678da49d9f72c9f6c609fbe41a5dfb9a9282f9e6a2253d5a91e0fc382d7c0"},
{file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6db4667b187a6742b33afbbaf05a7bc551ffcf1ced0000a571aedbb4aa42fc7b"},
{file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:55fdc093b5a3cb41d420884cdaf37a1e74c3c37a31f46e66286d9145d2063bd0"},
{file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82e8211d69a4f4bc360ea22cd6555f8e61a1bd211d1d5d39d3d228b48c83a897"},
{file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:89aa2c2eeb20957be2d950b85974b30a01a762f3308cd02bb15e1ad632e22dc7"},
{file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9d3e0c25a2350080e9319724dede4f31f43a6c9779be48021a7f4ebde8b2d742"},
{file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7268252af60904bf52c26173cbadc3a071cece75f873705419c8681f24d3edea"},
{file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:0c250a29735d4f15321007fb02865f0e6b6a41a6b88f1f523ca1596ab5f50bd5"},
{file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:96ec70beabbd3b10e8bfe52616a13561e58fe84c0101dd031dc78f250d5128b9"},
{file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:23b2d7679b73fe0e5a4560b672a39f98dfc6f60df63823b0a9970525325b95f6"},
{file = "frozenlist-1.4.1-cp39-cp39-win32.whl", hash = "sha256:a7496bfe1da7fb1a4e1cc23bb67c58fab69311cc7d32b5a99c2007b4b2a0e932"},
{file = "frozenlist-1.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:e6a20a581f9ce92d389a8c7d7c3dd47c81fd5d6e655c8dddf341e14aa48659d0"},
{file = "frozenlist-1.4.1-py3-none-any.whl", hash = "sha256:04ced3e6a46b4cfffe20f9ae482818e34eba9b5fb0ce4056e4cc9b6e212d09b7"},
{file = "frozenlist-1.4.1.tar.gz", hash = "sha256:c037a86e8513059a2613aaba4d817bb90b9d9b6b69aace3ce9c877e8c8ed402b"},
]
[[package]]
name = "idna"
version = "3.6"
description = "Internationalized Domain Names in Applications (IDNA)"
optional = false
python-versions = ">=3.5"
files = [
{file = "idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f"},
{file = "idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca"},
]
[[package]]
name = "linkify-it-py"
version = "2.0.3"
description = "Links recognition library with FULL unicode support."
optional = false
python-versions = ">=3.7"
files = [
{file = "linkify-it-py-2.0.3.tar.gz", hash = "sha256:68cda27e162e9215c17d786649d1da0021a451bdc436ef9e0fa0ba5234b9b048"},
{file = "linkify_it_py-2.0.3-py3-none-any.whl", hash = "sha256:6bcbc417b0ac14323382aef5c5192c0075bf8a9d6b41820a2b66371eac6b6d79"},
]
[package.dependencies]
uc-micro-py = "*"
[package.extras]
benchmark = ["pytest", "pytest-benchmark"]
dev = ["black", "flake8", "isort", "pre-commit", "pyproject-flake8"]
doc = ["myst-parser", "sphinx", "sphinx-book-theme"]
test = ["coverage", "pytest", "pytest-cov"]
[[package]]
name = "markdown-it-py"
version = "3.0.0"
description = "Python port of markdown-it. Markdown parsing, done right!"
optional = false
python-versions = ">=3.8"
files = [
{file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"},
{file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"},
]
[package.dependencies]
linkify-it-py = {version = ">=1,<3", optional = true, markers = "extra == \"linkify\""}
mdit-py-plugins = {version = "*", optional = true, markers = "extra == \"plugins\""}
mdurl = ">=0.1,<1.0"
[package.extras]
benchmarking = ["psutil", "pytest", "pytest-benchmark"]
code-style = ["pre-commit (>=3.0,<4.0)"]
compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"]
linkify = ["linkify-it-py (>=1,<3)"]
plugins = ["mdit-py-plugins"]
profiling = ["gprof2dot"]
rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"]
testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"]
[[package]]
name = "mdit-py-plugins"
version = "0.4.0"
description = "Collection of plugins for markdown-it-py"
optional = false
python-versions = ">=3.8"
files = [
{file = "mdit_py_plugins-0.4.0-py3-none-any.whl", hash = "sha256:b51b3bb70691f57f974e257e367107857a93b36f322a9e6d44ca5bf28ec2def9"},
{file = "mdit_py_plugins-0.4.0.tar.gz", hash = "sha256:d8ab27e9aed6c38aa716819fedfde15ca275715955f8a185a8e1cf90fb1d2c1b"},
]
[package.dependencies]
markdown-it-py = ">=1.0.0,<4.0.0"
[package.extras]
code-style = ["pre-commit"]
rtd = ["myst-parser", "sphinx-book-theme"]
testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"]
[[package]]
name = "mdurl"
version = "0.1.2"
description = "Markdown URL utilities"
optional = false
python-versions = ">=3.7"
files = [
{file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"},
{file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"},
]
[[package]]
name = "msgpack"
version = "1.0.7"
description = "MessagePack serializer"
optional = false
python-versions = ">=3.8"
files = [
{file = "msgpack-1.0.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04ad6069c86e531682f9e1e71b71c1c3937d6014a7c3e9edd2aa81ad58842862"},
{file = "msgpack-1.0.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cca1b62fe70d761a282496b96a5e51c44c213e410a964bdffe0928e611368329"},
{file = "msgpack-1.0.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e50ebce52f41370707f1e21a59514e3375e3edd6e1832f5e5235237db933c98b"},
{file = "msgpack-1.0.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a7b4f35de6a304b5533c238bee86b670b75b03d31b7797929caa7a624b5dda6"},
{file = "msgpack-1.0.7-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28efb066cde83c479dfe5a48141a53bc7e5f13f785b92ddde336c716663039ee"},
{file = "msgpack-1.0.7-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4cb14ce54d9b857be9591ac364cb08dc2d6a5c4318c1182cb1d02274029d590d"},
{file = "msgpack-1.0.7-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b573a43ef7c368ba4ea06050a957c2a7550f729c31f11dd616d2ac4aba99888d"},
{file = "msgpack-1.0.7-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:ccf9a39706b604d884d2cb1e27fe973bc55f2890c52f38df742bc1d79ab9f5e1"},
{file = "msgpack-1.0.7-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:cb70766519500281815dfd7a87d3a178acf7ce95390544b8c90587d76b227681"},
{file = "msgpack-1.0.7-cp310-cp310-win32.whl", hash = "sha256:b610ff0f24e9f11c9ae653c67ff8cc03c075131401b3e5ef4b82570d1728f8a9"},
{file = "msgpack-1.0.7-cp310-cp310-win_amd64.whl", hash = "sha256:a40821a89dc373d6427e2b44b572efc36a2778d3f543299e2f24eb1a5de65415"},
{file = "msgpack-1.0.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:576eb384292b139821c41995523654ad82d1916da6a60cff129c715a6223ea84"},
{file = "msgpack-1.0.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:730076207cb816138cf1af7f7237b208340a2c5e749707457d70705715c93b93"},
{file = "msgpack-1.0.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:85765fdf4b27eb5086f05ac0491090fc76f4f2b28e09d9350c31aac25a5aaff8"},
{file = "msgpack-1.0.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3476fae43db72bd11f29a5147ae2f3cb22e2f1a91d575ef130d2bf49afd21c46"},
{file = "msgpack-1.0.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d4c80667de2e36970ebf74f42d1088cc9ee7ef5f4e8c35eee1b40eafd33ca5b"},
{file = "msgpack-1.0.7-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5b0bf0effb196ed76b7ad883848143427a73c355ae8e569fa538365064188b8e"},
{file = "msgpack-1.0.7-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f9a7c509542db4eceed3dcf21ee5267ab565a83555c9b88a8109dcecc4709002"},
{file = "msgpack-1.0.7-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:84b0daf226913133f899ea9b30618722d45feffa67e4fe867b0b5ae83a34060c"},
{file = "msgpack-1.0.7-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:ec79ff6159dffcc30853b2ad612ed572af86c92b5168aa3fc01a67b0fa40665e"},
{file = "msgpack-1.0.7-cp311-cp311-win32.whl", hash = "sha256:3e7bf4442b310ff154b7bb9d81eb2c016b7d597e364f97d72b1acc3817a0fdc1"},
{file = "msgpack-1.0.7-cp311-cp311-win_amd64.whl", hash = "sha256:3f0c8c6dfa6605ab8ff0611995ee30d4f9fcff89966cf562733b4008a3d60d82"},
{file = "msgpack-1.0.7-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f0936e08e0003f66bfd97e74ee530427707297b0d0361247e9b4f59ab78ddc8b"},
{file = "msgpack-1.0.7-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:98bbd754a422a0b123c66a4c341de0474cad4a5c10c164ceed6ea090f3563db4"},
{file = "msgpack-1.0.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b291f0ee7961a597cbbcc77709374087fa2a9afe7bdb6a40dbbd9b127e79afee"},
{file = "msgpack-1.0.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ebbbba226f0a108a7366bf4b59bf0f30a12fd5e75100c630267d94d7f0ad20e5"},
{file = "msgpack-1.0.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1e2d69948e4132813b8d1131f29f9101bc2c915f26089a6d632001a5c1349672"},
{file = "msgpack-1.0.7-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bdf38ba2d393c7911ae989c3bbba510ebbcdf4ecbdbfec36272abe350c454075"},
{file = "msgpack-1.0.7-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:993584fc821c58d5993521bfdcd31a4adf025c7d745bbd4d12ccfecf695af5ba"},
{file = "msgpack-1.0.7-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:52700dc63a4676669b341ba33520f4d6e43d3ca58d422e22ba66d1736b0a6e4c"},
{file = "msgpack-1.0.7-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:e45ae4927759289c30ccba8d9fdce62bb414977ba158286b5ddaf8df2cddb5c5"},
{file = "msgpack-1.0.7-cp312-cp312-win32.whl", hash = "sha256:27dcd6f46a21c18fa5e5deed92a43d4554e3df8d8ca5a47bf0615d6a5f39dbc9"},
{file = "msgpack-1.0.7-cp312-cp312-win_amd64.whl", hash = "sha256:7687e22a31e976a0e7fc99c2f4d11ca45eff652a81eb8c8085e9609298916dcf"},
{file = "msgpack-1.0.7-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5b6ccc0c85916998d788b295765ea0e9cb9aac7e4a8ed71d12e7d8ac31c23c95"},
{file = "msgpack-1.0.7-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:235a31ec7db685f5c82233bddf9858748b89b8119bf4538d514536c485c15fe0"},
{file = "msgpack-1.0.7-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:cab3db8bab4b7e635c1c97270d7a4b2a90c070b33cbc00c99ef3f9be03d3e1f7"},
{file = "msgpack-1.0.7-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bfdd914e55e0d2c9e1526de210f6fe8ffe9705f2b1dfcc4aecc92a4cb4b533d"},
{file = "msgpack-1.0.7-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:36e17c4592231a7dbd2ed09027823ab295d2791b3b1efb2aee874b10548b7524"},
{file = "msgpack-1.0.7-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:38949d30b11ae5f95c3c91917ee7a6b239f5ec276f271f28638dec9156f82cfc"},
{file = "msgpack-1.0.7-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:ff1d0899f104f3921d94579a5638847f783c9b04f2d5f229392ca77fba5b82fc"},
{file = "msgpack-1.0.7-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:dc43f1ec66eb8440567186ae2f8c447d91e0372d793dfe8c222aec857b81a8cf"},
{file = "msgpack-1.0.7-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:dd632777ff3beaaf629f1ab4396caf7ba0bdd075d948a69460d13d44357aca4c"},
{file = "msgpack-1.0.7-cp38-cp38-win32.whl", hash = "sha256:4e71bc4416de195d6e9b4ee93ad3f2f6b2ce11d042b4d7a7ee00bbe0358bd0c2"},
{file = "msgpack-1.0.7-cp38-cp38-win_amd64.whl", hash = "sha256:8f5b234f567cf76ee489502ceb7165c2a5cecec081db2b37e35332b537f8157c"},
{file = "msgpack-1.0.7-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:bfef2bb6ef068827bbd021017a107194956918ab43ce4d6dc945ffa13efbc25f"},
{file = "msgpack-1.0.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:484ae3240666ad34cfa31eea7b8c6cd2f1fdaae21d73ce2974211df099a95d81"},
{file = "msgpack-1.0.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3967e4ad1aa9da62fd53e346ed17d7b2e922cba5ab93bdd46febcac39be636fc"},
{file = "msgpack-1.0.7-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8dd178c4c80706546702c59529ffc005681bd6dc2ea234c450661b205445a34d"},
{file = "msgpack-1.0.7-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6ffbc252eb0d229aeb2f9ad051200668fc3a9aaa8994e49f0cb2ffe2b7867e7"},
{file = "msgpack-1.0.7-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:822ea70dc4018c7e6223f13affd1c5c30c0f5c12ac1f96cd8e9949acddb48a61"},
{file = "msgpack-1.0.7-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:384d779f0d6f1b110eae74cb0659d9aa6ff35aaf547b3955abf2ab4c901c4819"},
{file = "msgpack-1.0.7-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f64e376cd20d3f030190e8c32e1c64582eba56ac6dc7d5b0b49a9d44021b52fd"},
{file = "msgpack-1.0.7-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5ed82f5a7af3697b1c4786053736f24a0efd0a1b8a130d4c7bfee4b9ded0f08f"},
{file = "msgpack-1.0.7-cp39-cp39-win32.whl", hash = "sha256:f26a07a6e877c76a88e3cecac8531908d980d3d5067ff69213653649ec0f60ad"},
{file = "msgpack-1.0.7-cp39-cp39-win_amd64.whl", hash = "sha256:1dc93e8e4653bdb5910aed79f11e165c85732067614f180f70534f056da97db3"},
{file = "msgpack-1.0.7.tar.gz", hash = "sha256:572efc93db7a4d27e404501975ca6d2d9775705c2d922390d878fcf768d92c87"},
]
[[package]]
name = "multidict"
version = "6.0.5"
description = "multidict implementation"
optional = false
python-versions = ">=3.7"
files = [
{file = "multidict-6.0.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:228b644ae063c10e7f324ab1ab6b548bdf6f8b47f3ec234fef1093bc2735e5f9"},
{file = "multidict-6.0.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:896ebdcf62683551312c30e20614305f53125750803b614e9e6ce74a96232604"},
{file = "multidict-6.0.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:411bf8515f3be9813d06004cac41ccf7d1cd46dfe233705933dd163b60e37600"},
{file = "multidict-6.0.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d147090048129ce3c453f0292e7697d333db95e52616b3793922945804a433c"},
{file = "multidict-6.0.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:215ed703caf15f578dca76ee6f6b21b7603791ae090fbf1ef9d865571039ade5"},
{file = "multidict-6.0.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c6390cf87ff6234643428991b7359b5f59cc15155695deb4eda5c777d2b880f"},
{file = "multidict-6.0.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21fd81c4ebdb4f214161be351eb5bcf385426bf023041da2fd9e60681f3cebae"},
{file = "multidict-6.0.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3cc2ad10255f903656017363cd59436f2111443a76f996584d1077e43ee51182"},
{file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:6939c95381e003f54cd4c5516740faba40cf5ad3eeff460c3ad1d3e0ea2549bf"},
{file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:220dd781e3f7af2c2c1053da9fa96d9cf3072ca58f057f4c5adaaa1cab8fc442"},
{file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:766c8f7511df26d9f11cd3a8be623e59cca73d44643abab3f8c8c07620524e4a"},
{file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:fe5d7785250541f7f5019ab9cba2c71169dc7d74d0f45253f8313f436458a4ef"},
{file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1c1496e73051918fcd4f58ff2e0f2f3066d1c76a0c6aeffd9b45d53243702cc"},
{file = "multidict-6.0.5-cp310-cp310-win32.whl", hash = "sha256:7afcdd1fc07befad18ec4523a782cde4e93e0a2bf71239894b8d61ee578c1319"},
{file = "multidict-6.0.5-cp310-cp310-win_amd64.whl", hash = "sha256:99f60d34c048c5c2fabc766108c103612344c46e35d4ed9ae0673d33c8fb26e8"},
{file = "multidict-6.0.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f285e862d2f153a70586579c15c44656f888806ed0e5b56b64489afe4a2dbfba"},
{file = "multidict-6.0.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:53689bb4e102200a4fafa9de9c7c3c212ab40a7ab2c8e474491914d2305f187e"},
{file = "multidict-6.0.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:612d1156111ae11d14afaf3a0669ebf6c170dbb735e510a7438ffe2369a847fd"},
{file = "multidict-6.0.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7be7047bd08accdb7487737631d25735c9a04327911de89ff1b26b81745bd4e3"},
{file = "multidict-6.0.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de170c7b4fe6859beb8926e84f7d7d6c693dfe8e27372ce3b76f01c46e489fcf"},
{file = "multidict-6.0.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04bde7a7b3de05732a4eb39c94574db1ec99abb56162d6c520ad26f83267de29"},
{file = "multidict-6.0.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85f67aed7bb647f93e7520633d8f51d3cbc6ab96957c71272b286b2f30dc70ed"},
{file = "multidict-6.0.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:425bf820055005bfc8aa9a0b99ccb52cc2f4070153e34b701acc98d201693733"},
{file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d3eb1ceec286eba8220c26f3b0096cf189aea7057b6e7b7a2e60ed36b373b77f"},
{file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:7901c05ead4b3fb75113fb1dd33eb1253c6d3ee37ce93305acd9d38e0b5f21a4"},
{file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:e0e79d91e71b9867c73323a3444724d496c037e578a0e1755ae159ba14f4f3d1"},
{file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:29bfeb0dff5cb5fdab2023a7a9947b3b4af63e9c47cae2a10ad58394b517fddc"},
{file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e030047e85cbcedbfc073f71836d62dd5dadfbe7531cae27789ff66bc551bd5e"},
{file = "multidict-6.0.5-cp311-cp311-win32.whl", hash = "sha256:2f4848aa3baa109e6ab81fe2006c77ed4d3cd1e0ac2c1fbddb7b1277c168788c"},
{file = "multidict-6.0.5-cp311-cp311-win_amd64.whl", hash = "sha256:2faa5ae9376faba05f630d7e5e6be05be22913782b927b19d12b8145968a85ea"},
{file = "multidict-6.0.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:51d035609b86722963404f711db441cf7134f1889107fb171a970c9701f92e1e"},
{file = "multidict-6.0.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:cbebcd5bcaf1eaf302617c114aa67569dd3f090dd0ce8ba9e35e9985b41ac35b"},
{file = "multidict-6.0.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2ffc42c922dbfddb4a4c3b438eb056828719f07608af27d163191cb3e3aa6cc5"},
{file = "multidict-6.0.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ceb3b7e6a0135e092de86110c5a74e46bda4bd4fbfeeb3a3bcec79c0f861e450"},
{file = "multidict-6.0.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:79660376075cfd4b2c80f295528aa6beb2058fd289f4c9252f986751a4cd0496"},
{file = "multidict-6.0.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e4428b29611e989719874670fd152b6625500ad6c686d464e99f5aaeeaca175a"},
{file = "multidict-6.0.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d84a5c3a5f7ce6db1f999fb9438f686bc2e09d38143f2d93d8406ed2dd6b9226"},
{file = "multidict-6.0.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:76c0de87358b192de7ea9649beb392f107dcad9ad27276324c24c91774ca5271"},
{file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:79a6d2ba910adb2cbafc95dad936f8b9386e77c84c35bc0add315b856d7c3abb"},
{file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:92d16a3e275e38293623ebf639c471d3e03bb20b8ebb845237e0d3664914caef"},
{file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:fb616be3538599e797a2017cccca78e354c767165e8858ab5116813146041a24"},
{file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:14c2976aa9038c2629efa2c148022ed5eb4cb939e15ec7aace7ca932f48f9ba6"},
{file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:435a0984199d81ca178b9ae2c26ec3d49692d20ee29bc4c11a2a8d4514c67eda"},
{file = "multidict-6.0.5-cp312-cp312-win32.whl", hash = "sha256:9fe7b0653ba3d9d65cbe7698cca585bf0f8c83dbbcc710db9c90f478e175f2d5"},
{file = "multidict-6.0.5-cp312-cp312-win_amd64.whl", hash = "sha256:01265f5e40f5a17f8241d52656ed27192be03bfa8764d88e8220141d1e4b3556"},
{file = "multidict-6.0.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:19fe01cea168585ba0f678cad6f58133db2aa14eccaf22f88e4a6dccadfad8b3"},
{file = "multidict-6.0.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6bf7a982604375a8d49b6cc1b781c1747f243d91b81035a9b43a2126c04766f5"},
{file = "multidict-6.0.5-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:107c0cdefe028703fb5dafe640a409cb146d44a6ae201e55b35a4af8e95457dd"},
{file = "multidict-6.0.5-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:403c0911cd5d5791605808b942c88a8155c2592e05332d2bf78f18697a5fa15e"},
{file = "multidict-6.0.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aeaf541ddbad8311a87dd695ed9642401131ea39ad7bc8cf3ef3967fd093b626"},
{file = "multidict-6.0.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e4972624066095e52b569e02b5ca97dbd7a7ddd4294bf4e7247d52635630dd83"},
{file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d946b0a9eb8aaa590df1fe082cee553ceab173e6cb5b03239716338629c50c7a"},
{file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b55358304d7a73d7bdf5de62494aaf70bd33015831ffd98bc498b433dfe5b10c"},
{file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:a3145cb08d8625b2d3fee1b2d596a8766352979c9bffe5d7833e0503d0f0b5e5"},
{file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:d65f25da8e248202bd47445cec78e0025c0fe7582b23ec69c3b27a640dd7a8e3"},
{file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:c9bf56195c6bbd293340ea82eafd0071cb3d450c703d2c93afb89f93b8386ccc"},
{file = "multidict-6.0.5-cp37-cp37m-win32.whl", hash = "sha256:69db76c09796b313331bb7048229e3bee7928eb62bab5e071e9f7fcc4879caee"},
{file = "multidict-6.0.5-cp37-cp37m-win_amd64.whl", hash = "sha256:fce28b3c8a81b6b36dfac9feb1de115bab619b3c13905b419ec71d03a3fc1423"},
{file = "multidict-6.0.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:76f067f5121dcecf0d63a67f29080b26c43c71a98b10c701b0677e4a065fbd54"},
{file = "multidict-6.0.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b82cc8ace10ab5bd93235dfaab2021c70637005e1ac787031f4d1da63d493c1d"},
{file = "multidict-6.0.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5cb241881eefd96b46f89b1a056187ea8e9ba14ab88ba632e68d7a2ecb7aadf7"},
{file = "multidict-6.0.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8e94e6912639a02ce173341ff62cc1201232ab86b8a8fcc05572741a5dc7d93"},
{file = "multidict-6.0.5-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09a892e4a9fb47331da06948690ae38eaa2426de97b4ccbfafbdcbe5c8f37ff8"},
{file = "multidict-6.0.5-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:55205d03e8a598cfc688c71ca8ea5f66447164efff8869517f175ea632c7cb7b"},
{file = "multidict-6.0.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:37b15024f864916b4951adb95d3a80c9431299080341ab9544ed148091b53f50"},
{file = "multidict-6.0.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2a1dee728b52b33eebff5072817176c172050d44d67befd681609b4746e1c2e"},
{file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:edd08e6f2f1a390bf137080507e44ccc086353c8e98c657e666c017718561b89"},
{file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:60d698e8179a42ec85172d12f50b1668254628425a6bd611aba022257cac1386"},
{file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:3d25f19500588cbc47dc19081d78131c32637c25804df8414463ec908631e453"},
{file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:4cc0ef8b962ac7a5e62b9e826bd0cd5040e7d401bc45a6835910ed699037a461"},
{file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:eca2e9d0cc5a889850e9bbd68e98314ada174ff6ccd1129500103df7a94a7a44"},
{file = "multidict-6.0.5-cp38-cp38-win32.whl", hash = "sha256:4a6a4f196f08c58c59e0b8ef8ec441d12aee4125a7d4f4fef000ccb22f8d7241"},
{file = "multidict-6.0.5-cp38-cp38-win_amd64.whl", hash = "sha256:0275e35209c27a3f7951e1ce7aaf93ce0d163b28948444bec61dd7badc6d3f8c"},
{file = "multidict-6.0.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e7be68734bd8c9a513f2b0cfd508802d6609da068f40dc57d4e3494cefc92929"},
{file = "multidict-6.0.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1d9ea7a7e779d7a3561aade7d596649fbecfa5c08a7674b11b423783217933f9"},
{file = "multidict-6.0.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ea1456df2a27c73ce51120fa2f519f1bea2f4a03a917f4a43c8707cf4cbbae1a"},
{file = "multidict-6.0.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf590b134eb70629e350691ecca88eac3e3b8b3c86992042fb82e3cb1830d5e1"},
{file = "multidict-6.0.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5c0631926c4f58e9a5ccce555ad7747d9a9f8b10619621f22f9635f069f6233e"},
{file = "multidict-6.0.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dce1c6912ab9ff5f179eaf6efe7365c1f425ed690b03341911bf4939ef2f3046"},
{file = "multidict-6.0.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0868d64af83169e4d4152ec612637a543f7a336e4a307b119e98042e852ad9c"},
{file = "multidict-6.0.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:141b43360bfd3bdd75f15ed811850763555a251e38b2405967f8e25fb43f7d40"},
{file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7df704ca8cf4a073334e0427ae2345323613e4df18cc224f647f251e5e75a527"},
{file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:6214c5a5571802c33f80e6c84713b2c79e024995b9c5897f794b43e714daeec9"},
{file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:cd6c8fca38178e12c00418de737aef1261576bd1b6e8c6134d3e729a4e858b38"},
{file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:e02021f87a5b6932fa6ce916ca004c4d441509d33bbdbeca70d05dff5e9d2479"},
{file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ebd8d160f91a764652d3e51ce0d2956b38efe37c9231cd82cfc0bed2e40b581c"},
{file = "multidict-6.0.5-cp39-cp39-win32.whl", hash = "sha256:04da1bb8c8dbadf2a18a452639771951c662c5ad03aefe4884775454be322c9b"},
{file = "multidict-6.0.5-cp39-cp39-win_amd64.whl", hash = "sha256:d6f6d4f185481c9669b9447bf9d9cf3b95a0e9df9d169bbc17e363b7d5487755"},
{file = "multidict-6.0.5-py3-none-any.whl", hash = "sha256:0d63c74e3d7ab26de115c49bffc92cc77ed23395303d496eae515d4204a625e7"},
{file = "multidict-6.0.5.tar.gz", hash = "sha256:f7e301075edaf50500f0b341543c41194d8df3ae5caf4702f2095f3ca73dd8da"},
]
[[package]]
name = "pygments"
version = "2.17.2"
description = "Pygments is a syntax highlighting package written in Python."
optional = false
python-versions = ">=3.7"
files = [
{file = "pygments-2.17.2-py3-none-any.whl", hash = "sha256:b27c2826c47d0f3219f29554824c30c5e8945175d888647acd804ddd04af846c"},
{file = "pygments-2.17.2.tar.gz", hash = "sha256:da46cec9fd2de5be3a8a784f434e4c4ab670b4ff54d605c4c2717e9d49c4c367"},
]
[package.extras]
plugins = ["importlib-metadata"]
windows-terminal = ["colorama (>=0.4.6)"]
[[package]]
name = "rich"
version = "13.7.0"
description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
optional = false
python-versions = ">=3.7.0"
files = [
{file = "rich-13.7.0-py3-none-any.whl", hash = "sha256:6da14c108c4866ee9520bbffa71f6fe3962e193b7da68720583850cd4548e235"},
{file = "rich-13.7.0.tar.gz", hash = "sha256:5cb5123b5cf9ee70584244246816e9114227e0b98ad9176eede6ad54bf5403fa"},
]
[package.dependencies]
markdown-it-py = ">=2.2.0"
pygments = ">=2.13.0,<3.0.0"
typing-extensions = {version = ">=4.0.0,<5.0", markers = "python_version < \"3.9\""}
[package.extras]
jupyter = ["ipywidgets (>=7.5.1,<9)"]
[[package]]
name = "textual"
version = "0.50.1"
description = "Modern Text User Interface framework"
optional = false
python-versions = ">=3.8,<4.0"
files = [
{file = "textual-0.50.1-py3-none-any.whl", hash = "sha256:11bd87fe6c543358122c43db2e9dfc5940900ef9b8975502ab7043792928638b"},
{file = "textual-0.50.1.tar.gz", hash = "sha256:415bef44b2dfa702d17ebb08637c0141eb54767cfbeafe60d07e62104183b56a"},
]
[package.dependencies]
markdown-it-py = {version = ">=2.1.0", extras = ["linkify", "plugins"]}
rich = ">=13.3.3"
typing-extensions = ">=4.4.0,<5.0.0"
[package.extras]
syntax = ["tree-sitter (>=0.20.1,<0.21.0)", "tree_sitter_languages (>=1.7.0)"]
[[package]]
name = "textual-dev"
version = "1.4.0"
description = "Development tools for working with Textual"
optional = false
python-versions = ">=3.8,<4.0"
files = [
{file = "textual_dev-1.4.0-py3-none-any.whl", hash = "sha256:330beec18b8f469adf7cdf9d69cc94965bf3e69d9aec23625d62cdedcadab044"},
{file = "textual_dev-1.4.0.tar.gz", hash = "sha256:a20ea746a93e66978e9dfe71a7e5409854c96cc3e46550cc40760b199d7a5d3b"},
]
[package.dependencies]
aiohttp = ">=3.8.1"
click = ">=8.1.2"
msgpack = ">=1.0.3"
textual = ">=0.36.0"
typing-extensions = ">=4.4.0,<5.0.0"
[[package]]
name = "typing-extensions"
version = "4.9.0"
description = "Backported and Experimental Type Hints for Python 3.8+"
optional = false
python-versions = ">=3.8"
files = [
{file = "typing_extensions-4.9.0-py3-none-any.whl", hash = "sha256:af72aea155e91adfc61c3ae9e0e342dbc0cba726d6cba4b6c72c1f34e47291cd"},
{file = "typing_extensions-4.9.0.tar.gz", hash = "sha256:23478f88c37f27d76ac8aee6c905017a143b0b1b886c3c9f66bc2fd94f9f5783"},
]
[[package]]
name = "uc-micro-py"
version = "1.0.3"
description = "Micro subset of unicode data files for linkify-it-py projects."
optional = false
python-versions = ">=3.7"
files = [
{file = "uc-micro-py-1.0.3.tar.gz", hash = "sha256:d321b92cff673ec58027c04015fcaa8bb1e005478643ff4a500882eaab88c48a"},
{file = "uc_micro_py-1.0.3-py3-none-any.whl", hash = "sha256:db1dffff340817673d7b466ec86114a9dc0e9d4d9b5ba229d9d60e5c12600cd5"},
]
[package.extras]
test = ["coverage", "pytest", "pytest-cov"]
[[package]]
name = "yarl"
version = "1.9.4"
description = "Yet another URL library"
optional = false
python-versions = ">=3.7"
files = [
{file = "yarl-1.9.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a8c1df72eb746f4136fe9a2e72b0c9dc1da1cbd23b5372f94b5820ff8ae30e0e"},
{file = "yarl-1.9.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a3a6ed1d525bfb91b3fc9b690c5a21bb52de28c018530ad85093cc488bee2dd2"},
{file = "yarl-1.9.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c38c9ddb6103ceae4e4498f9c08fac9b590c5c71b0370f98714768e22ac6fa66"},
{file = "yarl-1.9.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d9e09c9d74f4566e905a0b8fa668c58109f7624db96a2171f21747abc7524234"},
{file = "yarl-1.9.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b8477c1ee4bd47c57d49621a062121c3023609f7a13b8a46953eb6c9716ca392"},
{file = "yarl-1.9.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5ff2c858f5f6a42c2a8e751100f237c5e869cbde669a724f2062d4c4ef93551"},
{file = "yarl-1.9.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:357495293086c5b6d34ca9616a43d329317feab7917518bc97a08f9e55648455"},
{file = "yarl-1.9.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:54525ae423d7b7a8ee81ba189f131054defdb122cde31ff17477951464c1691c"},
{file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:801e9264d19643548651b9db361ce3287176671fb0117f96b5ac0ee1c3530d53"},
{file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e516dc8baf7b380e6c1c26792610230f37147bb754d6426462ab115a02944385"},
{file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:7d5aaac37d19b2904bb9dfe12cdb08c8443e7ba7d2852894ad448d4b8f442863"},
{file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:54beabb809ffcacbd9d28ac57b0db46e42a6e341a030293fb3185c409e626b8b"},
{file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bac8d525a8dbc2a1507ec731d2867025d11ceadcb4dd421423a5d42c56818541"},
{file = "yarl-1.9.4-cp310-cp310-win32.whl", hash = "sha256:7855426dfbddac81896b6e533ebefc0af2f132d4a47340cee6d22cac7190022d"},
{file = "yarl-1.9.4-cp310-cp310-win_amd64.whl", hash = "sha256:848cd2a1df56ddbffeb375535fb62c9d1645dde33ca4d51341378b3f5954429b"},
{file = "yarl-1.9.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:35a2b9396879ce32754bd457d31a51ff0a9d426fd9e0e3c33394bf4b9036b099"},
{file = "yarl-1.9.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4c7d56b293cc071e82532f70adcbd8b61909eec973ae9d2d1f9b233f3d943f2c"},
{file = "yarl-1.9.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d8a1c6c0be645c745a081c192e747c5de06e944a0d21245f4cf7c05e457c36e0"},
{file = "yarl-1.9.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4b3c1ffe10069f655ea2d731808e76e0f452fc6c749bea04781daf18e6039525"},
{file = "yarl-1.9.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:549d19c84c55d11687ddbd47eeb348a89df9cb30e1993f1b128f4685cd0ebbf8"},
{file = "yarl-1.9.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a7409f968456111140c1c95301cadf071bd30a81cbd7ab829169fb9e3d72eae9"},
{file = "yarl-1.9.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e23a6d84d9d1738dbc6e38167776107e63307dfc8ad108e580548d1f2c587f42"},
{file = "yarl-1.9.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d8b889777de69897406c9fb0b76cdf2fd0f31267861ae7501d93003d55f54fbe"},
{file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:03caa9507d3d3c83bca08650678e25364e1843b484f19986a527630ca376ecce"},
{file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:4e9035df8d0880b2f1c7f5031f33f69e071dfe72ee9310cfc76f7b605958ceb9"},
{file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:c0ec0ed476f77db9fb29bca17f0a8fcc7bc97ad4c6c1d8959c507decb22e8572"},
{file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:ee04010f26d5102399bd17f8df8bc38dc7ccd7701dc77f4a68c5b8d733406958"},
{file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:49a180c2e0743d5d6e0b4d1a9e5f633c62eca3f8a86ba5dd3c471060e352ca98"},
{file = "yarl-1.9.4-cp311-cp311-win32.whl", hash = "sha256:81eb57278deb6098a5b62e88ad8281b2ba09f2f1147c4767522353eaa6260b31"},
{file = "yarl-1.9.4-cp311-cp311-win_amd64.whl", hash = "sha256:d1d2532b340b692880261c15aee4dc94dd22ca5d61b9db9a8a361953d36410b1"},
{file = "yarl-1.9.4-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0d2454f0aef65ea81037759be5ca9947539667eecebca092733b2eb43c965a81"},
{file = "yarl-1.9.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:44d8ffbb9c06e5a7f529f38f53eda23e50d1ed33c6c869e01481d3fafa6b8142"},
{file = "yarl-1.9.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:aaaea1e536f98754a6e5c56091baa1b6ce2f2700cc4a00b0d49eca8dea471074"},
{file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3777ce5536d17989c91696db1d459574e9a9bd37660ea7ee4d3344579bb6f129"},
{file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9fc5fc1eeb029757349ad26bbc5880557389a03fa6ada41703db5e068881e5f2"},
{file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea65804b5dc88dacd4a40279af0cdadcfe74b3e5b4c897aa0d81cf86927fee78"},
{file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa102d6d280a5455ad6a0f9e6d769989638718e938a6a0a2ff3f4a7ff8c62cc4"},
{file = "yarl-1.9.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09efe4615ada057ba2d30df871d2f668af661e971dfeedf0c159927d48bbeff0"},
{file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:008d3e808d03ef28542372d01057fd09168419cdc8f848efe2804f894ae03e51"},
{file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:6f5cb257bc2ec58f437da2b37a8cd48f666db96d47b8a3115c29f316313654ff"},
{file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:992f18e0ea248ee03b5a6e8b3b4738850ae7dbb172cc41c966462801cbf62cf7"},
{file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:0e9d124c191d5b881060a9e5060627694c3bdd1fe24c5eecc8d5d7d0eb6faabc"},
{file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3986b6f41ad22988e53d5778f91855dc0399b043fc8946d4f2e68af22ee9ff10"},
{file = "yarl-1.9.4-cp312-cp312-win32.whl", hash = "sha256:4b21516d181cd77ebd06ce160ef8cc2a5e9ad35fb1c5930882baff5ac865eee7"},
{file = "yarl-1.9.4-cp312-cp312-win_amd64.whl", hash = "sha256:a9bd00dc3bc395a662900f33f74feb3e757429e545d831eef5bb280252631984"},
{file = "yarl-1.9.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:63b20738b5aac74e239622d2fe30df4fca4942a86e31bf47a81a0e94c14df94f"},
{file = "yarl-1.9.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7d7f7de27b8944f1fee2c26a88b4dabc2409d2fea7a9ed3df79b67277644e17"},
{file = "yarl-1.9.4-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c74018551e31269d56fab81a728f683667e7c28c04e807ba08f8c9e3bba32f14"},
{file = "yarl-1.9.4-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ca06675212f94e7a610e85ca36948bb8fc023e458dd6c63ef71abfd482481aa5"},
{file = "yarl-1.9.4-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5aef935237d60a51a62b86249839b51345f47564208c6ee615ed2a40878dccdd"},
{file = "yarl-1.9.4-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2b134fd795e2322b7684155b7855cc99409d10b2e408056db2b93b51a52accc7"},
{file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d25039a474c4c72a5ad4b52495056f843a7ff07b632c1b92ea9043a3d9950f6e"},
{file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:f7d6b36dd2e029b6bcb8a13cf19664c7b8e19ab3a58e0fefbb5b8461447ed5ec"},
{file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:957b4774373cf6f709359e5c8c4a0af9f6d7875db657adb0feaf8d6cb3c3964c"},
{file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:d7eeb6d22331e2fd42fce928a81c697c9ee2d51400bd1a28803965883e13cead"},
{file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:6a962e04b8f91f8c4e5917e518d17958e3bdee71fd1d8b88cdce74dd0ebbf434"},
{file = "yarl-1.9.4-cp37-cp37m-win32.whl", hash = "sha256:f3bc6af6e2b8f92eced34ef6a96ffb248e863af20ef4fde9448cc8c9b858b749"},
{file = "yarl-1.9.4-cp37-cp37m-win_amd64.whl", hash = "sha256:ad4d7a90a92e528aadf4965d685c17dacff3df282db1121136c382dc0b6014d2"},
{file = "yarl-1.9.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ec61d826d80fc293ed46c9dd26995921e3a82146feacd952ef0757236fc137be"},
{file = "yarl-1.9.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8be9e837ea9113676e5754b43b940b50cce76d9ed7d2461df1af39a8ee674d9f"},
{file = "yarl-1.9.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:bef596fdaa8f26e3d66af846bbe77057237cb6e8efff8cd7cc8dff9a62278bbf"},
{file = "yarl-1.9.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2d47552b6e52c3319fede1b60b3de120fe83bde9b7bddad11a69fb0af7db32f1"},
{file = "yarl-1.9.4-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84fc30f71689d7fc9168b92788abc977dc8cefa806909565fc2951d02f6b7d57"},
{file = "yarl-1.9.4-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4aa9741085f635934f3a2583e16fcf62ba835719a8b2b28fb2917bb0537c1dfa"},
{file = "yarl-1.9.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:206a55215e6d05dbc6c98ce598a59e6fbd0c493e2de4ea6cc2f4934d5a18d130"},
{file = "yarl-1.9.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07574b007ee20e5c375a8fe4a0789fad26db905f9813be0f9fef5a68080de559"},
{file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5a2e2433eb9344a163aced6a5f6c9222c0786e5a9e9cac2c89f0b28433f56e23"},
{file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:6ad6d10ed9b67a382b45f29ea028f92d25bc0bc1daf6c5b801b90b5aa70fb9ec"},
{file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:6fe79f998a4052d79e1c30eeb7d6c1c1056ad33300f682465e1b4e9b5a188b78"},
{file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:a825ec844298c791fd28ed14ed1bffc56a98d15b8c58a20e0e08c1f5f2bea1be"},
{file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8619d6915b3b0b34420cf9b2bb6d81ef59d984cb0fde7544e9ece32b4b3043c3"},
{file = "yarl-1.9.4-cp38-cp38-win32.whl", hash = "sha256:686a0c2f85f83463272ddffd4deb5e591c98aac1897d65e92319f729c320eece"},
{file = "yarl-1.9.4-cp38-cp38-win_amd64.whl", hash = "sha256:a00862fb23195b6b8322f7d781b0dc1d82cb3bcac346d1e38689370cc1cc398b"},
{file = "yarl-1.9.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:604f31d97fa493083ea21bd9b92c419012531c4e17ea6da0f65cacdcf5d0bd27"},
{file = "yarl-1.9.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8a854227cf581330ffa2c4824d96e52ee621dd571078a252c25e3a3b3d94a1b1"},
{file = "yarl-1.9.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ba6f52cbc7809cd8d74604cce9c14868306ae4aa0282016b641c661f981a6e91"},
{file = "yarl-1.9.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a6327976c7c2f4ee6816eff196e25385ccc02cb81427952414a64811037bbc8b"},
{file = "yarl-1.9.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8397a3817d7dcdd14bb266283cd1d6fc7264a48c186b986f32e86d86d35fbac5"},
{file = "yarl-1.9.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e0381b4ce23ff92f8170080c97678040fc5b08da85e9e292292aba67fdac6c34"},
{file = "yarl-1.9.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23d32a2594cb5d565d358a92e151315d1b2268bc10f4610d098f96b147370136"},
{file = "yarl-1.9.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ddb2a5c08a4eaaba605340fdee8fc08e406c56617566d9643ad8bf6852778fc7"},
{file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:26a1dc6285e03f3cc9e839a2da83bcbf31dcb0d004c72d0730e755b33466c30e"},
{file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:18580f672e44ce1238b82f7fb87d727c4a131f3a9d33a5e0e82b793362bf18b4"},
{file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:29e0f83f37610f173eb7e7b5562dd71467993495e568e708d99e9d1944f561ec"},
{file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:1f23e4fe1e8794f74b6027d7cf19dc25f8b63af1483d91d595d4a07eca1fb26c"},
{file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:db8e58b9d79200c76956cefd14d5c90af54416ff5353c5bfd7cbe58818e26ef0"},
{file = "yarl-1.9.4-cp39-cp39-win32.whl", hash = "sha256:c7224cab95645c7ab53791022ae77a4509472613e839dab722a72abe5a684575"},
{file = "yarl-1.9.4-cp39-cp39-win_amd64.whl", hash = "sha256:824d6c50492add5da9374875ce72db7a0733b29c2394890aef23d533106e2b15"},
{file = "yarl-1.9.4-py3-none-any.whl", hash = "sha256:928cecb0ef9d5a7946eb6ff58417ad2fe9375762382f1bf5c55e61645f2c43ad"},
{file = "yarl-1.9.4.tar.gz", hash = "sha256:566db86717cf8080b99b58b083b773a908ae40f06681e87e589a976faf8246bf"},
]
[package.dependencies]
idna = ">=2.0"
multidict = ">=4.0"
[metadata]
lock-version = "2.0"
python-versions = "^3.8"
content-hash = "47c708d9c31b3ce34b866394fb39b7565ba5be9a1e8ffb6497168e423c500b59"

28
pyproject.toml Normal file
View file

@ -0,0 +1,28 @@
[tool.poetry]
name = "toolong"
version = "1.4.0"
description = "A terminal log file viewer / tailer / analyzer"
authors = ["Will McGugan <will@textualize.io>"]
license = "MIT"
readme = "README.md"
homepage = "https://github.com/textualize/toolong"
repository = "https://github.com/textualize/toolong"
documentation = "https://github.com/textualize/toolong"
[tool.poetry.dependencies]
python = "^3.8"
click = "^8.1.7"
textual = "^0.52.0"
typing-extensions = "^4.9.0"
[tool.poetry.group.dev.dependencies]
textual-dev = "^1.4.0"
[tool.poetry.scripts]
tl = "toolong.cli:run"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

0
src/toolong/__init__.py Normal file
View file

4
src/toolong/__main__.py Normal file
View file

@ -0,0 +1,4 @@
from toolong.cli import run
if __name__ == "__main__":
run()

76
src/toolong/cli.py Normal file
View file

@ -0,0 +1,76 @@
from __future__ import annotations
from importlib.metadata import version
import os
import sys
import click
from toolong.ui import UI
@click.command()
@click.version_option(version("toolong"))
@click.argument("files", metavar="FILE1 FILE2", nargs=-1)
@click.option("-m", "--merge", is_flag=True, help="Merge files.")
@click.option(
"-o",
"--output-merge",
metavar="PATH",
nargs=1,
help="Path to save merged file (requires -m).",
)
def run(files: list[str], merge: bool, output_merge: str) -> None:
"""View / tail / search log files."""
stdin_tty = sys.__stdin__.isatty()
if not files and stdin_tty:
ctx = click.get_current_context()
click.echo(ctx.get_help())
ctx.exit()
if stdin_tty:
try:
ui = UI(files, merge=merge, save_merge=output_merge)
ui.run()
except Exception:
pass
else:
import signal
import selectors
import subprocess
import tempfile
def request_exit(*args) -> None:
"""Don't write anything when a signal forces an error."""
sys.stderr.write("^C")
signal.signal(signal.SIGINT, request_exit)
signal.signal(signal.SIGTERM, request_exit)
# Write piped data to a temporary file
with tempfile.NamedTemporaryFile(
mode="w+b", buffering=0, prefix="tl_"
) as temp_file:
# Get input directly from /dev/tty to free up stdin
with open("/dev/tty", "rb", buffering=0) as tty_stdin:
# Launch a new process to render the UI
with subprocess.Popen(
[sys.argv[0], temp_file.name],
stdin=tty_stdin,
close_fds=True,
env={**os.environ, "TEXTUAL_ALLOW_SIGNALS": "1"},
) as process:
# Current process copies from stdin to the temp file
selector = selectors.SelectSelector()
selector.register(sys.stdin.fileno(), selectors.EVENT_READ)
while process.poll() is None:
for _, event in selector.select(0.1):
if process.poll() is not None:
break
if event & selectors.EVENT_READ:
if line := os.read(sys.stdin.fileno(), 1024 * 64):
temp_file.write(line)
else:
break

158
src/toolong/find_dialog.py Normal file
View file

@ -0,0 +1,158 @@
from dataclasses import dataclass
import re
from textual import on
from textual.app import ComposeResult
from textual.binding import Binding
from textual.message import Message
from textual.suggester import Suggester
from textual.validation import Validator, ValidationResult
from textual.widget import Widget
from textual.widgets import Input, Checkbox
class Regex(Validator):
def validate(self, value: str) -> ValidationResult:
"""Check a string is equal to its reverse."""
try:
re.compile(value)
except Exception:
return self.failure("Invalid regex")
else:
return self.success()
class FindDialog(Widget, can_focus_children=True):
DEFAULT_CSS = """
FindDialog {
layout: horizontal;
dock: top;
padding-top: 1;
width: 1fr;
height: auto;
max-height: 70%;
display: none;
& #find {
width: 1fr;
}
&.visible {
display: block;
}
Input {
width: 1fr;
}
Input#find-regex {
display: none;
}
Input#find-text {
display: block;
}
&.-find-regex {
Input#find-regex {
display: block;
}
Input#find-text {
display: none;
}
}
}
"""
BINDINGS = [
Binding("escape", "dismiss_find", "Dismiss", key_display="esc", show=False),
Binding("down,j", "pointer_down", "Next", key_display=""),
Binding("up,k", "pointer_up", "Previous", key_display=""),
Binding("j", "pointer_down", "Next", key_display="", show=False),
Binding("k", "pointer_up", "Previous", key_display="", show=False),
]
DEFAULT_CLASSES = "float"
BORDER_TITLE = "Find"
@dataclass
class Update(Message):
find: str
regex: bool
case_sensitive: bool
class Dismiss(Message):
pass
@dataclass
class MovePointer(Message):
direction: int = 1
class SelectLine(Message):
pass
def __init__(self, suggester: Suggester) -> None:
self.suggester = suggester
super().__init__()
def compose(self) -> ComposeResult:
yield Input(
placeholder="Regex",
id="find-regex",
suggester=self.suggester,
validators=[Regex()],
)
yield Input(
placeholder="Find",
id="find-text",
suggester=self.suggester,
)
yield Checkbox("Case sensitive", id="case-sensitive")
yield Checkbox("Regex", id="regex")
def focus_input(self) -> None:
if self.has_class("find-regex"):
self.query_one("#find-regex").focus()
else:
self.query_one("#find-text").focus()
def get_value(self) -> str:
if self.has_class("find-regex"):
return self.query_one("#find-regex", Input).value
else:
return self.query_one("#find-text", Input).value
@on(Checkbox.Changed, "#regex")
def on_checkbox_changed_regex(self, event: Checkbox.Changed):
if event.value:
self.query_one("#find-regex", Input).value = self.query_one(
"#find-text", Input
).value
else:
self.query_one("#find-text", Input).value = self.query_one(
"#find-regex", Input
).value
self.set_class(event.value, "-find-regex")
@on(Input.Changed)
@on(Checkbox.Changed)
def input_change(self, event: Input.Changed) -> None:
event.stop()
self.post_update()
@on(Input.Submitted)
def input_submitted(self, event: Input.Changed) -> None:
event.stop()
self.post_message(self.SelectLine())
def post_update(self) -> None:
update = FindDialog.Update(
find=self.get_value(),
regex=self.query_one("#regex", Checkbox).value,
case_sensitive=self.query_one("#case-sensitive", Checkbox).value,
)
self.post_message(update)
def allow_focus_children(self) -> bool:
return self.has_class("visible")
def action_dismiss_find(self) -> None:
self.post_message(FindDialog.Dismiss())
def action_pointer_down(self) -> None:
self.post_message(self.MovePointer(direction=+1))
def action_pointer_up(self) -> None:
self.post_message(self.MovePointer(direction=-1))

View file

@ -0,0 +1,123 @@
from __future__ import annotations
from datetime import datetime
import json
import re
from typing_extensions import TypeAlias
from rich.highlighter import JSONHighlighter
from rich.text import Text
from toolong.highlighter import LogHighlighter
from toolong import timestamps
from typing import Optional
ParseResult: TypeAlias = "tuple[Optional[datetime], str, Text]"
class LogFormat:
def parse(self, line: str) -> ParseResult | None:
raise NotImplementedError()
class RegexLogFormat(LogFormat):
REGEX = re.compile(".*?")
HIGHLIGHT_WORDS = [
"GET",
"POST",
"PUT",
"HEAD",
"POST",
"DELETE",
"OPTIONS",
"PATCH",
]
highlighter = LogHighlighter()
def parse(self, line: str) -> ParseResult | None:
match = self.REGEX.fullmatch(line)
if match is None:
return None
groups = match.groupdict()
_, timestamp = timestamps.parse(groups["date"].strip("[]"))
text = Text.from_ansi(line)
if not text.spans:
text = self.highlighter(text)
if status := groups.get("status", None):
text.highlight_words(
[f" {status} "], "bold red" if status.startswith("4") else "magenta"
)
text.highlight_words(self.HIGHLIGHT_WORDS, "bold yellow")
return timestamp, line, text
class CommonLogFormat(RegexLogFormat):
REGEX = re.compile(
r'(?P<ip>.*?) (?P<remote_log_name>.*?) (?P<userid>.*?) (?P<date>\[.*?(?= ).*?\]) "(?P<request_method>.*?) (?P<path>.*?)(?P<request_version> HTTP\/.*)?" (?P<status>.*?) (?P<length>.*?) "(?P<referrer>.*?)"'
)
class CombinedLogFormat(RegexLogFormat):
REGEX = re.compile(
r'(?P<ip>.*?) (?P<remote_log_name>.*?) (?P<userid>.*?) \[(?P<date>.*?)(?= ) (?P<timezone>.*?)\] "(?P<request_method>.*?) (?P<path>.*?)(?P<request_version> HTTP\/.*)?" (?P<status>.*?) (?P<length>.*?) "(?P<referrer>.*?)" "(?P<user_agent>.*?)" (?P<session_id>.*?) (?P<generation_time_micro>.*?) (?P<virtual_host>.*)'
)
class DefaultLogFormat(LogFormat):
highlighter = LogHighlighter()
def parse(self, line: str) -> ParseResult | None:
text = Text.from_ansi(line)
if not text.spans:
text = self.highlighter(text)
return None, line, text
class JSONLogFormat(LogFormat):
highlighter = JSONHighlighter()
def parse(self, line: str) -> ParseResult | None:
line = line.strip()
if not line:
return None
try:
json.loads(line)
except Exception:
return None
_, timestamp = timestamps.parse(line)
text = Text.from_ansi(line)
if not text.spans:
text = self.highlighter(text)
return timestamp, line, text
FORMATS = [
JSONLogFormat(),
CommonLogFormat(),
CombinedLogFormat(),
DefaultLogFormat(),
]
class FormatParser:
"""Parses a log line."""
def __init__(self) -> None:
self._formats = FORMATS.copy()
def parse(self, line: str) -> ParseResult:
"""Parse a line."""
if len(line) > 10_000:
line = line[:10_000]
for index, format in enumerate(self._formats):
parse_result = format.parse(line)
if parse_result is not None:
if index:
del self._formats[index : index + 1]
self._formats.insert(0, format)
return parse_result
return None, "", Text()

View file

@ -0,0 +1,65 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from textual.app import ComposeResult
from textual.screen import ModalScreen
from textual.containers import Horizontal
from textual.widgets import Input, Label
from textual.validation import Integer
if TYPE_CHECKING:
from toolong.log_lines import LogLines
class GotoScreen(ModalScreen):
BINDINGS = [("escape", "dismiss")]
DEFAULT_CSS = """
GotoScreen {
background: black 20%;
align: right bottom;
#goto {
width: auto;
height: auto;
margin: 3 3;
Label {
margin: 1;
}
Input {
width: 16;
}
}
}
"""
def __init__(self, log_lines: LogLines) -> None:
self.log_lines = log_lines
super().__init__()
def compose(self) -> ComposeResult:
log_lines = self.log_lines
with Horizontal(id="goto"):
yield Input(
(
str(
log_lines.pointer_line + 1
if log_lines.pointer_line is not None
else log_lines.scroll_offset.y + 1
)
),
placeholder="Enter line number",
type="integer",
)
def on_input_changed(self, event: Input.Changed) -> None:
try:
line_no = int(event.value) - 1
except Exception:
self.log_lines.pointer_line = None
else:
self.log_lines.pointer_line = line_no
self.log_lines.scroll_pointer_to_center()

180
src/toolong/help.py Normal file
View file

@ -0,0 +1,180 @@
import webbrowser
from importlib.metadata import version
from rich.text import Text
from textual import on
from textual.app import ComposeResult
from textual.containers import Center, VerticalScroll
from textual.screen import ModalScreen
from textual.widgets import Static, Markdown, Footer
TEXTUAL_LINK = "https://www.textualize.io/"
REPOSITORY_LINK = "https://github.com/Textualize/toolong"
LOGMERGER_LINK = "https://github.com/ptmcg/logmerger"
HELP_MD = """
TooLong is a log file viewer / navigator for the terminal.
Built with [Textual](https://www.textualize.io/)
Repository: [https://github.com/Textualize/toolong](https://github.com/Textualize/toolong) Author: [Will McGugan](https://www.willmcgugan.com)
---
### Navigation
- `tab` / `shift+tab` to navigate between widgets.
- `home` / `end` Jump to start or end of file. Press `end` a second time to *tail* the current file.
- `page up` / `page down` to go to the next / previous page.
- `` / `` Move up / down a line.
- `m` / `M` Advance +1 / -1 minutes.
- `h` / `H` Advance +1 / -1 hours.
- `d` / `D` Advance +1 / -1 days.
- `enter` Toggle pointer mode.
- `escape` Dismiss.
### Other keys
- `ctrl+f` or `/` Show find dialog.
- `ctrl+l` Toggle line numbers.
- `ctrl+t` Tail current file.
- `ctrl+c` Exit the app.
### Opening Files
Open files from the command line.
```bash
$ tl foo.log bar.log
```
If you specify more than one file, they will be displayed within tabs.
#### Opening compressed files
If a file is compressed with BZip or GZip, it will be uncompressed automatically:
```bash
$ tl foo.log.2.gz
```
#### Merging files
Multiple files will open in tabs.
If you add the `--merge` switch, TooLong will merge all the log files based on their timestamps:
```bash
$ tl mysite.log* --merge
```
### Pointer mode
Pointer mode lets you navigate by line.
To enter pointer mode, press `enter` or click a line.
When in pointer mode, the navigation keys will move this pointer rather than scroll the log file.
Press `enter` again or click the line a second time to expand the line in to a new panel.
Press `escape` to hide the line panel if it is visible, or to leave pointer mode if the line panel is not visible.
### Credits
Inspiration and regexes taken from [LogMerger](https://github.com/ptmcg/logmerger) by Paul McGuire.
### License
Copyright 2024 Will McGugan
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the Software), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
"""
TITLE = rf"""
_______ _
|__ __| | | Built with Textual
| | ___ ___ | | ___ _ __ __ _
| |/ _ \ / _ \| | / _ \| '_ \ / _` |
| | (_) | (_) | |___| (_) | | | | (_| |
|_|\___/ \___/|______\___/|_| |_|\__, |
__/ |
Moving at Terminal velocity |___/ v{version('toolong')}
"""
COLORS = [
"#881177",
"#aa3355",
"#cc6666",
"#ee9944",
"#eedd00",
"#99dd55",
"#44dd88",
"#22ccbb",
"#00bbcc",
"#0099cc",
"#3366bb",
"#663399",
]
def get_title() -> Text:
"""Get the title, with a rainbow effect."""
lines = TITLE.splitlines(keepends=True)
return Text.assemble(*zip(lines, COLORS))
class HelpScreen(ModalScreen):
"""Simple Help screen with Markdown and a few links."""
CSS = """
HelpScreen VerticalScroll {
background: $surface;
margin: 4 8;
border: heavy $accent;
height: 1fr;
.title {
width: auto;
}
scrollbar-gutter: stable;
Markdown {
margin:0 2;
}
Markdown .code_inline {
background: $primary-darken-1;
text-style: bold;
}
}
"""
BINDINGS = [
("escape", "dismiss"),
("a", "go('https://www.willmcgugan.com')", "Author"),
("t", f"go({TEXTUAL_LINK!r})", "Textual"),
("r", f"go({REPOSITORY_LINK!r})", "Repository"),
("l", f"go({LOGMERGER_LINK!r})", "Logmerger"),
]
def compose(self) -> ComposeResult:
yield Footer()
with VerticalScroll() as vertical_scroll:
with Center():
yield Static(get_title(), classes="title")
yield Markdown(HELP_MD)
vertical_scroll.border_title = "Help"
vertical_scroll.border_subtitle = "ESCAPE to dismiss"
@on(Markdown.LinkClicked)
def on_markdown_link_clicked(self, event: Markdown.LinkClicked) -> None:
self.action_go(event.href)
def action_go(self, href: str) -> None:
self.notify(f"Opening {href}", title="Link")
webbrowser.open(href)

View file

@ -0,0 +1,45 @@
from rich.highlighter import RegexHighlighter
from rich.text import Text
def _combine_regex(*regexes: str) -> str:
"""Combine a number of regexes in to a single regex.
Returns:
str: New regex with all regexes ORed together.
"""
return "|".join(regexes)
class LogHighlighter(RegexHighlighter):
"""Highlights the text typically produced from ``__repr__`` methods."""
base_style = "repr."
highlights = [
_combine_regex(
r"(?P<ipv4>[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3})",
r"(?P<ipv6>([A-Fa-f0-9]{1,4}::?){1,7}[A-Fa-f0-9]{1,4})",
r"(?P<eui64>(?:[0-9A-Fa-f]{1,2}-){7}[0-9A-Fa-f]{1,2}|(?:[0-9A-Fa-f]{1,2}:){7}[0-9A-Fa-f]{1,2}|(?:[0-9A-Fa-f]{4}\.){3}[0-9A-Fa-f]{4})",
r"(?P<eui48>(?:[0-9A-Fa-f]{1,2}-){5}[0-9A-Fa-f]{1,2}|(?:[0-9A-Fa-f]{1,2}:){5}[0-9A-Fa-f]{1,2}|(?:[0-9A-Fa-f]{4}\.){2}[0-9A-Fa-f]{4})",
r"(?P<uuid>[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12})",
r"\b(?P<bool_true>True)\b|\b(?P<bool_false>False)\b|\b(?P<none>None)\b",
r"(?P<number>(?<!\w)\-?[0-9]+\.?[0-9]*(e[-+]?\d+?)?\b|0x[0-9a-fA-F]*)",
r"(?<![\\\w])(?P<str>b?'''.*?(?<!\\)'''|b?'.*?(?<!\\)'|b?\"\"\".*?(?<!\\)\"\"\"|b?\".*?(?<!\\)\")",
# r"(?P<url>(file|https|http|ws|wss)://[-0-9a-zA-Z$_+!`(),.?/;:&=%#~]*)",
r"(?P<path>\[.*?\])",
),
]
def highlight(self, text: Text) -> None:
"""Highlight :class:`rich.text.Text` using regular expressions.
Args:
text (~Text): Text to highlighted.
"""
if len(text) >= 10_000:
return
highlight_regex = text.highlight_regex
for re_highlight in self.highlights:
highlight_regex(re_highlight, style_prefix=self.base_style)

64
src/toolong/line_panel.py Normal file
View file

@ -0,0 +1,64 @@
from __future__ import annotations
from datetime import datetime
import json
from rich.json import JSON
from rich.text import Text
from textual.app import ComposeResult
from textual.containers import ScrollableContainer
from textual.widget import Widget
from textual.widgets import Label, Static
class LineDisplay(Widget):
DEFAULT_CSS = """
LineDisplay {
padding: 0 1;
margin: 1 0;
width: auto;
height: auto;
Label {
width: 1fr;
}
.json {
width: auto;
}
}
"""
def __init__(self, line: str, text: Text, timestamp: datetime | None) -> None:
self.line = line
self.text = text
self.timestamp = timestamp
super().__init__()
def compose(self) -> ComposeResult:
try:
json_data = json.loads(self.line)
except Exception:
pass
else:
yield Static(JSON.from_data(json_data), expand=True, classes="json")
return
yield Label(self.text)
class LinePanel(ScrollableContainer):
DEFAULT_CSS = """
LinePanel {
background: $panel;
overflow-y: auto;
border: blank transparent;
scrollbar-gutter: stable;
&:focus {
border: heavy $accent;
}
}
"""
async def update(self, line: str, text: Text, timestamp: datetime | None) -> None:
with self.app.batch_update():
await self.query(LineDisplay).remove()
await self.mount(LineDisplay(line, text, timestamp))

240
src/toolong/log_file.py Normal file
View file

@ -0,0 +1,240 @@
from __future__ import annotations
from datetime import datetime
import os
import mmap
import mimetypes
import platform
import time
from pathlib import Path
from typing import IO, Iterable
from threading import Event, Lock
import rich.repr
from toolong.format_parser import FormatParser, ParseResult
from toolong.timestamps import TimestampScanner
IS_WINDOWS = platform.system() == "Windows"
class LogError(Exception):
"""An error related to logs."""
@rich.repr.auto(angular=True)
class LogFile:
"""A single log file."""
def __init__(self, path: str) -> None:
self.path = Path(path)
self.name = self.path.name
self.file: IO[bytes] | None = None
self.size = 0
self.can_tail = False
self.timestamp_scanner = TimestampScanner()
self.format_parser = FormatParser()
self._lock = Lock()
def __rich_repr__(self) -> rich.repr.Result:
yield self.name
yield "size", self.size
@property
def is_open(self) -> bool:
return self.file is not None
@property
def fileno(self) -> int:
assert self.file is not None
return self.file.fileno()
@property
def is_compressed(self) -> bool:
_, encoding = mimetypes.guess_type(self.path.name, strict=False)
return encoding in ("gzip", "bzip2")
def parse(self, line: str) -> ParseResult:
"""Parse a line."""
return self.format_parser.parse(line)
def get_create_time(self) -> datetime | None:
try:
stat_result = self.path.stat()
except Exception:
return None
try:
# This works on Mac
create_time_seconds = stat_result.st_birthtime
except AttributeError:
# No birthtime for Linux, so we assume the epoch start
return datetime.fromtimestamp(0)
timestamp = datetime.fromtimestamp(create_time_seconds)
return timestamp
def open(self, exit_event: Event) -> bool:
# Check for compressed files
_, encoding = mimetypes.guess_type(self.path.name, strict=False)
# Open compressed files
if encoding in ("gzip", "bzip2"):
return self.open_compressed(exit_event, encoding)
# Open uncompressed file
self.file = open(self.path, "rb", buffering=0)
self.file.seek(0, os.SEEK_END)
self.size = self.file.tell()
self.can_tail = True
return True
def open_compressed(self, exit_event: Event, encoding: str) -> bool:
from tempfile import TemporaryFile
chunk_size = 1024 * 256
temp_file = TemporaryFile("wb+")
compressed_file: IO[bytes]
if encoding == "gzip":
import gzip
compressed_file = gzip.open(self.path, "rb")
elif encoding == "bzip2":
import bz2
compressed_file = bz2.open(self.path, "rb")
else:
# Shouldn't get here
raise AssertionError("Not supported")
try:
while data := compressed_file.read(chunk_size):
temp_file.write(data)
if exit_event.is_set():
temp_file.close()
return False
finally:
compressed_file.close()
temp_file.flush()
self.file = temp_file
self.size = temp_file.tell()
self.can_tail = False
return True
def close(self) -> None:
if self.file is not None:
self.file.close()
self.file = None
if IS_WINDOWS:
def get_raw(self, start: int, end: int) -> bytes:
with self._lock:
if start >= end or self.file is None:
return b""
position = os.lseek(self.fileno, 0, os.SEEK_CUR)
try:
os.lseek(self.fileno, start, os.SEEK_SET)
return os.read(self.fileno, end - start)
finally:
os.lseek(self.fileno, position, os.SEEK_SET)
else:
def get_raw(self, start: int, end: int) -> bytes:
if start >= end or self.file is None:
return b""
return os.pread(self.fileno, end - start, start)
def get_line(self, start: int, end: int) -> str:
return (
self.get_raw(start, end)
.decode("utf-8", errors="replace")
.strip("\n\r")
.expandtabs(4)
)
def scan_line_breaks(
self, batch_time: float = 0.25
) -> Iterable[tuple[int, list[int]]]:
"""Scan the file for line breaks.
Args:
batch_time: Time to group the batches.
Returns:
An iterable of tuples, containing the scan position and a list of offsets of new lines.
"""
fileno = self.fileno
size = self.size
if not size:
return
if IS_WINDOWS:
log_mmap = mmap.mmap(fileno, size, access=mmap.ACCESS_READ)
else:
log_mmap = mmap.mmap(fileno, size, prot=mmap.PROT_READ)
try:
rfind = log_mmap.rfind
position = size
batch: list[int] = []
append = batch.append
get_length = batch.__len__
monotonic = time.monotonic
break_time = monotonic()
if log_mmap[-1] != "\n":
batch.append(position)
while (position := rfind(b"\n", 0, position)) != -1:
append(position)
if get_length() % 1000 == 0 and monotonic() - break_time > batch_time:
break_time = monotonic()
yield (position, batch)
batch = []
append = batch.append
yield (0, batch)
finally:
log_mmap.close()
def scan_timestamps(
self, batch_time: float = 0.25
) -> Iterable[list[tuple[int, int, float]]]:
size = self.size
if not size:
return
fileno = self.fileno
if IS_WINDOWS:
log_mmap = mmap.mmap(fileno, size, access=mmap.ACCESS_READ)
else:
log_mmap = mmap.mmap(fileno, size, prot=mmap.PROT_READ)
monotonic = time.monotonic
scan_time = monotonic()
scan = self.timestamp_scanner.scan
line_no = 0
position = 0
results: list[tuple[int, int, float]] = []
append = results.append
get_length = results.__len__
while line_bytes := log_mmap.readline():
line = line_bytes.decode("utf-8", errors="replace")
timestamp = scan(line)
position += len(line_bytes)
append((line_no, position, timestamp.timestamp() if timestamp else 0.0))
line_no += 1
if (
results
and get_length() % 1000 == 0
and monotonic() - scan_time > batch_time
):
scan_time = monotonic()
yield results
results = []
append = results.append
if results:
yield results

996
src/toolong/log_lines.py Normal file
View file

@ -0,0 +1,996 @@
from __future__ import annotations
from dataclasses import dataclass
from queue import Empty, Queue
from operator import itemgetter
import platform
from threading import Event, RLock, Thread
from textual.message import Message
from textual.suggester import Suggester
from toolong.scan_progress_bar import ScanProgressBar
from toolong.find_dialog import FindDialog
from toolong.log_file import LogFile
from toolong.messages import (
DismissOverlay,
FileError,
NewBreaks,
PendingLines,
PointerMoved,
ScanComplete,
ScanProgress,
TailFile,
)
from toolong.watcher import WatcherBase
from rich.segment import Segment
from rich.style import Style
from rich.text import Text
from textual import events, on, scrollbar, work
from textual.app import ComposeResult
from textual.binding import Binding
from textual.cache import LRUCache
from textual.geometry import Region, Size, clamp
from textual.reactive import reactive
from textual.scroll_view import ScrollView
from textual.strip import Strip
from textual.worker import Worker, get_current_worker
import mmap
import re
import time
from datetime import datetime, timedelta
from typing import Iterable, Literal, Mapping
SPLIT_REGEX = r"[\s/\[\]\(\)\"\/]"
MAX_LINE_LENGTH = 1000
@dataclass
class LineRead(Message):
"""A line has been read from the file."""
index: int
log_file: LogFile
start: int
end: int
line: str
class LineReader(Thread):
"""A thread which read lines from log files.
This allows lines to be loaded lazily, i.e. without blocking.
"""
def __init__(self, log_lines: LogLines) -> None:
self.log_lines = log_lines
self.queue: Queue[tuple[LogFile | None, int, int, int]] = Queue(maxsize=1000)
self.exit_event = Event()
self.pending: set[tuple[LogFile | None, int, int, int]] = set()
super().__init__()
def request_line(self, log_file: LogFile, index: int, start: int, end: int) -> None:
request = (log_file, index, start, end)
if request not in self.pending:
self.pending.add(request)
self.queue.put(request)
def stop(self) -> None:
"""Stop the thread and join."""
self.exit_event.set()
self.queue.put((None, -1, 0, 0))
self.join()
def run(self) -> None:
log_lines = self.log_lines
while not self.exit_event.is_set():
try:
request = self.queue.get(timeout=0.2)
except Empty:
continue
else:
self.pending.discard(request)
log_file, index, start, end = request
self.queue.task_done()
if self.exit_event.is_set() or log_file is None:
break
log_lines.post_message(
LineRead(
index,
log_file,
start,
end,
log_file.get_line(start, end),
)
)
class SearchSuggester(Suggester):
def __init__(self, search_index: Mapping[str, str]) -> None:
self.search_index = search_index
super().__init__(use_cache=False, case_sensitive=True)
async def get_suggestion(self, value: str) -> str | None:
word = re.split(SPLIT_REGEX, value)[-1]
start = value[: -len(word)]
if not word:
return None
search_hit = self.search_index.get(word.lower(), None)
if search_hit is None:
return None
return start + search_hit
class LogLines(ScrollView, inherit_bindings=False):
BINDINGS = [
Binding("up,k", "scroll_up", "Scroll Up", show=False),
Binding("down,j", "scroll_down", "Scroll Down", show=False),
Binding("left", "scroll_left", "Scroll Up", show=False),
Binding("right", "scroll_right", "Scroll Right", show=False),
Binding("home", "scroll_home", "Scroll Home", show=False),
Binding("end", "scroll_end", "Scroll End", show=False),
Binding("pageup", "page_up", "Page Up", show=False),
Binding("pagedown", "page_down", "Page Down", show=False),
Binding("enter", "select", "Select line", show=False),
Binding("escape", "dismiss", "Dismiss", show=False, priority=True),
Binding("m", "navigate(+1, 'm')"),
Binding("M", "navigate(-1, 'm')"),
Binding("h", "navigate(+1, 'h')"),
Binding("H", "navigate(-1, 'h')"),
Binding("d", "navigate(+1, 'd')"),
Binding("D", "navigate(-1, 'd')"),
]
DEFAULT_CSS = """
LogLines {
scrollbar-gutter: stable;
overflow: scroll;
border: heavy transparent;
.loglines--filter-highlight {
background: $secondary;
color: auto;
}
.loglines--pointer-highlight {
background: $primary;
}
&:focus {
border: heavy $accent;
}
border-subtitle-color: $success;
border-subtitle-align: center;
align: center middle;
&.-scanning {
tint: $background 30%;
}
.loglines--line-numbers {
color: $warning 70%;
}
.loglines--line-numbers-active {
color: $warning;
text-style: bold;
}
}
"""
COMPONENT_CLASSES = {
"loglines--filter-highlight",
"loglines--pointer-highlight",
"loglines--line-numbers",
"loglines--line-numbers-active",
}
show_find = reactive(False)
find = reactive("")
case_sensitive = reactive(False)
regex = reactive(False)
show_gutter = reactive(False)
pointer_line: reactive[int | None] = reactive(None, repaint=False)
is_scrolling: reactive[int] = reactive(int)
pending_lines: reactive[int] = reactive(int)
tail: reactive[bool] = reactive(True)
can_tail: reactive[bool] = reactive(True)
show_line_numbers: reactive[bool] = reactive(False)
def __init__(self, watcher: WatcherBase, file_paths: list[str]) -> None:
super().__init__()
self.watcher = watcher
self.file_paths = file_paths
self.log_files = [LogFile(path) for path in file_paths]
self._render_line_cache: LRUCache[
tuple[LogFile, int, int, bool, str], Strip
] = LRUCache(maxsize=1000)
self._max_width = 0
self._search_index: LRUCache[str, str] = LRUCache(maxsize=10000)
self._suggester = SearchSuggester(self._search_index)
self.icons: dict[int, str] = {}
self._line_breaks: dict[LogFile, list[int]] = {}
self._line_cache: LRUCache[tuple[LogFile, int, int], str] = LRUCache(10000)
self._text_cache: LRUCache[
tuple[LogFile, int, int, bool], tuple[str, Text, datetime | None]
] = LRUCache(1000)
self.initial_scan_worker: Worker | None = None
self._line_count = 0
self._scanned_size = 0
self._scan_start = 0
self._gutter_width = 0
self._line_reader = LineReader(self)
self._merge_lines: list[tuple[float, int, LogFile]] | None = None
self._lock = RLock()
@property
def log_file(self) -> LogFile:
return self.log_files[0]
@property
def line_count(self) -> int:
with self._lock:
if self._merge_lines is not None:
return len(self._merge_lines)
return self._line_count
@property
def gutter_width(self) -> int:
return self._gutter_width
@property
def focusable(self) -> bool:
"""Can this widget currently be focused?"""
return self.can_focus and self.visible and not self._self_or_ancestors_disabled
def compose(self) -> ComposeResult:
yield ScanProgressBar()
def clear_caches(self) -> None:
self._line_cache.clear()
self._text_cache.clear()
def notify_style_update(self) -> None:
self.clear_caches()
def validate_pointer_line(self, pointer_line: int | None) -> int | None:
if pointer_line is None:
return None
if pointer_line < 0:
return 0
if pointer_line >= self.line_count:
return self.line_count - 1
return pointer_line
def on_mount(self) -> None:
self.loading = True
self.add_class("-scanning")
self._line_reader.start()
self.initial_scan_worker = self.run_scan(self.app.save_merge)
def start_tail(self) -> None:
def size_changed(size: int, breaks: list[int]) -> None:
"""Callback when the file changes size."""
with self._lock:
for offset, _ in enumerate(breaks, 1):
self.get_line_from_index(self.line_count - offset)
self.post_message(NewBreaks(self.log_file, breaks, size, tail=True))
if self.message_queue_size > 10:
while self.message_queue_size > 2:
time.sleep(0.1)
def watch_error(error: Exception) -> None:
"""Callback when there is an error watching the file."""
self.post_message(FileError(error))
self.watcher.add(
self.log_file,
size_changed,
watch_error,
)
@work(thread=True)
def run_scan(self, save_merge: str | None = None) -> None:
worker = get_current_worker()
if len(self.log_files) > 1:
self.merge_log_files()
if save_merge is not None:
self.call_later(self.save, save_merge, self.line_count)
return
try:
if not self.log_file.open(worker.cancelled_event):
self.loading = False
return
except FileNotFoundError:
self.notify(
f"File {self.log_file.path.name!r} not found.", severity="error"
)
self.loading = False
return
except Exception as error:
self.notify(
f"Failed to open {self.log_file.path.name!r}; {error}", severity="error"
)
self.loading = False
return
size = self.log_file.size
if not size:
self.post_message(ScanComplete(0, 0))
return
position = size
line_count = 0
for position, breaks in self.log_file.scan_line_breaks():
line_count_thousands = line_count // 1000
message = f"Scanning… ({line_count_thousands:,}K lines)- ESCAPE to cancel"
self.post_message(ScanProgress(message, 1 - (position / size), position))
if breaks:
self.post_message(NewBreaks(self.log_file, breaks))
line_count += len(breaks)
if worker.is_cancelled:
break
self.post_message(ScanComplete(size, position))
def merge_log_files(self) -> None:
worker = get_current_worker()
self._merge_lines = []
merge_lines = self._merge_lines
for log_file in self.log_files:
try:
log_file.open(worker.cancelled_event)
except Exception as error:
self.notify(
f"Failed to open {log_file.name!r}; {error}", severity="error"
)
else:
self._line_breaks[log_file] = []
self.loading = False
total_size = sum(log_file.size for log_file in self.log_files)
position = 0
for log_file in self.log_files:
if not log_file.is_open:
continue
line_breaks = self._line_breaks[log_file]
append = line_breaks.append
meta: list[tuple[float, int, LogFile]] = []
append_meta = meta.append
for timestamps in log_file.scan_timestamps():
break_position = 0
for line_no, break_position, timestamp in timestamps:
append_meta((timestamp, line_no, log_file))
append(break_position)
append(log_file.size)
self.post_message(
ScanProgress(
f"Merging {log_file.name} - ESCAPE to cancel",
(position + break_position) / total_size,
)
)
if worker.is_cancelled:
self.post_message(
ScanComplete(total_size, position + break_position)
)
return
# Header may be missing timestamp, so we will attempt to back fill timestamps
seconds = 0.0
for offset, (seconds, line_no, log_file) in enumerate(meta):
if seconds:
for index, (_seconds, line_no, log_file) in zip(
range(offset), meta
):
meta[index] = (seconds, line_no, log_file)
break
if offset > 10:
# May be pointless to scan the entire thing
break
self._merge_lines.extend(meta)
position += log_file.size
merge_lines.sort(key=itemgetter(0, 1))
self.post_message(ScanComplete(total_size, total_size))
@classmethod
def _scan_file(
cls, fileno: int, size: int, batch_time: float = 0.25
) -> Iterable[tuple[int, list[int]]]:
"""Find line breaks in a file.
Yields lists of offsets.
"""
if platform.system() == "Windows":
log_mmap = mmap.mmap(fileno, size, access=mmap.ACCESS_READ)
else:
log_mmap = mmap.mmap(fileno, size, prot=mmap.PROT_READ)
rfind = log_mmap.rfind
position = size
batch: list[int] = []
append = batch.append
get_length = batch.__len__
monotonic = time.monotonic
break_time = monotonic()
while (position := rfind(b"\n", 0, position)) != -1:
append(position)
if get_length() % 1000 == 0 and monotonic() - break_time > batch_time:
yield (position, batch)
batch = []
yield (0, batch)
@work(thread=True)
def save(self, path: str, line_count: int) -> None:
"""Save visible lines (used to export merged lines).
Args:
path: Path to save to.
line_count: Number of lines to save.
"""
try:
with open(path, "w") as file_out:
for line_no in range(line_count):
line = self.get_line_from_index_blocking(line_no)
if line:
file_out.write(f"{line}\n")
except Exception as error:
self.notify(f"Failed to save {path!r}; {error}", severity="error")
else:
self.notify(f"Saved merged log files to {path!r}")
def get_log_file_from_index(self, index: int) -> tuple[LogFile, int]:
if self._merge_lines is not None:
try:
_, index, log_file = self._merge_lines[index]
except IndexError:
return self.log_files[0], index
return log_file, index
return self.log_files[0], index
def index_to_span(self, index: int) -> tuple[LogFile, int, int]:
log_file, index = self.get_log_file_from_index(index)
line_breaks = self._line_breaks.setdefault(log_file, [])
scan_start = 0 if self._merge_lines else self._scan_start
if not line_breaks:
return (log_file, scan_start, self._scan_start)
index = clamp(index, 0, len(line_breaks))
if index == 0:
return (log_file, scan_start, line_breaks[0])
start = line_breaks[index - 1]
end = (
line_breaks[index]
if index < len(line_breaks)
else max(0, self._scanned_size - 1)
)
return (log_file, start, end)
def get_line_from_index_blocking(self, index: int) -> str | None:
with self._lock:
log_file, start, end = self.index_to_span(index)
return log_file.get_line(start, end)
def get_line_from_index(self, index: int) -> str | None:
with self._lock:
log_file, start, end = self.index_to_span(index)
return self.get_line(log_file, index, start, end)
def _get_line(self, log_file: LogFile, start: int, end: int) -> str:
return log_file.get_line(start, end)
def get_line(
self, log_file: LogFile, index: int, start: int, end: int
) -> str | None:
cache_key = (log_file, start, end)
with self._lock:
try:
line = self._line_cache[cache_key]
except KeyError:
self._line_reader.request_line(log_file, index, start, end)
return None
return line
def get_line_blocking(
self, log_file: LogFile, index: int, start: int, end: int
) -> str:
with self._lock:
cache_key = (log_file, start, end)
try:
line = self._line_cache[cache_key]
except KeyError:
line = self._get_line(log_file, start, end)
self._line_cache[cache_key] = line
return line
def get_text(
self,
line_index: int,
abbreviate: bool = False,
block: bool = False,
max_line_length=MAX_LINE_LENGTH,
) -> tuple[str, Text, datetime | None]:
log_file, start, end = self.index_to_span(line_index)
cache_key = (log_file, start, end, abbreviate)
try:
line, text, timestamp = self._text_cache[cache_key]
except KeyError:
new_line: str | None
if block:
new_line = self.get_line_blocking(log_file, line_index, start, end)
else:
new_line = self.get_line(log_file, line_index, start, end)
if new_line is None:
return "", Text(""), None
line = new_line
timestamp, line, text = log_file.parse(line)
if abbreviate and len(text) > max_line_length:
text = text[:max_line_length] + ""
self._text_cache[cache_key] = (line, text, timestamp)
return line, text.copy(), timestamp
def get_timestamp(self, line_index: int) -> datetime | None:
"""Get a timestamp for the given line, or `None` if no timestamp detected.
Args:
line_index: Index of line.
Returns:
A datetime or `None`.
"""
log_file, start, end = self.index_to_span(line_index)
line = log_file.get_line(start, end)
timestamp = log_file.timestamp_scanner.scan(line)
return timestamp
def on_unmount(self) -> None:
self._line_reader.stop()
self.log_file.close()
def on_idle(self) -> None:
self.update_virtual_size()
def update_virtual_size(self) -> None:
self.virtual_size = Size(
self._max_width
+ (self.gutter_width if self.show_gutter or self.show_line_numbers else 0),
self.line_count,
)
def render_lines(self, crop: Region) -> list[Strip]:
self.update_virtual_size()
page_height = self.scrollable_content_region.height
scroll_y = self.scroll_offset.y
line_count = self.line_count
index_to_span = self.index_to_span
for index in range(
max(0, scroll_y - page_height),
min(line_count, scroll_y + page_height + page_height),
):
log_file_span = index_to_span(index)
if log_file_span not in self._line_cache:
log_file, *span = log_file_span
self._line_reader.request_line(log_file, index, *span)
if self.show_line_numbers:
max_line_no = self.scroll_offset.y + page_height
self._gutter_width = len(f"{max_line_no+1} ")
else:
self._gutter_width = 0
if self.pointer_line is not None:
self._gutter_width += 3
return super().render_lines(crop)
def render_line(self, y: int) -> Strip:
scroll_x, scroll_y = self.scroll_offset
index = y + scroll_y
style = self.rich_style
width, height = self.size
if index >= self.line_count:
return Strip.blank(width, style)
log_file_span = self.index_to_span(index)
is_pointer = self.pointer_line is not None and index == self.pointer_line
cache_key = (*log_file_span, is_pointer, self.find)
try:
strip = self._render_line_cache[cache_key]
except KeyError:
line, text, timestamp = self.get_text(index, abbreviate=True, block=True)
text.stylize_before(style)
if is_pointer:
pointer_style = self.get_component_rich_style(
"loglines--pointer-highlight"
)
text.stylize(Style(bgcolor=pointer_style.bgcolor, bold=True))
search_index = self._search_index
for word in re.split(SPLIT_REGEX, text.plain):
if len(word) <= 1:
continue
for offset in range(1, len(word) - 1):
sub_word = word[:offset]
if sub_word in search_index:
if len(search_index[sub_word]) < len(word):
search_index[sub_word.lower()] = word
else:
search_index[sub_word.lower()] = word
if self.find and self.show_find:
self.highlight_find(text)
strip = Strip(text.render(self.app.console), text.cell_len)
self._max_width = max(self._max_width, strip.cell_length)
self._render_line_cache[cache_key] = strip
if is_pointer:
pointer_style = self.get_component_rich_style("loglines--pointer-highlight")
strip = strip.crop_extend(scroll_x, scroll_x + width, pointer_style)
else:
strip = strip.crop_extend(scroll_x, scroll_x + width, None)
if self.show_gutter or self.show_line_numbers:
line_number_style = self.get_component_rich_style(
"loglines--line-numbers-active"
if index == self.pointer_line
else "loglines--line-numbers"
)
if self.pointer_line is not None and index == self.pointer_line:
icon = "👉"
else:
icon = self.icons.get(index, " ")
if self.show_line_numbers:
segments = [Segment(f"{index+1} ", line_number_style), Segment(icon)]
else:
segments = [Segment(icon)]
icon_strip = Strip(segments)
icon_strip = icon_strip.adjust_cell_length(self._gutter_width)
strip = Strip.join([icon_strip, strip])
return strip
def highlight_find(self, text: Text) -> None:
filter_style = self.get_component_rich_style("loglines--filter-highlight")
if self.regex:
try:
re.compile(self.find)
except Exception:
# Invalid regex
return
matches = list(
re.finditer(
self.find,
text.plain,
flags=0 if self.case_sensitive else re.IGNORECASE,
)
)
if matches:
for match in matches:
text.stylize(filter_style, *match.span())
else:
text.stylize("dim")
else:
if not text.highlight_words(
[self.find], filter_style, case_sensitive=self.case_sensitive
):
text.stylize("dim")
def check_match(self, line: str) -> bool:
if not line:
return True
if self.regex:
try:
return (
re.match(
self.find,
line,
flags=0 if self.case_sensitive else re.IGNORECASE,
)
is not None
)
except Exception:
self.notify("Regex is invalid!", severity="error")
return True
else:
if self.case_sensitive:
return self.find in line
else:
return self.find.lower() in line.lower()
def advance_search(self, direction: int = 1) -> None:
first = self.pointer_line is None
start_line = (
(
self.scroll_offset.y
if direction == 1
else self.scroll_offset.y + self.scrollable_content_region.height - 1
)
if self.pointer_line is None
else self.pointer_line + direction
)
if direction == 1:
line_range = range(start_line, self.line_count)
else:
line_range = range(start_line, -1, -1)
scroll_y = self.scroll_offset.y
max_scroll_y = scroll_y + self.scrollable_content_region.height - 1
if self.show_find:
check_match = self.check_match
index_to_span = self.index_to_span
with self._lock:
for line_no in line_range:
log_file, start, end = index_to_span(line_no)
line = log_file.get_raw(start, end).decode(
"utf-8", errors="replace"
)
if check_match(line):
self.pointer_line = line_no
self.scroll_pointer_to_center()
return
self.app.bell()
else:
self.pointer_line = next(
iter(line_range), self.pointer_line or self.scroll_offset.y
)
if first:
self.refresh()
else:
if self.pointer_line is not None and (
self.pointer_line < scroll_y or self.pointer_line > max_scroll_y
):
self.scroll_pointer_to_center()
def scroll_pointer_to_center(self, animate: bool = True):
if self.pointer_line is None:
return
y_offset = self.pointer_line - self.scrollable_content_region.height // 2
scroll_distance = abs(y_offset - self.scroll_offset.y)
self.scroll_to(
y=y_offset,
animate=animate and 100 > scroll_distance > 1,
duration=0.2,
)
def watch_show_find(self, show_find: bool) -> None:
self.clear_caches()
if not show_find:
self.pointer_line = None
def watch_find(self, find: str) -> None:
if not find:
self.pointer_line = None
def watch_case_sensitive(self) -> None:
self.clear_caches()
def watch_regex(self) -> None:
self.clear_caches()
def watch_pointer_line(
self, old_pointer_line: int | None, pointer_line: int | None
) -> None:
if old_pointer_line is not None:
self.refresh_line(old_pointer_line)
if pointer_line is not None:
self.refresh_line(pointer_line)
self.show_gutter = pointer_line is not None
self.post_message(PointerMoved(pointer_line))
def action_scroll_up(self) -> None:
if self.pointer_line is None:
super().action_scroll_up()
else:
self.advance_search(-1)
self.post_message(TailFile(False))
def action_scroll_down(self) -> None:
if self.pointer_line is None:
super().action_scroll_down()
else:
self.advance_search(+1)
def action_scroll_home(self) -> None:
if self.pointer_line is not None:
self.pointer_line = 0
self.scroll_to(y=0, duration=0)
self.post_message(TailFile(False))
def action_scroll_end(self) -> None:
if self.pointer_line is not None:
self.pointer_line = self.line_count
if self.scroll_offset.y == self.max_scroll_y:
self.post_message(TailFile(True))
else:
self.scroll_to(y=self.max_scroll_y, duration=0)
self.post_message(TailFile(False))
def action_page_down(self) -> None:
if self.pointer_line is None:
super().action_page_down()
else:
self.pointer_line = (
self.pointer_line + self.scrollable_content_region.height
)
self.scroll_pointer_to_center()
self.post_message(TailFile(False))
def action_page_up(self) -> None:
if self.pointer_line is None:
super().action_page_up()
else:
self.pointer_line = (
self.pointer_line - self.scrollable_content_region.height
)
self.scroll_pointer_to_center()
self.post_message(TailFile(False))
def on_click(self, event: events.Click) -> None:
if self.loading:
return
new_pointer_line = event.y + self.scroll_offset.y - self.gutter.top
if new_pointer_line == self.pointer_line:
self.post_message(FindDialog.SelectLine())
self.pointer_line = new_pointer_line
self.post_message(TailFile(False))
def action_select(self):
if self.pointer_line is None:
self.pointer_line = self.scroll_offset.y
else:
self.post_message(FindDialog.SelectLine())
def action_dismiss(self):
if self.initial_scan_worker is not None and self.initial_scan_worker.is_running:
self.initial_scan_worker.cancel()
self.notify(
"Stopped scanning. Some lines may not be available.", severity="warning"
)
else:
self.post_message(DismissOverlay())
# @work(thread=True)
def action_navigate(self, steps: int, unit: Literal["m", "h", "d"]) -> None:
initial_line_no = line_no = (
self.scroll_offset.y if self.pointer_line is None else self.pointer_line
)
count = 0
# If the current line doesn't have a timestamp, try to find the next one
while (timestamp := self.get_timestamp(line_no)) is None:
line_no += 1
count += 1
if count >= self.line_count or count > 10:
self.app.bell()
return
direction = +1 if steps > 0 else -1
line_no += direction
if unit == "m":
target_timestamp = timestamp + timedelta(minutes=steps)
elif unit == "h":
target_timestamp = timestamp + timedelta(hours=steps)
elif unit == "d":
target_timestamp = timestamp + timedelta(hours=steps * 24)
if direction == +1:
line_count = self.line_count
while line_no < line_count:
timestamp = self.get_timestamp(line_no)
if timestamp is not None and timestamp >= target_timestamp:
break
line_no += 1
else:
while line_no > 0:
timestamp = self.get_timestamp(line_no)
if timestamp is not None and timestamp <= target_timestamp:
break
line_no -= 1
self.pointer_line = line_no
self.scroll_pointer_to_center(animate=abs(initial_line_no - line_no) < 100)
def watch_tail(self, tail: bool) -> None:
self.set_class(tail, "-tail")
if tail:
self.update_line_count()
self.scroll_to(y=self.max_scroll_y, animate=False)
if tail:
self.pointer_line = None
def update_line_count(self) -> None:
line_count = len(self._line_breaks.get(self.log_file, []))
line_count = max(1, line_count)
self._line_count = line_count
@on(NewBreaks)
def on_new_breaks(self, event: NewBreaks) -> None:
line_breaks = self._line_breaks.setdefault(event.log_file, [])
first = not line_breaks
event.stop()
self._scanned_size = max(self._scanned_size, event.scanned_size)
if not self.tail and event.tail:
self.post_message(PendingLines(len(line_breaks) - self._line_count + 1))
line_breaks.extend(event.breaks)
if not event.tail:
line_breaks.sort()
pointer_distance_from_end = (
None
if self.pointer_line is None
else self.virtual_size.height - self.pointer_line
)
self.loading = False
if not event.tail or self.tail or first:
self.update_line_count()
if self.tail:
if self.pointer_line is not None and pointer_distance_from_end is not None:
self.pointer_line = self.virtual_size.height - pointer_distance_from_end
self.update_virtual_size()
self.scroll_to(y=self.max_scroll_y, animate=False, force=True)
def watch_scroll_y(self, old_value: float, new_value: float) -> None:
self.post_message(PointerMoved(self.pointer_line))
super().watch_scroll_y(old_value, new_value)
@on(scrollbar.ScrollTo)
def on_scroll_to(self, event: scrollbar.ScrollTo) -> None:
# Stop tail when scrolling in the Y direction only
if event.y:
self.post_message(TailFile(False))
@on(scrollbar.ScrollUp)
@on(scrollbar.ScrollDown)
@on(events.MouseScrollDown)
@on(events.MouseScrollUp)
def on_scroll(self, event: events.Event) -> None:
self.post_message(TailFile(False))
@on(ScanComplete)
def on_scan_complete(self, event: ScanComplete) -> None:
self._scanned_size = max(self._scanned_size, event.size)
self._scan_start = event.scan_start
self.update_line_count()
self.refresh()
if len(self.log_files) == 1 and self.can_tail:
self.start_tail()
@on(ScanProgress)
def on_scan_progress(self, event: ScanProgress):
if event.scan_start is not None:
self._scan_start = event.scan_start
@on(LineRead)
def on_line_read(self, event: LineRead) -> None:
event.stop()
start = event.start
end = event.end
log_file = event.log_file
self._render_line_cache.discard((log_file, start, end, True, self.find))
self._render_line_cache.discard((log_file, start, end, False, self.find))
self._line_cache[(log_file, start, end)] = event.line
self._text_cache.discard((log_file, start, end, False))
self._text_cache.discard((log_file, start, end, True))
self.refresh_lines(event.index, 1)

449
src/toolong/log_view.py Normal file
View file

@ -0,0 +1,449 @@
from __future__ import annotations
from asyncio import Lock
from datetime import datetime
from rich.text import Text
from textual import on
from textual.app import ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal
from textual.dom import NoScreen
from textual import events
from textual.reactive import reactive
from textual.widget import Widget
from textual.widgets import Label
from toolong.scan_progress_bar import ScanProgressBar
from toolong.messages import (
DismissOverlay,
Goto,
PendingLines,
PointerMoved,
ScanComplete,
ScanProgress,
TailFile,
)
from toolong.find_dialog import FindDialog
from toolong.line_panel import LinePanel
from toolong.watcher import WatcherBase
from toolong.log_lines import LogLines
SPLIT_REGEX = r"[\s/\[\]]"
MAX_DETAIL_LINE_LENGTH = 100_000
class InfoOverlay(Widget):
"""Displays text under the lines widget when there are new lines."""
DEFAULT_CSS = """
InfoOverlay {
display: none;
dock: bottom;
layer: overlay;
width: 1fr;
visibility: hidden;
offset-y: -1;
text-style: bold;
}
InfoOverlay Horizontal {
width: 1fr;
align: center bottom;
}
InfoOverlay Label {
visibility: visible;
width: auto;
height: 1;
background: $panel;
color: $success;
padding: 0 1;
&:hover {
background: $success;
color: auto 90%;
text-style: bold;
}
}
"""
message = reactive("")
tail = reactive(False)
def compose(self) -> ComposeResult:
self.tooltip = "Click to tail file"
with Horizontal():
yield Label("")
def watch_message(self, message: str) -> None:
self.display = bool(message.strip())
self.query_one(Label).update(message)
def watch_tail(self, tail: bool) -> None:
if not tail:
self.message = ""
self.display = bool(self.message.strip() and not tail)
def on_click(self) -> None:
self.post_message(TailFile())
class FooterKey(Label):
"""Displays a clickable label for a key."""
DEFAULT_CSS = """
FooterKey {
color: $success;
padding: 0 1 0 0;
&:hover {
text-style: bold underline;
}
}
"""
DEFAULT_CLASSES = "key"
def __init__(self, key: str, key_display: str, description: str) -> None:
self.key = key
self.key_display = key_display
self.description = description
super().__init__()
def render(self) -> str:
return f"[reverse]{self.key_display}[/reverse] {self.description}"
async def on_click(self) -> None:
await self.app.check_bindings(self.key)
class MetaLabel(Label):
DEFAULT_CSS = """
MetaLabel {
margin-left: 1;
}
MetaLabel:hover {
text-style: underline;
}
"""
def on_click(self) -> None:
self.post_message(Goto())
class LogFooter(Widget):
"""Shows a footer with information about the file and keys."""
DEFAULT_CSS = """
LogFooter {
layout: horizontal;
height: 1;
width: 1fr;
dock: bottom;
Horizontal {
width: 1fr;
height: 1;
}
.key {
color: $warning;
}
.meta {
width: auto;
height: 1;
color: $success;
padding: 0 1 0 0;
}
.tail {
padding: 0 1;
margin: 0 1;
background: $success 15%;
color: $success;
text-style: bold;
display: none;
&.on {
display: block;
}
}
}
"""
line_no: reactive[int | None] = reactive(None)
filename: reactive[str] = reactive("")
timestamp: reactive[datetime | None] = reactive(None)
tail: reactive[bool] = reactive(False)
can_tail: reactive[bool] = reactive(False)
def __init__(self) -> None:
self.lock = Lock()
super().__init__()
def compose(self) -> ComposeResult:
with Horizontal(classes="key-container"):
pass
yield Label("TAIL", classes="tail")
yield MetaLabel("", classes="meta")
async def mount_keys(self) -> None:
try:
if self.screen != self.app.screen:
return
except NoScreen:
pass
async with self.lock:
with self.app.batch_update():
key_container = self.query_one(".key-container")
await key_container.query("*").remove()
bindings = [
binding
for (_, binding) in self.app.namespace_bindings.values()
if binding.show
]
await key_container.mount_all(
[
FooterKey(
binding.key,
binding.key_display or binding.key,
binding.description,
)
for binding in bindings
if binding.action != "toggle_tail"
or (binding.action == "toggle_tail" and self.can_tail)
]
)
async def on_mount(self):
self.watch(self.screen, "focused", self.mount_keys)
self.watch(self.screen, "stack_updates", self.mount_keys)
self.call_after_refresh(self.mount_keys)
def update_meta(self) -> None:
meta: list[str] = []
if self.filename:
meta.append(self.filename)
if self.timestamp is not None:
meta.append(f"{self.timestamp:%x %X}")
if self.line_no is not None:
meta.append(f"{self.line_no + 1}")
meta_line = "".join(meta)
self.query_one(".meta", Label).update(meta_line)
def watch_tail(self, tail: bool) -> None:
self.query(".tail").set_class(tail and self.can_tail, "on")
async def watch_can_tail(self, can_tail: bool) -> None:
await self.mount_keys()
def watch_filename(self, filename: str) -> None:
self.update_meta()
def watch_line_no(self, line_no: int | None) -> None:
self.update_meta()
def watch_timestamp(self, timestamp: datetime | None) -> None:
self.update_meta()
class LogView(Horizontal):
"""Widget that contains log lines and associated widgets."""
DEFAULT_CSS = """
LogView {
&.show-panel {
LinePanel {
display: block;
}
}
LogLines {
width: 1fr;
}
LinePanel {
width: 50%;
display: none;
}
}
"""
BINDINGS = [
Binding("ctrl+t", "toggle_tail", "Tail", key_display="^t"),
Binding("ctrl+l", "toggle('show_line_numbers')", "Line nos.", key_display="^l"),
Binding("ctrl+f", "show_find_dialog", "Find", key_display="^f"),
Binding("slash", "show_find_dialog", "Find", key_display="^f", show=False),
Binding("ctrl+g", "goto", "Go to", key_display="^g"),
]
show_find: reactive[bool] = reactive(False)
show_panel: reactive[bool] = reactive(False)
show_line_numbers: reactive[bool] = reactive(False)
tail: reactive[bool] = reactive(False)
can_tail: reactive[bool] = reactive(True)
def __init__(
self, file_paths: list[str], watcher: WatcherBase, can_tail: bool = True
) -> None:
self.file_paths = file_paths
self.watcher = watcher
super().__init__()
# Need a better solution for this
self.call_later(setattr, self, "can_tail", can_tail)
def compose(self) -> ComposeResult:
yield (
log_lines := LogLines(self.watcher, self.file_paths).data_bind(
LogView.tail,
LogView.show_line_numbers,
LogView.show_find,
LogView.can_tail,
)
)
yield LinePanel()
yield FindDialog(log_lines._suggester)
yield InfoOverlay().data_bind(LogView.tail)
yield LogFooter().data_bind(LogView.tail, LogView.can_tail)
@on(FindDialog.Update)
def filter_dialog_update(self, event: FindDialog.Update) -> None:
log_lines = self.query_one(LogLines)
log_lines.find = event.find
log_lines.regex = event.regex
log_lines.case_sensitive = event.case_sensitive
async def watch_show_find(self, show_find: bool) -> None:
if not self.is_mounted:
return
filter_dialog = self.query_one(FindDialog)
filter_dialog.set_class(show_find, "visible")
if show_find:
filter_dialog.focus_input()
else:
self.query_one(LogLines).focus()
async def watch_show_panel(self, show_panel: bool) -> None:
self.set_class(show_panel, "show-panel")
await self.update_panel()
@on(FindDialog.Dismiss)
def dismiss_filter_dialog(self, event: FindDialog.Dismiss) -> None:
event.stop()
self.show_find = False
@on(FindDialog.MovePointer)
def move_pointer(self, event: FindDialog.MovePointer) -> None:
event.stop()
log_lines = self.query_one(LogLines)
log_lines.advance_search(event.direction)
@on(FindDialog.SelectLine)
def select_line(self) -> None:
self.show_panel = not self.show_panel
@on(DismissOverlay)
def dismiss_overlay(self) -> None:
if self.show_find:
self.show_find = False
elif self.show_panel:
self.show_panel = False
else:
self.query_one(LogLines).pointer_line = None
@on(TailFile)
def on_tail_file(self, event: TailFile) -> None:
self.tail = event.tail
event.stop()
async def update_panel(self) -> None:
if not self.show_panel:
return
pointer_line = self.query_one(LogLines).pointer_line
if pointer_line is not None:
line, text, timestamp = self.query_one(LogLines).get_text(
pointer_line,
block=True,
abbreviate=True,
max_line_length=MAX_DETAIL_LINE_LENGTH,
)
await self.query_one(LinePanel).update(line, text, timestamp)
@on(PointerMoved)
async def pointer_moved(self, event: PointerMoved):
if event.pointer_line is None:
self.show_panel = False
if self.show_panel:
await self.update_panel()
log_lines = self.query_one(LogLines)
pointer_line = (
log_lines.scroll_offset.y
if event.pointer_line is None
else event.pointer_line
)
log_file, _, _ = log_lines.index_to_span(pointer_line)
log_footer = self.query_one(LogFooter)
log_footer.line_no = pointer_line
if len(log_lines.log_files) > 1:
log_footer.filename = log_file.name
timestamp = log_lines.get_timestamp(pointer_line)
log_footer.timestamp = timestamp
@on(PendingLines)
def on_pending_lines(self, event: PendingLines) -> None:
if self.app._exit:
return
event.stop()
self.query_one(InfoOverlay).message = f"+{event.count:,} lines"
@on(ScanProgress)
def on_scan_progress(self, event: ScanProgress):
event.stop()
scan_progress_bar = self.query_one(ScanProgressBar)
scan_progress_bar.message = event.message
scan_progress_bar.complete = event.complete
@on(ScanComplete)
async def on_scan_complete(self, event: ScanComplete) -> None:
self.query_one(ScanProgressBar).remove()
log_lines = self.query_one(LogLines)
log_lines.loading = False
self.query_one("LogLines").remove_class("-scanning")
self.post_message(PointerMoved(log_lines.pointer_line))
self.tail = True
footer = self.query_one(LogFooter)
footer.call_after_refresh(footer.mount_keys)
@on(events.DescendantFocus)
@on(events.DescendantBlur)
def on_descendant_focus(self, event: events.DescendantBlur) -> None:
self.set_class(isinstance(self.screen.focused, LogLines), "lines-view")
def action_toggle_tail(self) -> None:
if not self.can_tail:
self.notify("Can't tail merged files", title="Tail", severity="error")
else:
self.tail = not self.tail
def action_show_find_dialog(self) -> None:
find_dialog = self.query_one(FindDialog)
if not self.show_find or not any(
input.has_focus for input in find_dialog.query("Input")
):
self.show_find = True
find_dialog.focus_input()
@on(Goto)
def on_goto(self) -> None:
self.action_goto()
def action_goto(self) -> None:
from toolong.goto_screen import GotoScreen
self.app.push_screen(GotoScreen(self.query_one(LogLines)))

92
src/toolong/messages.py Normal file
View file

@ -0,0 +1,92 @@
from __future__ import annotations
from dataclasses import dataclass
import rich.repr
from textual.message import Message
from toolong.log_file import LogFile
@dataclass
class Goto(Message):
pass
@dataclass
class SizeChanged(Message, bubble=False):
"""File size has changed."""
size: int
def can_replace(self, message: Message) -> bool:
return isinstance(message, SizeChanged)
@dataclass
class FileError(Message, bubble=False):
"""An error occurred watching a file."""
error: Exception
@dataclass
class PendingLines(Message):
"""Pending lines detected."""
count: int
def can_replace(self, message: Message) -> bool:
return isinstance(message, PendingLines)
@rich.repr.auto
@dataclass
class NewBreaks(Message):
"""New line break to add."""
log_file: LogFile
breaks: list[int]
scanned_size: int = 0
tail: bool = False
def __rich_repr__(self) -> rich.repr.Result:
yield "scanned_size", self.scanned_size
yield "tail", self.tail
class DismissOverlay(Message):
"""Request to dismiss overlay."""
@dataclass
class TailFile(Message):
"""Set file tailing."""
tail: bool = True
@dataclass
class ScanProgress(Message):
"""Update scan progress bar."""
message: str
complete: float
scan_start: int | None = None
@dataclass
class ScanComplete(Message):
"""Scan has completed."""
size: int
scan_start: int
@dataclass
class PointerMoved(Message):
"""Pointer has moved."""
pointer_line: int | None
def can_replace(self, message: Message) -> bool:
return isinstance(message, PointerMoved)

View file

@ -0,0 +1,33 @@
from __future__ import annotations
from os import lseek, read, SEEK_CUR
import time
from toolong.watcher import WatcherBase
class PollWatcher(WatcherBase):
"""A watcher that simply polls."""
def run(self) -> None:
chunk_size = 64 * 1024
scan_chunk = self.scan_chunk
while not self._exit_event.is_set():
successful_read = False
for fileno, watched_file in self._file_descriptors.items():
try:
position = lseek(fileno, 0, SEEK_CUR)
if chunk := read(fileno, chunk_size):
successful_read = True
breaks = scan_chunk(chunk, position)
watched_file.callback(position + len(chunk), breaks)
position += len(chunk)
except Exception as error:
watched_file.error_callback(error)
self._file_descriptors.pop(fileno, None)
break
else:
if not successful_read:
time.sleep(0.05)

View file

@ -0,0 +1,41 @@
from textual.app import ComposeResult
from textual.containers import Center, Vertical
from textual.reactive import reactive
from textual.widgets import Label, ProgressBar
class ScanProgressBar(Vertical):
SCOPED_CSS = False
DEFAULT_CSS = """
ScanProgressBar {
width: 100%;
height: auto;
margin: 2 4;
dock: top;
padding: 1 2;
background: $primary;
display: block;
text-align: center;
display: none;
align: center top;
}
LogLines:focus ScanProgressBar.-has-content {
display: block;
}
"""
message = reactive("")
complete = reactive(0.0)
def watch_message(self, message: str) -> None:
self.query_one(".message", Label).update(message)
self.set_class(bool(message), "-has-content")
def compose(self) -> ComposeResult:
with Center():
yield Label(classes="message")
with Center():
yield ProgressBar(
total=1.0, show_eta=False, show_percentage=False
).data_bind(progress=ScanProgressBar.complete)

View file

@ -0,0 +1,60 @@
from selectors import DefaultSelector, EVENT_READ
from typing import Callable
import os
from toolong.log_file import LogFile
from toolong.watcher import WatcherBase, WatchedFile
class SelectorWatcher(WatcherBase):
"""Watches files for changes."""
def __init__(self) -> None:
self._selector = DefaultSelector()
super().__init__()
def close(self) -> None:
if not self._exit_event.is_set():
self._exit_event.set()
def add(
self,
log_file: LogFile,
callback: Callable[[int, list[int]], None],
error_callback: Callable[[Exception], None],
) -> None:
"""Add a file to the watcher."""
super().add(log_file, callback, error_callback)
fileno = log_file.fileno
size = log_file.size
os.lseek(fileno, size, os.SEEK_SET)
self._selector.register(fileno, EVENT_READ)
def run(self) -> None:
"""Thread runner."""
chunk_size = 64 * 1024
scan_chunk = self.scan_chunk
while not self._exit_event.is_set():
for key, mask in self._selector.select(timeout=0.1):
if self._exit_event.is_set():
break
if mask & EVENT_READ:
fileno = key.fileobj
assert isinstance(fileno, int)
watched_file = self._file_descriptors.get(fileno, None)
if watched_file is None:
continue
try:
position = os.lseek(fileno, 0, os.SEEK_CUR)
chunk = os.read(fileno, chunk_size)
if chunk:
breaks = scan_chunk(chunk, position)
watched_file.callback(position + len(chunk), breaks)
except Exception as error:
watched_file.error_callback(error)
self._file_descriptors.pop(fileno, None)
self._selector.unregister(fileno)

165
src/toolong/timestamps.py Normal file
View file

@ -0,0 +1,165 @@
from __future__ import annotations
from datetime import datetime
import re
from typing import Callable, NamedTuple
class TimestampFormat(NamedTuple):
regex: str
parser: Callable[[str], datetime | None]
def parse_timestamp(format: str) -> Callable[[str], datetime | None]:
def parse(timestamp: str) -> datetime | None:
try:
return datetime.strptime(timestamp, format)
except ValueError:
return None
return parse
# Info taken from logmerger project https://github.com/ptmcg/logmerger/blob/main/logmerger/timestamp_wrapper.py
TIMESTAMP_FORMATS = [
TimestampFormat(
r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3}\s?(?:Z|[+-]\d{4})",
datetime.fromisoformat,
),
TimestampFormat(
r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3}",
datetime.fromisoformat,
),
TimestampFormat(
r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}\s?(?:Z|[+-]\d{4})",
datetime.fromisoformat,
),
TimestampFormat(
r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}",
datetime.fromisoformat,
),
TimestampFormat(
r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\s?(?:Z|[+-]\d{4})",
datetime.fromisoformat,
),
TimestampFormat(
r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}",
datetime.fromisoformat,
),
TimestampFormat(
r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2},\d{3}\s?(?:Z|[+-]\d{4})",
datetime.fromisoformat,
),
TimestampFormat(
r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2},\d{3}",
datetime.fromisoformat,
),
TimestampFormat(
r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}\s?(?:Z|[+-]\d{4}Z?)",
datetime.fromisoformat,
),
TimestampFormat(
r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}",
datetime.fromisoformat,
),
TimestampFormat(
r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\s?(?:Z|[+-]\d{4})",
datetime.fromisoformat,
),
TimestampFormat(
r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}",
datetime.fromisoformat,
),
TimestampFormat(
r"[JFMASOND][a-z]{2}\s(\s|\d)\d \d{2}:\d{2}:\d{2}",
parse_timestamp("%b %d %H:%M:%S"),
),
TimestampFormat(
r"\d{2}\/\w+\/\d{4} \d{2}:\d{2}:\d{2}",
parse_timestamp(
"%d/%b/%Y %H:%M:%S",
),
),
TimestampFormat(
r"\d{2}\/\w+\/\d{4}:\d{2}:\d{2}:\d{2} [+-]\d{4}",
parse_timestamp("%d/%b/%Y:%H:%M:%S %z"),
),
TimestampFormat(
r"\d{10}\.\d+",
lambda s: datetime.fromtimestamp(float(s)),
),
TimestampFormat(
r"\d{13}",
lambda s: datetime.fromtimestamp(int(s)),
),
]
def parse(line: str) -> tuple[TimestampFormat | None, datetime | None]:
"""Attempt to parse a timestamp."""
for timestamp in TIMESTAMP_FORMATS:
regex, parse_callable = timestamp
match = re.search(regex, line)
if match is not None:
try:
return timestamp, parse_callable(match.string)
except ValueError:
continue
return None, None
class TimestampScanner:
"""Scan a line for something that looks like a timestamp."""
def __init__(self) -> None:
self._timestamp_formats = TIMESTAMP_FORMATS.copy()
def scan(self, line: str) -> datetime | None:
"""Scan a line.
Args:
line: A log line with a timestamp.
Returns:
A datetime or `None` if no timestamp was found.
"""
if len(line) > 10_000:
line = line[:10000]
for index, timestamp_format in enumerate(self._timestamp_formats):
regex, parse_callable = timestamp_format
if (match := re.search(regex, line)) is not None:
try:
if (timestamp := parse_callable(match.group(0))) is None:
continue
except Exception:
continue
if index:
# Put matched format at the top so that
# the next line will be matched quicker
del self._timestamp_formats[index : index + 1]
self._timestamp_formats.insert(0, timestamp_format)
return timestamp
return None
if __name__ == "__main__":
# print(parse_timestamp("%Y-%m-%d %H:%M:%S%z")("2024-01-08 13:31:48+00"))
print(parse("29/Jan/2024:13:48:00 +0000"))
scanner = TimestampScanner()
LINES = """\
121.137.55.45 - - [29/Jan/2024:13:45:19 +0000] "GET /blog/rootblog/feeds/posts/ HTTP/1.1" 200 107059 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
216.244.66.233 - - [29/Jan/2024:13:45:22 +0000] "GET /robots.txt HTTP/1.1" 200 132 "-" "Mozilla/5.0 (compatible; DotBot/1.2; +https://opensiteexplorer.org/dotbot; help@moz.com)"
78.82.5.250 - - [29/Jan/2024:13:45:29 +0000] "GET /blog/tech/post/real-working-hyperlinks-in-the-terminal-with-rich/ HTTP/1.1" 200 6982 "https://www.google.com/" "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36"
78.82.5.250 - - [29/Jan/2024:13:45:30 +0000] "GET /favicon.ico HTTP/1.1" 200 5694 "https://www.willmcgugan.com/blog/tech/post/real-working-hyperlinks-in-the-terminal-with-rich/" "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36"
46.244.252.112 - - [29/Jan/2024:13:46:44 +0000] "GET /blog/tech/feeds/posts/ HTTP/1.1" 200 118238 "https://www.willmcgugan.com/blog/tech/feeds/posts/" "FreshRSS/1.23.1 (Linux; https://freshrss.org)"
92.247.181.15 - - [29/Jan/2024:13:47:33 +0000] "GET /feeds/posts/ HTTP/1.1" 200 107059 "https://www.willmcgugan.com/" "Inoreader/1.0 (+http://www.inoreader.com/feed-fetcher; 26 subscribers; )"
188.27.184.30 - - [29/Jan/2024:13:47:56 +0000] "GET /feeds/posts/ HTTP/1.1" 200 107059 "-" "Mozilla/5.0 (X11; Linux x86_64; rv:115.0) Gecko/20100101 Thunderbird/115.6.1"
198.58.103.36 - - [29/Jan/2024:13:48:00 +0000] "GET /blog/tech/feeds/tag/django/ HTTP/1.1" 200 110812 "http://www.willmcgugan.com/blog/tech/feeds/tag/django/" "Superfeedr bot/2.0 http://superfeedr.com - Make your feeds realtime: get in touch - feed-id:46271263"
3.37.46.91 - - [29/Jan/2024:13:48:19 +0000] "GET /blog/rootblog/feeds/posts/ HTTP/1.1" 200 107059 "-" "node
""".splitlines()
for line in LINES:
print(scanner.scan(line))

126
src/toolong/ui.py Normal file
View file

@ -0,0 +1,126 @@
from __future__ import annotations
import locale
from pathlib import Path
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.lazy import Lazy
from textual.screen import Screen
from textual.widgets import TabbedContent, TabPane
from toolong.log_view import LogView
from toolong.watcher import get_watcher
from toolong.help import HelpScreen
locale.setlocale(locale.LC_ALL, "")
class LogScreen(Screen):
BINDINGS = [
Binding("f1", "help", "Help"),
]
CSS = """
LogScreen {
layers: overlay;
& TabPane {
padding: 0;
}
& Tabs:focus Underline > .underline--bar {
color: $accent;
}
Underline > .underline--bar {
color: $panel;
}
}
"""
def compose(self) -> ComposeResult:
assert isinstance(self.app, UI)
with TabbedContent():
if self.app.merge and len(self.app.file_paths) > 1:
tab_name = " + ".join(Path(path).name for path in self.app.file_paths)
with TabPane(tab_name):
yield Lazy(
LogView(
self.app.file_paths,
self.app.watcher,
can_tail=False,
)
)
else:
for path in self.app.file_paths:
with TabPane(path):
yield Lazy(
LogView(
[path],
self.app.watcher,
can_tail=True,
)
)
def on_mount(self) -> None:
assert isinstance(self.app, UI)
self.query("TabbedContent Tabs").set(display=len(self.query(TabPane)) > 1)
active_pane = self.query_one(TabbedContent).active_pane
if active_pane is not None:
active_pane.query("LogView > LogLines").focus()
def action_help(self) -> None:
self.app.push_screen(HelpScreen())
from functools import total_ordering
@total_ordering
class CompareTokens:
"""Compare filenames."""
def __init__(self, path: str) -> None:
self.tokens = [
int(token) if token.isdigit() else token.lower()
for token in path.split("/")[-1].split(".")
]
def __eq__(self, other: object) -> bool:
return self.tokens == other.tokens
def __lt__(self, other: CompareTokens) -> bool:
for token1, token2 in zip(self.tokens, other.tokens):
try:
if token1 < token2:
return True
except TypeError:
if str(token1) < str(token2):
return True
return len(self.tokens) < len(other.tokens)
class UI(App):
"""The top level App object."""
@classmethod
def sort_paths(cls, paths: list[str]) -> list[str]:
return sorted(paths, key=CompareTokens)
def __init__(
self, file_paths: list[str], merge: bool = False, save_merge: str | None = None
) -> None:
self.file_paths = self.sort_paths(file_paths)
self.merge = merge
self.save_merge = save_merge
self.watcher = get_watcher()
super().__init__()
async def on_mount(self) -> None:
await self.push_screen(LogScreen())
self.screen.query("LogLines").focus()
self.watcher.start()
def on_unmount(self) -> None:
self.watcher.close()

89
src/toolong/watcher.py Normal file
View file

@ -0,0 +1,89 @@
from __future__ import annotations
import rich.repr
from abc import ABC, abstractmethod
from dataclasses import dataclass
import platform
from threading import Event, Lock, Thread
from typing import Callable, TYPE_CHECKING
def get_watcher() -> WatcherBase:
"""Return an Watcher appropriate for the OS."""
if platform.system() == "Darwin":
from toolong.selector_watcher import SelectorWatcher
return SelectorWatcher()
else:
from toolong.poll_watcher import PollWatcher
return PollWatcher()
if TYPE_CHECKING:
from .log_file import LogFile
@dataclass
@rich.repr.auto
class WatchedFile:
"""A currently watched file."""
log_file: LogFile
callback: Callable[[int, list[int]], None]
error_callback: Callable[[Exception], None]
class WatcherBase(ABC):
"""Watches files for changes."""
def __init__(self) -> None:
self._file_descriptors: dict[int, WatchedFile] = {}
self._thread: Thread | None = None
self._exit_event = Event()
super().__init__()
@classmethod
def scan_chunk(cls, chunk: bytes, position: int) -> list[int]:
"""Scan line breaks in a binary chunk,
Args:
chunk: A binary chunk.
position: Offset within the file
Returns:
A list of indices with new lines.
"""
breaks: list[int] = []
offset = 0
append = breaks.append
while (offset := chunk.find(b"\n", offset)) != -1:
append(position + offset)
offset += 1
return breaks
def close(self) -> None:
if not self._exit_event.is_set():
self._exit_event.set()
self._thread = None
def start(self) -> None:
assert self._thread is None
self._thread = Thread(target=self.run, name=repr(self))
self._thread.start()
def add(
self,
log_file: LogFile,
callback: Callable[[int, list[int]], None],
error_callback: Callable[[Exception], None],
) -> None:
"""Add a file to the watcher."""
fileno = log_file.fileno
self._file_descriptors[fileno] = WatchedFile(log_file, callback, error_callback)
@abstractmethod
def run(self) -> None:
"""Thread runner."""