Adding upstream version 1.4.0.
Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
parent
cef31edaff
commit
1933d2d9f9
24 changed files with 4114 additions and 0 deletions
160
.gitignore
vendored
Normal file
160
.gitignore
vendored
Normal 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
21
LICENSE
Normal 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
192
README.md
Normal 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>
|
||||
|
||||
|
||||
[](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>
|
||||
|
||||
|
||||
|
||||
<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>
|
||||
|
||||
|
||||
<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>
|
||||
|
||||
|
||||
<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>
|
||||
|
||||
|
||||
<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
707
poetry.lock
generated
Normal 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
28
pyproject.toml
Normal 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
0
src/toolong/__init__.py
Normal file
4
src/toolong/__main__.py
Normal file
4
src/toolong/__main__.py
Normal file
|
@ -0,0 +1,4 @@
|
|||
from toolong.cli import run
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
76
src/toolong/cli.py
Normal file
76
src/toolong/cli.py
Normal 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
158
src/toolong/find_dialog.py
Normal 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))
|
123
src/toolong/format_parser.py
Normal file
123
src/toolong/format_parser.py
Normal 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()
|
65
src/toolong/goto_screen.py
Normal file
65
src/toolong/goto_screen.py
Normal 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
180
src/toolong/help.py
Normal 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)
|
45
src/toolong/highlighter.py
Normal file
45
src/toolong/highlighter.py
Normal 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
64
src/toolong/line_panel.py
Normal 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
240
src/toolong/log_file.py
Normal 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
996
src/toolong/log_lines.py
Normal 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
449
src/toolong/log_view.py
Normal 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
92
src/toolong/messages.py
Normal 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)
|
33
src/toolong/poll_watcher.py
Normal file
33
src/toolong/poll_watcher.py
Normal 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)
|
41
src/toolong/scan_progress_bar.py
Normal file
41
src/toolong/scan_progress_bar.py
Normal 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)
|
60
src/toolong/selector_watcher.py
Normal file
60
src/toolong/selector_watcher.py
Normal 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
165
src/toolong/timestamps.py
Normal 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
126
src/toolong/ui.py
Normal 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
89
src/toolong/watcher.py
Normal 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."""
|
Loading…
Add table
Add a link
Reference in a new issue