├── .gitignore ├── LICENSE ├── README.md ├── poetry.lock ├── pyproject.toml └── src └── toolong ├── __init__.py ├── __main__.py ├── cli.py ├── find_dialog.py ├── format_parser.py ├── goto_screen.py ├── help.py ├── highlighter.py ├── line_panel.py ├── log_file.py ├── log_lines.py ├── log_view.py ├── messages.py ├── poll_watcher.py ├── scan_progress_bar.py ├── selector_watcher.py ├── timestamps.py ├── ui.py └── watcher.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Will McGugan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |

3 | A Kookaburra sitting on a scroll 4 |

5 | 6 | 7 | [![Discord](https://img.shields.io/discord/1026214085173461072)](https://discord.gg/Enf6Z3qhVr) 8 | 9 | # Toolong 10 | 11 | A terminal application to view, tail, merge, and search log files (plus JSONL). 12 | 13 |
14 | 🎬 Viewing a single file 15 | 16 |   17 | 18 |
19 |
21 | 22 |
23 | 24 | ## Keep calm and log files 25 | 26 | See [Toolong on Calmcode.io](https://calmcode.io/shorts/toolong.py) for a calming introduction to Toolong. 27 | 28 | ## What? 29 | 30 | Screenshot 2024-02-08 at 13 47 28 31 | 32 | 33 | - Live tailing of log files. 34 | - Syntax highlights common web server log formats. 35 | - As fast to open a multiple-gigabyte file as it is to open a tiny text file. 36 | - Support for JSONL files: lines are pretty printed. 37 | - Opens .bz and .bz2 files automatically. 38 | - Merges log files by auto detecting timestamps. 39 | 40 | 41 | ## Why? 42 | 43 | I spent a lot of time in my past life as a web developer working with logs, typically on web servers via ssh. 44 | 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. 45 | As useful as these tools are, they are not without friction. 46 | 47 | I built `toolong` to be the tool I would have wanted back then. 48 | It is snappy, straightforward to use, and does a lot of the *grunt work* for you. 49 | 50 | 51 | ### Screenshots 52 | 53 | 54 | 55 | 58 | 61 | 62 | 63 | 66 | 69 | 70 |
56 | Screenshot 2024-02-08 at 13 47 28 57 | 59 | Screenshot 2024-02-08 at 13 48 04 60 |
64 | Screenshot 2024-02-08 at 13 49 22 65 | 67 | Screenshot 2024-02-08 at 13 50 04 68 |
71 | 72 | 73 | ### Videos 74 | 75 |
76 | 🎬 Merging multiple (compressed) files 77 |   78 | 79 |
80 |
82 | 83 | 84 |
85 | 86 |
87 | 🎬 Viewing JSONL files 88 |   89 | 90 |
91 |
93 | 94 | 95 | 96 |
97 | 98 |
99 | 🎬 Live Tailing a file 100 |   101 | 102 |
103 |
105 | 106 | 107 |
108 | 109 | ## How? 110 | 111 | Toolong is currently best installed with [pipx](https://github.com/pypa/pipx). 112 | 113 | ```bash 114 | pipx install toolong 115 | ``` 116 | 117 | You could also install Toolong with Pip: 118 | 119 | ```bash 120 | pip install toolong 121 | ``` 122 | 123 | > [!NOTE] 124 | > If you use pip, you should ideally create a virtual environment to avoid potential dependancy conflicts. 125 | 126 | However you install Toolong, the `tl` command will be added to your path: 127 | 128 | ```bash 129 | tl 130 | ``` 131 | 132 | In the near future there will be more install methods, and hopefully your favorite package manager. 133 | 134 | ### Compatibility 135 | 136 | Toolong works on Linux, macOS, and Windows. 137 | 138 | ### Opening files 139 | 140 | To open a file with Toolong, add the file name(s) as arguments to the command: 141 | 142 | ```bash 143 | tl mylogfile.log 144 | ``` 145 | 146 | If you add multiple filenames, they will open in tabs. 147 | 148 | Add the `--merge` switch to open multiple files and combine them in to a single view: 149 | 150 | ```bash 151 | tl access.log* --merge 152 | ``` 153 | 154 | In the app, press **f1** for additional help. 155 | 156 | ### Piping 157 | 158 | In addition to specifying files, you can also pipe directly into `tl`. 159 | This means that you can tail data that comes from another process, and not neccesarily a file. 160 | Here's an example of piping output from the `tree` command in to Toolong: 161 | 162 | ```bash 163 | tree / | tl 164 | ``` 165 | 166 | ## Who? 167 | 168 | This [guy](https://github.com/willmcgugan). An ex web developer who somehow makes a living writing terminal apps. 169 | 170 | 171 | --- 172 | 173 | ## History 174 | 175 | 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. 176 | I settled on the name "Toolong" because it is a bit more apt, and still had the same initials. 177 | 178 | ## Development 179 | 180 | Toolong v1.0.0 has a solid feature set, which covers most of my requirements. 181 | 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. 182 | 183 | If you want to talk about Toolong, find me on the [Textualize Discord Server](https://discord.gg/Enf6Z3qhVr). 184 | 185 | 186 | ## Thanks 187 | 188 | I am grateful for the [LogMerger](https://github.com/ptmcg/logmerger) project which I referenced (and borrowed regexes from) when building Toolong. 189 | 190 | ## Alternatives 191 | 192 | Toolong is not the first TUI for working with log files. See [lnav](https://lnav.org/) as a more mature alternative. 193 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "aiohttp" 5 | version = "3.9.5" 6 | description = "Async http client/server framework (asyncio)" 7 | optional = false 8 | python-versions = ">=3.8" 9 | files = [ 10 | {file = "aiohttp-3.9.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fcde4c397f673fdec23e6b05ebf8d4751314fa7c24f93334bf1f1364c1c69ac7"}, 11 | {file = "aiohttp-3.9.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5d6b3f1fabe465e819aed2c421a6743d8debbde79b6a8600739300630a01bf2c"}, 12 | {file = "aiohttp-3.9.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6ae79c1bc12c34082d92bf9422764f799aee4746fd7a392db46b7fd357d4a17a"}, 13 | {file = "aiohttp-3.9.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4d3ebb9e1316ec74277d19c5f482f98cc65a73ccd5430540d6d11682cd857430"}, 14 | {file = "aiohttp-3.9.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84dabd95154f43a2ea80deffec9cb44d2e301e38a0c9d331cc4aa0166fe28ae3"}, 15 | {file = "aiohttp-3.9.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c8a02fbeca6f63cb1f0475c799679057fc9268b77075ab7cf3f1c600e81dd46b"}, 16 | {file = "aiohttp-3.9.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c26959ca7b75ff768e2776d8055bf9582a6267e24556bb7f7bd29e677932be72"}, 17 | {file = "aiohttp-3.9.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:714d4e5231fed4ba2762ed489b4aec07b2b9953cf4ee31e9871caac895a839c0"}, 18 | {file = "aiohttp-3.9.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e7a6a8354f1b62e15d48e04350f13e726fa08b62c3d7b8401c0a1314f02e3558"}, 19 | {file = "aiohttp-3.9.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:c413016880e03e69d166efb5a1a95d40f83d5a3a648d16486592c49ffb76d0db"}, 20 | {file = "aiohttp-3.9.5-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:ff84aeb864e0fac81f676be9f4685f0527b660f1efdc40dcede3c251ef1e867f"}, 21 | {file = "aiohttp-3.9.5-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:ad7f2919d7dac062f24d6f5fe95d401597fbb015a25771f85e692d043c9d7832"}, 22 | {file = "aiohttp-3.9.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:702e2c7c187c1a498a4e2b03155d52658fdd6fda882d3d7fbb891a5cf108bb10"}, 23 | {file = "aiohttp-3.9.5-cp310-cp310-win32.whl", hash = "sha256:67c3119f5ddc7261d47163ed86d760ddf0e625cd6246b4ed852e82159617b5fb"}, 24 | {file = "aiohttp-3.9.5-cp310-cp310-win_amd64.whl", hash = "sha256:471f0ef53ccedec9995287f02caf0c068732f026455f07db3f01a46e49d76bbb"}, 25 | {file = "aiohttp-3.9.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e0ae53e33ee7476dd3d1132f932eeb39bf6125083820049d06edcdca4381f342"}, 26 | {file = "aiohttp-3.9.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c088c4d70d21f8ca5c0b8b5403fe84a7bc8e024161febdd4ef04575ef35d474d"}, 27 | {file = "aiohttp-3.9.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:639d0042b7670222f33b0028de6b4e2fad6451462ce7df2af8aee37dcac55424"}, 28 | {file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f26383adb94da5e7fb388d441bf09c61e5e35f455a3217bfd790c6b6bc64b2ee"}, 29 | {file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66331d00fb28dc90aa606d9a54304af76b335ae204d1836f65797d6fe27f1ca2"}, 30 | {file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4ff550491f5492ab5ed3533e76b8567f4b37bd2995e780a1f46bca2024223233"}, 31 | {file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f22eb3a6c1080d862befa0a89c380b4dafce29dc6cd56083f630073d102eb595"}, 32 | {file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a81b1143d42b66ffc40a441379387076243ef7b51019204fd3ec36b9f69e77d6"}, 33 | {file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f64fd07515dad67f24b6ea4a66ae2876c01031de91c93075b8093f07c0a2d93d"}, 34 | {file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:93e22add827447d2e26d67c9ac0161756007f152fdc5210277d00a85f6c92323"}, 35 | {file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:55b39c8684a46e56ef8c8d24faf02de4a2b2ac60d26cee93bc595651ff545de9"}, 36 | {file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4715a9b778f4293b9f8ae7a0a7cef9829f02ff8d6277a39d7f40565c737d3771"}, 37 | {file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:afc52b8d969eff14e069a710057d15ab9ac17cd4b6753042c407dcea0e40bf75"}, 38 | {file = "aiohttp-3.9.5-cp311-cp311-win32.whl", hash = "sha256:b3df71da99c98534be076196791adca8819761f0bf6e08e07fd7da25127150d6"}, 39 | {file = "aiohttp-3.9.5-cp311-cp311-win_amd64.whl", hash = "sha256:88e311d98cc0bf45b62fc46c66753a83445f5ab20038bcc1b8a1cc05666f428a"}, 40 | {file = "aiohttp-3.9.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:c7a4b7a6cf5b6eb11e109a9755fd4fda7d57395f8c575e166d363b9fc3ec4678"}, 41 | {file = "aiohttp-3.9.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:0a158704edf0abcac8ac371fbb54044f3270bdbc93e254a82b6c82be1ef08f3c"}, 42 | {file = "aiohttp-3.9.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d153f652a687a8e95ad367a86a61e8d53d528b0530ef382ec5aaf533140ed00f"}, 43 | {file = "aiohttp-3.9.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82a6a97d9771cb48ae16979c3a3a9a18b600a8505b1115cfe354dfb2054468b4"}, 44 | {file = "aiohttp-3.9.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60cdbd56f4cad9f69c35eaac0fbbdf1f77b0ff9456cebd4902f3dd1cf096464c"}, 45 | {file = "aiohttp-3.9.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8676e8fd73141ded15ea586de0b7cda1542960a7b9ad89b2b06428e97125d4fa"}, 46 | {file = "aiohttp-3.9.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da00da442a0e31f1c69d26d224e1efd3a1ca5bcbf210978a2ca7426dfcae9f58"}, 47 | {file = "aiohttp-3.9.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:18f634d540dd099c262e9f887c8bbacc959847cfe5da7a0e2e1cf3f14dbf2daf"}, 48 | {file = "aiohttp-3.9.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:320e8618eda64e19d11bdb3bd04ccc0a816c17eaecb7e4945d01deee2a22f95f"}, 49 | {file = "aiohttp-3.9.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:2faa61a904b83142747fc6a6d7ad8fccff898c849123030f8e75d5d967fd4a81"}, 50 | {file = "aiohttp-3.9.5-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:8c64a6dc3fe5db7b1b4d2b5cb84c4f677768bdc340611eca673afb7cf416ef5a"}, 51 | {file = "aiohttp-3.9.5-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:393c7aba2b55559ef7ab791c94b44f7482a07bf7640d17b341b79081f5e5cd1a"}, 52 | {file = "aiohttp-3.9.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:c671dc117c2c21a1ca10c116cfcd6e3e44da7fcde37bf83b2be485ab377b25da"}, 53 | {file = "aiohttp-3.9.5-cp312-cp312-win32.whl", hash = "sha256:5a7ee16aab26e76add4afc45e8f8206c95d1d75540f1039b84a03c3b3800dd59"}, 54 | {file = "aiohttp-3.9.5-cp312-cp312-win_amd64.whl", hash = "sha256:5ca51eadbd67045396bc92a4345d1790b7301c14d1848feaac1d6a6c9289e888"}, 55 | {file = "aiohttp-3.9.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:694d828b5c41255e54bc2dddb51a9f5150b4eefa9886e38b52605a05d96566e8"}, 56 | {file = "aiohttp-3.9.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0605cc2c0088fcaae79f01c913a38611ad09ba68ff482402d3410bf59039bfb8"}, 57 | {file = "aiohttp-3.9.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4558e5012ee03d2638c681e156461d37b7a113fe13970d438d95d10173d25f78"}, 58 | {file = "aiohttp-3.9.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dbc053ac75ccc63dc3a3cc547b98c7258ec35a215a92bd9f983e0aac95d3d5b"}, 59 | {file = "aiohttp-3.9.5-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4109adee842b90671f1b689901b948f347325045c15f46b39797ae1bf17019de"}, 60 | {file = "aiohttp-3.9.5-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a6ea1a5b409a85477fd8e5ee6ad8f0e40bf2844c270955e09360418cfd09abac"}, 61 | {file = "aiohttp-3.9.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3c2890ca8c59ee683fd09adf32321a40fe1cf164e3387799efb2acebf090c11"}, 62 | {file = "aiohttp-3.9.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3916c8692dbd9d55c523374a3b8213e628424d19116ac4308e434dbf6d95bbdd"}, 63 | {file = "aiohttp-3.9.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:8d1964eb7617907c792ca00b341b5ec3e01ae8c280825deadbbd678447b127e1"}, 64 | {file = "aiohttp-3.9.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:d5ab8e1f6bee051a4bf6195e38a5c13e5e161cb7bad83d8854524798bd9fcd6e"}, 65 | {file = "aiohttp-3.9.5-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:52c27110f3862a1afbcb2af4281fc9fdc40327fa286c4625dfee247c3ba90156"}, 66 | {file = "aiohttp-3.9.5-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:7f64cbd44443e80094309875d4f9c71d0401e966d191c3d469cde4642bc2e031"}, 67 | {file = "aiohttp-3.9.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8b4f72fbb66279624bfe83fd5eb6aea0022dad8eec62b71e7bf63ee1caadeafe"}, 68 | {file = "aiohttp-3.9.5-cp38-cp38-win32.whl", hash = "sha256:6380c039ec52866c06d69b5c7aad5478b24ed11696f0e72f6b807cfb261453da"}, 69 | {file = "aiohttp-3.9.5-cp38-cp38-win_amd64.whl", hash = "sha256:da22dab31d7180f8c3ac7c7635f3bcd53808f374f6aa333fe0b0b9e14b01f91a"}, 70 | {file = "aiohttp-3.9.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:1732102949ff6087589408d76cd6dea656b93c896b011ecafff418c9661dc4ed"}, 71 | {file = "aiohttp-3.9.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c6021d296318cb6f9414b48e6a439a7f5d1f665464da507e8ff640848ee2a58a"}, 72 | {file = "aiohttp-3.9.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:239f975589a944eeb1bad26b8b140a59a3a320067fb3cd10b75c3092405a1372"}, 73 | {file = "aiohttp-3.9.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b7b30258348082826d274504fbc7c849959f1989d86c29bc355107accec6cfb"}, 74 | {file = "aiohttp-3.9.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd2adf5c87ff6d8b277814a28a535b59e20bfea40a101db6b3bdca7e9926bc24"}, 75 | {file = "aiohttp-3.9.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e9a3d838441bebcf5cf442700e3963f58b5c33f015341f9ea86dcd7d503c07e2"}, 76 | {file = "aiohttp-3.9.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e3a1ae66e3d0c17cf65c08968a5ee3180c5a95920ec2731f53343fac9bad106"}, 77 | {file = "aiohttp-3.9.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9c69e77370cce2d6df5d12b4e12bdcca60c47ba13d1cbbc8645dd005a20b738b"}, 78 | {file = "aiohttp-3.9.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0cbf56238f4bbf49dab8c2dc2e6b1b68502b1e88d335bea59b3f5b9f4c001475"}, 79 | {file = "aiohttp-3.9.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:d1469f228cd9ffddd396d9948b8c9cd8022b6d1bf1e40c6f25b0fb90b4f893ed"}, 80 | {file = "aiohttp-3.9.5-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:45731330e754f5811c314901cebdf19dd776a44b31927fa4b4dbecab9e457b0c"}, 81 | {file = "aiohttp-3.9.5-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:3fcb4046d2904378e3aeea1df51f697b0467f2aac55d232c87ba162709478c46"}, 82 | {file = "aiohttp-3.9.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8cf142aa6c1a751fcb364158fd710b8a9be874b81889c2bd13aa8893197455e2"}, 83 | {file = "aiohttp-3.9.5-cp39-cp39-win32.whl", hash = "sha256:7b179eea70833c8dee51ec42f3b4097bd6370892fa93f510f76762105568cf09"}, 84 | {file = "aiohttp-3.9.5-cp39-cp39-win_amd64.whl", hash = "sha256:38d80498e2e169bc61418ff36170e0aad0cd268da8b38a17c4cf29d254a8b3f1"}, 85 | {file = "aiohttp-3.9.5.tar.gz", hash = "sha256:edea7d15772ceeb29db4aff55e482d4bcfb6ae160ce144f2682de02f6d693551"}, 86 | ] 87 | 88 | [package.dependencies] 89 | aiosignal = ">=1.1.2" 90 | async-timeout = {version = ">=4.0,<5.0", markers = "python_version < \"3.11\""} 91 | attrs = ">=17.3.0" 92 | frozenlist = ">=1.1.1" 93 | multidict = ">=4.5,<7.0" 94 | yarl = ">=1.0,<2.0" 95 | 96 | [package.extras] 97 | speedups = ["Brotli", "aiodns", "brotlicffi"] 98 | 99 | [[package]] 100 | name = "aiosignal" 101 | version = "1.3.1" 102 | description = "aiosignal: a list of registered asynchronous callbacks" 103 | optional = false 104 | python-versions = ">=3.7" 105 | files = [ 106 | {file = "aiosignal-1.3.1-py3-none-any.whl", hash = "sha256:f8376fb07dd1e86a584e4fcdec80b36b7f81aac666ebc724e2c090300dd83b17"}, 107 | {file = "aiosignal-1.3.1.tar.gz", hash = "sha256:54cd96e15e1649b75d6c87526a6ff0b6c1b0dd3459f43d9ca11d48c339b68cfc"}, 108 | ] 109 | 110 | [package.dependencies] 111 | frozenlist = ">=1.1.0" 112 | 113 | [[package]] 114 | name = "async-timeout" 115 | version = "4.0.3" 116 | description = "Timeout context manager for asyncio programs" 117 | optional = false 118 | python-versions = ">=3.7" 119 | files = [ 120 | {file = "async-timeout-4.0.3.tar.gz", hash = "sha256:4640d96be84d82d02ed59ea2b7105a0f7b33abe8703703cd0ab0bf87c427522f"}, 121 | {file = "async_timeout-4.0.3-py3-none-any.whl", hash = "sha256:7405140ff1230c310e51dc27b3145b9092d659ce68ff733fb0cefe3ee42be028"}, 122 | ] 123 | 124 | [[package]] 125 | name = "attrs" 126 | version = "23.2.0" 127 | description = "Classes Without Boilerplate" 128 | optional = false 129 | python-versions = ">=3.7" 130 | files = [ 131 | {file = "attrs-23.2.0-py3-none-any.whl", hash = "sha256:99b87a485a5820b23b879f04c2305b44b951b502fd64be915879d77a7e8fc6f1"}, 132 | {file = "attrs-23.2.0.tar.gz", hash = "sha256:935dc3b529c262f6cf76e50877d35a4bd3c1de194fd41f47a2b7ae8f19971f30"}, 133 | ] 134 | 135 | [package.extras] 136 | cov = ["attrs[tests]", "coverage[toml] (>=5.3)"] 137 | dev = ["attrs[tests]", "pre-commit"] 138 | docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope-interface"] 139 | tests = ["attrs[tests-no-zope]", "zope-interface"] 140 | tests-mypy = ["mypy (>=1.6)", "pytest-mypy-plugins"] 141 | tests-no-zope = ["attrs[tests-mypy]", "cloudpickle", "hypothesis", "pympler", "pytest (>=4.3.0)", "pytest-xdist[psutil]"] 142 | 143 | [[package]] 144 | name = "click" 145 | version = "8.1.7" 146 | description = "Composable command line interface toolkit" 147 | optional = false 148 | python-versions = ">=3.7" 149 | files = [ 150 | {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, 151 | {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, 152 | ] 153 | 154 | [package.dependencies] 155 | colorama = {version = "*", markers = "platform_system == \"Windows\""} 156 | 157 | [[package]] 158 | name = "colorama" 159 | version = "0.4.6" 160 | description = "Cross-platform colored terminal text." 161 | optional = false 162 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 163 | files = [ 164 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 165 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 166 | ] 167 | 168 | [[package]] 169 | name = "frozenlist" 170 | version = "1.4.1" 171 | description = "A list-like structure which implements collections.abc.MutableSequence" 172 | optional = false 173 | python-versions = ">=3.8" 174 | files = [ 175 | {file = "frozenlist-1.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f9aa1878d1083b276b0196f2dfbe00c9b7e752475ed3b682025ff20c1c1f51ac"}, 176 | {file = "frozenlist-1.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:29acab3f66f0f24674b7dc4736477bcd4bc3ad4b896f5f45379a67bce8b96868"}, 177 | {file = "frozenlist-1.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:74fb4bee6880b529a0c6560885fce4dc95936920f9f20f53d99a213f7bf66776"}, 178 | {file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:590344787a90ae57d62511dd7c736ed56b428f04cd8c161fcc5e7232c130c69a"}, 179 | {file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:068b63f23b17df8569b7fdca5517edef76171cf3897eb68beb01341131fbd2ad"}, 180 | {file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c849d495bf5154cd8da18a9eb15db127d4dba2968d88831aff6f0331ea9bd4c"}, 181 | {file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9750cc7fe1ae3b1611bb8cfc3f9ec11d532244235d75901fb6b8e42ce9229dfe"}, 182 | {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"}, 183 | {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0633c8d5337cb5c77acbccc6357ac49a1770b8c487e5b3505c57b949b4b82e98"}, 184 | {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:27657df69e8801be6c3638054e202a135c7f299267f1a55ed3a598934f6c0d75"}, 185 | {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:f9a3ea26252bd92f570600098783d1371354d89d5f6b7dfd87359d669f2109b5"}, 186 | {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:4f57dab5fe3407b6c0c1cc907ac98e8a189f9e418f3b6e54d65a718aaafe3950"}, 187 | {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:e02a0e11cf6597299b9f3bbd3f93d79217cb90cfd1411aec33848b13f5c656cc"}, 188 | {file = "frozenlist-1.4.1-cp310-cp310-win32.whl", hash = "sha256:a828c57f00f729620a442881cc60e57cfcec6842ba38e1b19fd3e47ac0ff8dc1"}, 189 | {file = "frozenlist-1.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:f56e2333dda1fe0f909e7cc59f021eba0d2307bc6f012a1ccf2beca6ba362439"}, 190 | {file = "frozenlist-1.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a0cb6f11204443f27a1628b0e460f37fb30f624be6051d490fa7d7e26d4af3d0"}, 191 | {file = "frozenlist-1.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b46c8ae3a8f1f41a0d2ef350c0b6e65822d80772fe46b653ab6b6274f61d4a49"}, 192 | {file = "frozenlist-1.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fde5bd59ab5357e3853313127f4d3565fc7dad314a74d7b5d43c22c6a5ed2ced"}, 193 | {file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:722e1124aec435320ae01ee3ac7bec11a5d47f25d0ed6328f2273d287bc3abb0"}, 194 | {file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2471c201b70d58a0f0c1f91261542a03d9a5e088ed3dc6c160d614c01649c106"}, 195 | {file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c757a9dd70d72b076d6f68efdbb9bc943665ae954dad2801b874c8c69e185068"}, 196 | {file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f146e0911cb2f1da549fc58fc7bcd2b836a44b79ef871980d605ec392ff6b0d2"}, 197 | {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"}, 198 | {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:c302220494f5c1ebeb0912ea782bcd5e2f8308037b3c7553fad0e48ebad6ad82"}, 199 | {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:442acde1e068288a4ba7acfe05f5f343e19fac87bfc96d89eb886b0363e977ec"}, 200 | {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:1b280e6507ea8a4fa0c0a7150b4e526a8d113989e28eaaef946cc77ffd7efc0a"}, 201 | {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:fe1a06da377e3a1062ae5fe0926e12b84eceb8a50b350ddca72dc85015873f74"}, 202 | {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:db9e724bebd621d9beca794f2a4ff1d26eed5965b004a97f1f1685a173b869c2"}, 203 | {file = "frozenlist-1.4.1-cp311-cp311-win32.whl", hash = "sha256:e774d53b1a477a67838a904131c4b0eef6b3d8a651f8b138b04f748fccfefe17"}, 204 | {file = "frozenlist-1.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:fb3c2db03683b5767dedb5769b8a40ebb47d6f7f45b1b3e3b4b51ec8ad9d9825"}, 205 | {file = "frozenlist-1.4.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:1979bc0aeb89b33b588c51c54ab0161791149f2461ea7c7c946d95d5f93b56ae"}, 206 | {file = "frozenlist-1.4.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:cc7b01b3754ea68a62bd77ce6020afaffb44a590c2289089289363472d13aedb"}, 207 | {file = "frozenlist-1.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c9c92be9fd329ac801cc420e08452b70e7aeab94ea4233a4804f0915c14eba9b"}, 208 | {file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c3894db91f5a489fc8fa6a9991820f368f0b3cbdb9cd8849547ccfab3392d86"}, 209 | {file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ba60bb19387e13597fb059f32cd4d59445d7b18b69a745b8f8e5db0346f33480"}, 210 | {file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8aefbba5f69d42246543407ed2461db31006b0f76c4e32dfd6f42215a2c41d09"}, 211 | {file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:780d3a35680ced9ce682fbcf4cb9c2bad3136eeff760ab33707b71db84664e3a"}, 212 | {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"}, 213 | {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:23b701e65c7b36e4bf15546a89279bd4d8675faabc287d06bbcfac7d3c33e1e6"}, 214 | {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:3e0153a805a98f5ada7e09826255ba99fb4f7524bb81bf6b47fb702666484ae1"}, 215 | {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:dd9b1baec094d91bf36ec729445f7769d0d0cf6b64d04d86e45baf89e2b9059b"}, 216 | {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:1a4471094e146b6790f61b98616ab8e44f72661879cc63fa1049d13ef711e71e"}, 217 | {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5667ed53d68d91920defdf4035d1cdaa3c3121dc0b113255124bcfada1cfa1b8"}, 218 | {file = "frozenlist-1.4.1-cp312-cp312-win32.whl", hash = "sha256:beee944ae828747fd7cb216a70f120767fc9f4f00bacae8543c14a6831673f89"}, 219 | {file = "frozenlist-1.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:64536573d0a2cb6e625cf309984e2d873979709f2cf22839bf2d61790b448ad5"}, 220 | {file = "frozenlist-1.4.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:20b51fa3f588ff2fe658663db52a41a4f7aa6c04f6201449c6c7c476bd255c0d"}, 221 | {file = "frozenlist-1.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:410478a0c562d1a5bcc2f7ea448359fcb050ed48b3c6f6f4f18c313a9bdb1826"}, 222 | {file = "frozenlist-1.4.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c6321c9efe29975232da3bd0af0ad216800a47e93d763ce64f291917a381b8eb"}, 223 | {file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48f6a4533887e189dae092f1cf981f2e3885175f7a0f33c91fb5b7b682b6bab6"}, 224 | {file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6eb73fa5426ea69ee0e012fb59cdc76a15b1283d6e32e4f8dc4482ec67d1194d"}, 225 | {file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fbeb989b5cc29e8daf7f976b421c220f1b8c731cbf22b9130d8815418ea45887"}, 226 | {file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:32453c1de775c889eb4e22f1197fe3bdfe457d16476ea407472b9442e6295f7a"}, 227 | {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"}, 228 | {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:1d0ce09d36d53bbbe566fe296965b23b961764c0bcf3ce2fa45f463745c04701"}, 229 | {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:3a670dc61eb0d0eb7080890c13de3066790f9049b47b0de04007090807c776b0"}, 230 | {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:dca69045298ce5c11fd539682cff879cc1e664c245d1c64da929813e54241d11"}, 231 | {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:a06339f38e9ed3a64e4c4e43aec7f59084033647f908e4259d279a52d3757d09"}, 232 | {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b7f2f9f912dca3934c1baec2e4585a674ef16fe00218d833856408c48d5beee7"}, 233 | {file = "frozenlist-1.4.1-cp38-cp38-win32.whl", hash = "sha256:e7004be74cbb7d9f34553a5ce5fb08be14fb33bc86f332fb71cbe5216362a497"}, 234 | {file = "frozenlist-1.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:5a7d70357e7cee13f470c7883a063aae5fe209a493c57d86eb7f5a6f910fae09"}, 235 | {file = "frozenlist-1.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:bfa4a17e17ce9abf47a74ae02f32d014c5e9404b6d9ac7f729e01562bbee601e"}, 236 | {file = "frozenlist-1.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b7e3ed87d4138356775346e6845cccbe66cd9e207f3cd11d2f0b9fd13681359d"}, 237 | {file = "frozenlist-1.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c99169d4ff810155ca50b4da3b075cbde79752443117d89429595c2e8e37fed8"}, 238 | {file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:edb678da49d9f72c9f6c609fbe41a5dfb9a9282f9e6a2253d5a91e0fc382d7c0"}, 239 | {file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6db4667b187a6742b33afbbaf05a7bc551ffcf1ced0000a571aedbb4aa42fc7b"}, 240 | {file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:55fdc093b5a3cb41d420884cdaf37a1e74c3c37a31f46e66286d9145d2063bd0"}, 241 | {file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82e8211d69a4f4bc360ea22cd6555f8e61a1bd211d1d5d39d3d228b48c83a897"}, 242 | {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"}, 243 | {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9d3e0c25a2350080e9319724dede4f31f43a6c9779be48021a7f4ebde8b2d742"}, 244 | {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7268252af60904bf52c26173cbadc3a071cece75f873705419c8681f24d3edea"}, 245 | {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:0c250a29735d4f15321007fb02865f0e6b6a41a6b88f1f523ca1596ab5f50bd5"}, 246 | {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:96ec70beabbd3b10e8bfe52616a13561e58fe84c0101dd031dc78f250d5128b9"}, 247 | {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:23b2d7679b73fe0e5a4560b672a39f98dfc6f60df63823b0a9970525325b95f6"}, 248 | {file = "frozenlist-1.4.1-cp39-cp39-win32.whl", hash = "sha256:a7496bfe1da7fb1a4e1cc23bb67c58fab69311cc7d32b5a99c2007b4b2a0e932"}, 249 | {file = "frozenlist-1.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:e6a20a581f9ce92d389a8c7d7c3dd47c81fd5d6e655c8dddf341e14aa48659d0"}, 250 | {file = "frozenlist-1.4.1-py3-none-any.whl", hash = "sha256:04ced3e6a46b4cfffe20f9ae482818e34eba9b5fb0ce4056e4cc9b6e212d09b7"}, 251 | {file = "frozenlist-1.4.1.tar.gz", hash = "sha256:c037a86e8513059a2613aaba4d817bb90b9d9b6b69aace3ce9c877e8c8ed402b"}, 252 | ] 253 | 254 | [[package]] 255 | name = "idna" 256 | version = "3.7" 257 | description = "Internationalized Domain Names in Applications (IDNA)" 258 | optional = false 259 | python-versions = ">=3.5" 260 | files = [ 261 | {file = "idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0"}, 262 | {file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"}, 263 | ] 264 | 265 | [[package]] 266 | name = "linkify-it-py" 267 | version = "2.0.3" 268 | description = "Links recognition library with FULL unicode support." 269 | optional = false 270 | python-versions = ">=3.7" 271 | files = [ 272 | {file = "linkify-it-py-2.0.3.tar.gz", hash = "sha256:68cda27e162e9215c17d786649d1da0021a451bdc436ef9e0fa0ba5234b9b048"}, 273 | {file = "linkify_it_py-2.0.3-py3-none-any.whl", hash = "sha256:6bcbc417b0ac14323382aef5c5192c0075bf8a9d6b41820a2b66371eac6b6d79"}, 274 | ] 275 | 276 | [package.dependencies] 277 | uc-micro-py = "*" 278 | 279 | [package.extras] 280 | benchmark = ["pytest", "pytest-benchmark"] 281 | dev = ["black", "flake8", "isort", "pre-commit", "pyproject-flake8"] 282 | doc = ["myst-parser", "sphinx", "sphinx-book-theme"] 283 | test = ["coverage", "pytest", "pytest-cov"] 284 | 285 | [[package]] 286 | name = "markdown-it-py" 287 | version = "3.0.0" 288 | description = "Python port of markdown-it. Markdown parsing, done right!" 289 | optional = false 290 | python-versions = ">=3.8" 291 | files = [ 292 | {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, 293 | {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, 294 | ] 295 | 296 | [package.dependencies] 297 | linkify-it-py = {version = ">=1,<3", optional = true, markers = "extra == \"linkify\""} 298 | mdit-py-plugins = {version = "*", optional = true, markers = "extra == \"plugins\""} 299 | mdurl = ">=0.1,<1.0" 300 | 301 | [package.extras] 302 | benchmarking = ["psutil", "pytest", "pytest-benchmark"] 303 | code-style = ["pre-commit (>=3.0,<4.0)"] 304 | 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)"] 305 | linkify = ["linkify-it-py (>=1,<3)"] 306 | plugins = ["mdit-py-plugins"] 307 | profiling = ["gprof2dot"] 308 | rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] 309 | testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] 310 | 311 | [[package]] 312 | name = "mdit-py-plugins" 313 | version = "0.4.0" 314 | description = "Collection of plugins for markdown-it-py" 315 | optional = false 316 | python-versions = ">=3.8" 317 | files = [ 318 | {file = "mdit_py_plugins-0.4.0-py3-none-any.whl", hash = "sha256:b51b3bb70691f57f974e257e367107857a93b36f322a9e6d44ca5bf28ec2def9"}, 319 | {file = "mdit_py_plugins-0.4.0.tar.gz", hash = "sha256:d8ab27e9aed6c38aa716819fedfde15ca275715955f8a185a8e1cf90fb1d2c1b"}, 320 | ] 321 | 322 | [package.dependencies] 323 | markdown-it-py = ">=1.0.0,<4.0.0" 324 | 325 | [package.extras] 326 | code-style = ["pre-commit"] 327 | rtd = ["myst-parser", "sphinx-book-theme"] 328 | testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] 329 | 330 | [[package]] 331 | name = "mdurl" 332 | version = "0.1.2" 333 | description = "Markdown URL utilities" 334 | optional = false 335 | python-versions = ">=3.7" 336 | files = [ 337 | {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, 338 | {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, 339 | ] 340 | 341 | [[package]] 342 | name = "msgpack" 343 | version = "1.0.8" 344 | description = "MessagePack serializer" 345 | optional = false 346 | python-versions = ">=3.8" 347 | files = [ 348 | {file = "msgpack-1.0.8-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:505fe3d03856ac7d215dbe005414bc28505d26f0c128906037e66d98c4e95868"}, 349 | {file = "msgpack-1.0.8-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6b7842518a63a9f17107eb176320960ec095a8ee3b4420b5f688e24bf50c53c"}, 350 | {file = "msgpack-1.0.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:376081f471a2ef24828b83a641a02c575d6103a3ad7fd7dade5486cad10ea659"}, 351 | {file = "msgpack-1.0.8-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5e390971d082dba073c05dbd56322427d3280b7cc8b53484c9377adfbae67dc2"}, 352 | {file = "msgpack-1.0.8-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00e073efcba9ea99db5acef3959efa45b52bc67b61b00823d2a1a6944bf45982"}, 353 | {file = "msgpack-1.0.8-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82d92c773fbc6942a7a8b520d22c11cfc8fd83bba86116bfcf962c2f5c2ecdaa"}, 354 | {file = "msgpack-1.0.8-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9ee32dcb8e531adae1f1ca568822e9b3a738369b3b686d1477cbc643c4a9c128"}, 355 | {file = "msgpack-1.0.8-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e3aa7e51d738e0ec0afbed661261513b38b3014754c9459508399baf14ae0c9d"}, 356 | {file = "msgpack-1.0.8-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:69284049d07fce531c17404fcba2bb1df472bc2dcdac642ae71a2d079d950653"}, 357 | {file = "msgpack-1.0.8-cp310-cp310-win32.whl", hash = "sha256:13577ec9e247f8741c84d06b9ece5f654920d8365a4b636ce0e44f15e07ec693"}, 358 | {file = "msgpack-1.0.8-cp310-cp310-win_amd64.whl", hash = "sha256:e532dbd6ddfe13946de050d7474e3f5fb6ec774fbb1a188aaf469b08cf04189a"}, 359 | {file = "msgpack-1.0.8-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9517004e21664f2b5a5fd6333b0731b9cf0817403a941b393d89a2f1dc2bd836"}, 360 | {file = "msgpack-1.0.8-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d16a786905034e7e34098634b184a7d81f91d4c3d246edc6bd7aefb2fd8ea6ad"}, 361 | {file = "msgpack-1.0.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e2872993e209f7ed04d963e4b4fbae72d034844ec66bc4ca403329db2074377b"}, 362 | {file = "msgpack-1.0.8-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c330eace3dd100bdb54b5653b966de7f51c26ec4a7d4e87132d9b4f738220ba"}, 363 | {file = "msgpack-1.0.8-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b5c044f3eff2a6534768ccfd50425939e7a8b5cf9a7261c385de1e20dcfc85"}, 364 | {file = "msgpack-1.0.8-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1876b0b653a808fcd50123b953af170c535027bf1d053b59790eebb0aeb38950"}, 365 | {file = "msgpack-1.0.8-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:dfe1f0f0ed5785c187144c46a292b8c34c1295c01da12e10ccddfc16def4448a"}, 366 | {file = "msgpack-1.0.8-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:3528807cbbb7f315bb81959d5961855e7ba52aa60a3097151cb21956fbc7502b"}, 367 | {file = "msgpack-1.0.8-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e2f879ab92ce502a1e65fce390eab619774dda6a6ff719718069ac94084098ce"}, 368 | {file = "msgpack-1.0.8-cp311-cp311-win32.whl", hash = "sha256:26ee97a8261e6e35885c2ecd2fd4a6d38252246f94a2aec23665a4e66d066305"}, 369 | {file = "msgpack-1.0.8-cp311-cp311-win_amd64.whl", hash = "sha256:eadb9f826c138e6cf3c49d6f8de88225a3c0ab181a9b4ba792e006e5292d150e"}, 370 | {file = "msgpack-1.0.8-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:114be227f5213ef8b215c22dde19532f5da9652e56e8ce969bf0a26d7c419fee"}, 371 | {file = "msgpack-1.0.8-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d661dc4785affa9d0edfdd1e59ec056a58b3dbb9f196fa43587f3ddac654ac7b"}, 372 | {file = "msgpack-1.0.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d56fd9f1f1cdc8227d7b7918f55091349741904d9520c65f0139a9755952c9e8"}, 373 | {file = "msgpack-1.0.8-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0726c282d188e204281ebd8de31724b7d749adebc086873a59efb8cf7ae27df3"}, 374 | {file = "msgpack-1.0.8-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8db8e423192303ed77cff4dce3a4b88dbfaf43979d280181558af5e2c3c71afc"}, 375 | {file = "msgpack-1.0.8-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:99881222f4a8c2f641f25703963a5cefb076adffd959e0558dc9f803a52d6a58"}, 376 | {file = "msgpack-1.0.8-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b5505774ea2a73a86ea176e8a9a4a7c8bf5d521050f0f6f8426afe798689243f"}, 377 | {file = "msgpack-1.0.8-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:ef254a06bcea461e65ff0373d8a0dd1ed3aa004af48839f002a0c994a6f72d04"}, 378 | {file = "msgpack-1.0.8-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:e1dd7839443592d00e96db831eddb4111a2a81a46b028f0facd60a09ebbdd543"}, 379 | {file = "msgpack-1.0.8-cp312-cp312-win32.whl", hash = "sha256:64d0fcd436c5683fdd7c907eeae5e2cbb5eb872fafbc03a43609d7941840995c"}, 380 | {file = "msgpack-1.0.8-cp312-cp312-win_amd64.whl", hash = "sha256:74398a4cf19de42e1498368c36eed45d9528f5fd0155241e82c4082b7e16cffd"}, 381 | {file = "msgpack-1.0.8-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:0ceea77719d45c839fd73abcb190b8390412a890df2f83fb8cf49b2a4b5c2f40"}, 382 | {file = "msgpack-1.0.8-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1ab0bbcd4d1f7b6991ee7c753655b481c50084294218de69365f8f1970d4c151"}, 383 | {file = "msgpack-1.0.8-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:1cce488457370ffd1f953846f82323cb6b2ad2190987cd4d70b2713e17268d24"}, 384 | {file = "msgpack-1.0.8-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3923a1778f7e5ef31865893fdca12a8d7dc03a44b33e2a5f3295416314c09f5d"}, 385 | {file = "msgpack-1.0.8-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a22e47578b30a3e199ab067a4d43d790249b3c0587d9a771921f86250c8435db"}, 386 | {file = "msgpack-1.0.8-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bd739c9251d01e0279ce729e37b39d49a08c0420d3fee7f2a4968c0576678f77"}, 387 | {file = "msgpack-1.0.8-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:d3420522057ebab1728b21ad473aa950026d07cb09da41103f8e597dfbfaeb13"}, 388 | {file = "msgpack-1.0.8-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:5845fdf5e5d5b78a49b826fcdc0eb2e2aa7191980e3d2cfd2a30303a74f212e2"}, 389 | {file = "msgpack-1.0.8-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6a0e76621f6e1f908ae52860bdcb58e1ca85231a9b0545e64509c931dd34275a"}, 390 | {file = "msgpack-1.0.8-cp38-cp38-win32.whl", hash = "sha256:374a8e88ddab84b9ada695d255679fb99c53513c0a51778796fcf0944d6c789c"}, 391 | {file = "msgpack-1.0.8-cp38-cp38-win_amd64.whl", hash = "sha256:f3709997b228685fe53e8c433e2df9f0cdb5f4542bd5114ed17ac3c0129b0480"}, 392 | {file = "msgpack-1.0.8-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:f51bab98d52739c50c56658cc303f190785f9a2cd97b823357e7aeae54c8f68a"}, 393 | {file = "msgpack-1.0.8-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:73ee792784d48aa338bba28063e19a27e8d989344f34aad14ea6e1b9bd83f596"}, 394 | {file = "msgpack-1.0.8-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f9904e24646570539a8950400602d66d2b2c492b9010ea7e965025cb71d0c86d"}, 395 | {file = "msgpack-1.0.8-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e75753aeda0ddc4c28dce4c32ba2f6ec30b1b02f6c0b14e547841ba5b24f753f"}, 396 | {file = "msgpack-1.0.8-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5dbf059fb4b7c240c873c1245ee112505be27497e90f7c6591261c7d3c3a8228"}, 397 | {file = "msgpack-1.0.8-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4916727e31c28be8beaf11cf117d6f6f188dcc36daae4e851fee88646f5b6b18"}, 398 | {file = "msgpack-1.0.8-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7938111ed1358f536daf311be244f34df7bf3cdedb3ed883787aca97778b28d8"}, 399 | {file = "msgpack-1.0.8-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:493c5c5e44b06d6c9268ce21b302c9ca055c1fd3484c25ba41d34476c76ee746"}, 400 | {file = "msgpack-1.0.8-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5fbb160554e319f7b22ecf530a80a3ff496d38e8e07ae763b9e82fadfe96f273"}, 401 | {file = "msgpack-1.0.8-cp39-cp39-win32.whl", hash = "sha256:f9af38a89b6a5c04b7d18c492c8ccf2aee7048aff1ce8437c4683bb5a1df893d"}, 402 | {file = "msgpack-1.0.8-cp39-cp39-win_amd64.whl", hash = "sha256:ed59dd52075f8fc91da6053b12e8c89e37aa043f8986efd89e61fae69dc1b011"}, 403 | {file = "msgpack-1.0.8-py3-none-any.whl", hash = "sha256:24f727df1e20b9876fa6e95f840a2a2651e34c0ad147676356f4bf5fbb0206ca"}, 404 | ] 405 | 406 | [[package]] 407 | name = "multidict" 408 | version = "6.0.5" 409 | description = "multidict implementation" 410 | optional = false 411 | python-versions = ">=3.7" 412 | files = [ 413 | {file = "multidict-6.0.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:228b644ae063c10e7f324ab1ab6b548bdf6f8b47f3ec234fef1093bc2735e5f9"}, 414 | {file = "multidict-6.0.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:896ebdcf62683551312c30e20614305f53125750803b614e9e6ce74a96232604"}, 415 | {file = "multidict-6.0.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:411bf8515f3be9813d06004cac41ccf7d1cd46dfe233705933dd163b60e37600"}, 416 | {file = "multidict-6.0.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d147090048129ce3c453f0292e7697d333db95e52616b3793922945804a433c"}, 417 | {file = "multidict-6.0.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:215ed703caf15f578dca76ee6f6b21b7603791ae090fbf1ef9d865571039ade5"}, 418 | {file = "multidict-6.0.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c6390cf87ff6234643428991b7359b5f59cc15155695deb4eda5c777d2b880f"}, 419 | {file = "multidict-6.0.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21fd81c4ebdb4f214161be351eb5bcf385426bf023041da2fd9e60681f3cebae"}, 420 | {file = "multidict-6.0.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3cc2ad10255f903656017363cd59436f2111443a76f996584d1077e43ee51182"}, 421 | {file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:6939c95381e003f54cd4c5516740faba40cf5ad3eeff460c3ad1d3e0ea2549bf"}, 422 | {file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:220dd781e3f7af2c2c1053da9fa96d9cf3072ca58f057f4c5adaaa1cab8fc442"}, 423 | {file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:766c8f7511df26d9f11cd3a8be623e59cca73d44643abab3f8c8c07620524e4a"}, 424 | {file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:fe5d7785250541f7f5019ab9cba2c71169dc7d74d0f45253f8313f436458a4ef"}, 425 | {file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1c1496e73051918fcd4f58ff2e0f2f3066d1c76a0c6aeffd9b45d53243702cc"}, 426 | {file = "multidict-6.0.5-cp310-cp310-win32.whl", hash = "sha256:7afcdd1fc07befad18ec4523a782cde4e93e0a2bf71239894b8d61ee578c1319"}, 427 | {file = "multidict-6.0.5-cp310-cp310-win_amd64.whl", hash = "sha256:99f60d34c048c5c2fabc766108c103612344c46e35d4ed9ae0673d33c8fb26e8"}, 428 | {file = "multidict-6.0.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f285e862d2f153a70586579c15c44656f888806ed0e5b56b64489afe4a2dbfba"}, 429 | {file = "multidict-6.0.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:53689bb4e102200a4fafa9de9c7c3c212ab40a7ab2c8e474491914d2305f187e"}, 430 | {file = "multidict-6.0.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:612d1156111ae11d14afaf3a0669ebf6c170dbb735e510a7438ffe2369a847fd"}, 431 | {file = "multidict-6.0.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7be7047bd08accdb7487737631d25735c9a04327911de89ff1b26b81745bd4e3"}, 432 | {file = "multidict-6.0.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de170c7b4fe6859beb8926e84f7d7d6c693dfe8e27372ce3b76f01c46e489fcf"}, 433 | {file = "multidict-6.0.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04bde7a7b3de05732a4eb39c94574db1ec99abb56162d6c520ad26f83267de29"}, 434 | {file = "multidict-6.0.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85f67aed7bb647f93e7520633d8f51d3cbc6ab96957c71272b286b2f30dc70ed"}, 435 | {file = "multidict-6.0.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:425bf820055005bfc8aa9a0b99ccb52cc2f4070153e34b701acc98d201693733"}, 436 | {file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d3eb1ceec286eba8220c26f3b0096cf189aea7057b6e7b7a2e60ed36b373b77f"}, 437 | {file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:7901c05ead4b3fb75113fb1dd33eb1253c6d3ee37ce93305acd9d38e0b5f21a4"}, 438 | {file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:e0e79d91e71b9867c73323a3444724d496c037e578a0e1755ae159ba14f4f3d1"}, 439 | {file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:29bfeb0dff5cb5fdab2023a7a9947b3b4af63e9c47cae2a10ad58394b517fddc"}, 440 | {file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e030047e85cbcedbfc073f71836d62dd5dadfbe7531cae27789ff66bc551bd5e"}, 441 | {file = "multidict-6.0.5-cp311-cp311-win32.whl", hash = "sha256:2f4848aa3baa109e6ab81fe2006c77ed4d3cd1e0ac2c1fbddb7b1277c168788c"}, 442 | {file = "multidict-6.0.5-cp311-cp311-win_amd64.whl", hash = "sha256:2faa5ae9376faba05f630d7e5e6be05be22913782b927b19d12b8145968a85ea"}, 443 | {file = "multidict-6.0.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:51d035609b86722963404f711db441cf7134f1889107fb171a970c9701f92e1e"}, 444 | {file = "multidict-6.0.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:cbebcd5bcaf1eaf302617c114aa67569dd3f090dd0ce8ba9e35e9985b41ac35b"}, 445 | {file = "multidict-6.0.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2ffc42c922dbfddb4a4c3b438eb056828719f07608af27d163191cb3e3aa6cc5"}, 446 | {file = "multidict-6.0.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ceb3b7e6a0135e092de86110c5a74e46bda4bd4fbfeeb3a3bcec79c0f861e450"}, 447 | {file = "multidict-6.0.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:79660376075cfd4b2c80f295528aa6beb2058fd289f4c9252f986751a4cd0496"}, 448 | {file = "multidict-6.0.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e4428b29611e989719874670fd152b6625500ad6c686d464e99f5aaeeaca175a"}, 449 | {file = "multidict-6.0.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d84a5c3a5f7ce6db1f999fb9438f686bc2e09d38143f2d93d8406ed2dd6b9226"}, 450 | {file = "multidict-6.0.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:76c0de87358b192de7ea9649beb392f107dcad9ad27276324c24c91774ca5271"}, 451 | {file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:79a6d2ba910adb2cbafc95dad936f8b9386e77c84c35bc0add315b856d7c3abb"}, 452 | {file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:92d16a3e275e38293623ebf639c471d3e03bb20b8ebb845237e0d3664914caef"}, 453 | {file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:fb616be3538599e797a2017cccca78e354c767165e8858ab5116813146041a24"}, 454 | {file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:14c2976aa9038c2629efa2c148022ed5eb4cb939e15ec7aace7ca932f48f9ba6"}, 455 | {file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:435a0984199d81ca178b9ae2c26ec3d49692d20ee29bc4c11a2a8d4514c67eda"}, 456 | {file = "multidict-6.0.5-cp312-cp312-win32.whl", hash = "sha256:9fe7b0653ba3d9d65cbe7698cca585bf0f8c83dbbcc710db9c90f478e175f2d5"}, 457 | {file = "multidict-6.0.5-cp312-cp312-win_amd64.whl", hash = "sha256:01265f5e40f5a17f8241d52656ed27192be03bfa8764d88e8220141d1e4b3556"}, 458 | {file = "multidict-6.0.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:19fe01cea168585ba0f678cad6f58133db2aa14eccaf22f88e4a6dccadfad8b3"}, 459 | {file = "multidict-6.0.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6bf7a982604375a8d49b6cc1b781c1747f243d91b81035a9b43a2126c04766f5"}, 460 | {file = "multidict-6.0.5-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:107c0cdefe028703fb5dafe640a409cb146d44a6ae201e55b35a4af8e95457dd"}, 461 | {file = "multidict-6.0.5-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:403c0911cd5d5791605808b942c88a8155c2592e05332d2bf78f18697a5fa15e"}, 462 | {file = "multidict-6.0.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aeaf541ddbad8311a87dd695ed9642401131ea39ad7bc8cf3ef3967fd093b626"}, 463 | {file = "multidict-6.0.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e4972624066095e52b569e02b5ca97dbd7a7ddd4294bf4e7247d52635630dd83"}, 464 | {file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d946b0a9eb8aaa590df1fe082cee553ceab173e6cb5b03239716338629c50c7a"}, 465 | {file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b55358304d7a73d7bdf5de62494aaf70bd33015831ffd98bc498b433dfe5b10c"}, 466 | {file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:a3145cb08d8625b2d3fee1b2d596a8766352979c9bffe5d7833e0503d0f0b5e5"}, 467 | {file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:d65f25da8e248202bd47445cec78e0025c0fe7582b23ec69c3b27a640dd7a8e3"}, 468 | {file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:c9bf56195c6bbd293340ea82eafd0071cb3d450c703d2c93afb89f93b8386ccc"}, 469 | {file = "multidict-6.0.5-cp37-cp37m-win32.whl", hash = "sha256:69db76c09796b313331bb7048229e3bee7928eb62bab5e071e9f7fcc4879caee"}, 470 | {file = "multidict-6.0.5-cp37-cp37m-win_amd64.whl", hash = "sha256:fce28b3c8a81b6b36dfac9feb1de115bab619b3c13905b419ec71d03a3fc1423"}, 471 | {file = "multidict-6.0.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:76f067f5121dcecf0d63a67f29080b26c43c71a98b10c701b0677e4a065fbd54"}, 472 | {file = "multidict-6.0.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b82cc8ace10ab5bd93235dfaab2021c70637005e1ac787031f4d1da63d493c1d"}, 473 | {file = "multidict-6.0.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5cb241881eefd96b46f89b1a056187ea8e9ba14ab88ba632e68d7a2ecb7aadf7"}, 474 | {file = "multidict-6.0.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8e94e6912639a02ce173341ff62cc1201232ab86b8a8fcc05572741a5dc7d93"}, 475 | {file = "multidict-6.0.5-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09a892e4a9fb47331da06948690ae38eaa2426de97b4ccbfafbdcbe5c8f37ff8"}, 476 | {file = "multidict-6.0.5-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:55205d03e8a598cfc688c71ca8ea5f66447164efff8869517f175ea632c7cb7b"}, 477 | {file = "multidict-6.0.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:37b15024f864916b4951adb95d3a80c9431299080341ab9544ed148091b53f50"}, 478 | {file = "multidict-6.0.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2a1dee728b52b33eebff5072817176c172050d44d67befd681609b4746e1c2e"}, 479 | {file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:edd08e6f2f1a390bf137080507e44ccc086353c8e98c657e666c017718561b89"}, 480 | {file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:60d698e8179a42ec85172d12f50b1668254628425a6bd611aba022257cac1386"}, 481 | {file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:3d25f19500588cbc47dc19081d78131c32637c25804df8414463ec908631e453"}, 482 | {file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:4cc0ef8b962ac7a5e62b9e826bd0cd5040e7d401bc45a6835910ed699037a461"}, 483 | {file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:eca2e9d0cc5a889850e9bbd68e98314ada174ff6ccd1129500103df7a94a7a44"}, 484 | {file = "multidict-6.0.5-cp38-cp38-win32.whl", hash = "sha256:4a6a4f196f08c58c59e0b8ef8ec441d12aee4125a7d4f4fef000ccb22f8d7241"}, 485 | {file = "multidict-6.0.5-cp38-cp38-win_amd64.whl", hash = "sha256:0275e35209c27a3f7951e1ce7aaf93ce0d163b28948444bec61dd7badc6d3f8c"}, 486 | {file = "multidict-6.0.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e7be68734bd8c9a513f2b0cfd508802d6609da068f40dc57d4e3494cefc92929"}, 487 | {file = "multidict-6.0.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1d9ea7a7e779d7a3561aade7d596649fbecfa5c08a7674b11b423783217933f9"}, 488 | {file = "multidict-6.0.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ea1456df2a27c73ce51120fa2f519f1bea2f4a03a917f4a43c8707cf4cbbae1a"}, 489 | {file = "multidict-6.0.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf590b134eb70629e350691ecca88eac3e3b8b3c86992042fb82e3cb1830d5e1"}, 490 | {file = "multidict-6.0.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5c0631926c4f58e9a5ccce555ad7747d9a9f8b10619621f22f9635f069f6233e"}, 491 | {file = "multidict-6.0.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dce1c6912ab9ff5f179eaf6efe7365c1f425ed690b03341911bf4939ef2f3046"}, 492 | {file = "multidict-6.0.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0868d64af83169e4d4152ec612637a543f7a336e4a307b119e98042e852ad9c"}, 493 | {file = "multidict-6.0.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:141b43360bfd3bdd75f15ed811850763555a251e38b2405967f8e25fb43f7d40"}, 494 | {file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7df704ca8cf4a073334e0427ae2345323613e4df18cc224f647f251e5e75a527"}, 495 | {file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:6214c5a5571802c33f80e6c84713b2c79e024995b9c5897f794b43e714daeec9"}, 496 | {file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:cd6c8fca38178e12c00418de737aef1261576bd1b6e8c6134d3e729a4e858b38"}, 497 | {file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:e02021f87a5b6932fa6ce916ca004c4d441509d33bbdbeca70d05dff5e9d2479"}, 498 | {file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ebd8d160f91a764652d3e51ce0d2956b38efe37c9231cd82cfc0bed2e40b581c"}, 499 | {file = "multidict-6.0.5-cp39-cp39-win32.whl", hash = "sha256:04da1bb8c8dbadf2a18a452639771951c662c5ad03aefe4884775454be322c9b"}, 500 | {file = "multidict-6.0.5-cp39-cp39-win_amd64.whl", hash = "sha256:d6f6d4f185481c9669b9447bf9d9cf3b95a0e9df9d169bbc17e363b7d5487755"}, 501 | {file = "multidict-6.0.5-py3-none-any.whl", hash = "sha256:0d63c74e3d7ab26de115c49bffc92cc77ed23395303d496eae515d4204a625e7"}, 502 | {file = "multidict-6.0.5.tar.gz", hash = "sha256:f7e301075edaf50500f0b341543c41194d8df3ae5caf4702f2095f3ca73dd8da"}, 503 | ] 504 | 505 | [[package]] 506 | name = "pygments" 507 | version = "2.17.2" 508 | description = "Pygments is a syntax highlighting package written in Python." 509 | optional = false 510 | python-versions = ">=3.7" 511 | files = [ 512 | {file = "pygments-2.17.2-py3-none-any.whl", hash = "sha256:b27c2826c47d0f3219f29554824c30c5e8945175d888647acd804ddd04af846c"}, 513 | {file = "pygments-2.17.2.tar.gz", hash = "sha256:da46cec9fd2de5be3a8a784f434e4c4ab670b4ff54d605c4c2717e9d49c4c367"}, 514 | ] 515 | 516 | [package.extras] 517 | plugins = ["importlib-metadata"] 518 | windows-terminal = ["colorama (>=0.4.6)"] 519 | 520 | [[package]] 521 | name = "rich" 522 | version = "13.7.1" 523 | description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" 524 | optional = false 525 | python-versions = ">=3.7.0" 526 | files = [ 527 | {file = "rich-13.7.1-py3-none-any.whl", hash = "sha256:4edbae314f59eb482f54e9e30bf00d33350aaa94f4bfcd4e9e3110e64d0d7222"}, 528 | {file = "rich-13.7.1.tar.gz", hash = "sha256:9be308cb1fe2f1f57d67ce99e95af38a1e2bc71ad9813b0e247cf7ffbcc3a432"}, 529 | ] 530 | 531 | [package.dependencies] 532 | markdown-it-py = ">=2.2.0" 533 | pygments = ">=2.13.0,<3.0.0" 534 | typing-extensions = {version = ">=4.0.0,<5.0", markers = "python_version < \"3.9\""} 535 | 536 | [package.extras] 537 | jupyter = ["ipywidgets (>=7.5.1,<9)"] 538 | 539 | [[package]] 540 | name = "textual" 541 | version = "0.58.0" 542 | description = "Modern Text User Interface framework" 543 | optional = false 544 | python-versions = "<4.0,>=3.8" 545 | files = [ 546 | {file = "textual-0.58.0-py3-none-any.whl", hash = "sha256:9daddf713cb64d186fa1ae647fea482dc84b643c9284132cd87adb99cd81d638"}, 547 | {file = "textual-0.58.0.tar.gz", hash = "sha256:5c8c3322308e2b932c4550b0ae9f70daebc39716de3f920831cda96d1640b383"}, 548 | ] 549 | 550 | [package.dependencies] 551 | markdown-it-py = {version = ">=2.1.0", extras = ["linkify", "plugins"]} 552 | rich = ">=13.3.3" 553 | typing-extensions = ">=4.4.0,<5.0.0" 554 | 555 | [package.extras] 556 | syntax = ["tree-sitter (>=0.20.1,<0.21.0)", "tree-sitter-languages (==1.10.2)"] 557 | 558 | [[package]] 559 | name = "textual-dev" 560 | version = "1.5.1" 561 | description = "Development tools for working with Textual" 562 | optional = false 563 | python-versions = ">=3.8,<4.0" 564 | files = [ 565 | {file = "textual_dev-1.5.1-py3-none-any.whl", hash = "sha256:bb37dd769ae6b67e1422aa97f6d6ef952e0a6d2aafe08327449e8bdd70474776"}, 566 | {file = "textual_dev-1.5.1.tar.gz", hash = "sha256:e0366ab6f42c128d7daa37a7c418e61fe7aa83731983da990808e4bf2de922a1"}, 567 | ] 568 | 569 | [package.dependencies] 570 | aiohttp = ">=3.8.1" 571 | click = ">=8.1.2" 572 | msgpack = ">=1.0.3" 573 | textual = ">=0.36.0" 574 | typing-extensions = ">=4.4.0,<5.0.0" 575 | 576 | [[package]] 577 | name = "typing-extensions" 578 | version = "4.11.0" 579 | description = "Backported and Experimental Type Hints for Python 3.8+" 580 | optional = false 581 | python-versions = ">=3.8" 582 | files = [ 583 | {file = "typing_extensions-4.11.0-py3-none-any.whl", hash = "sha256:c1f94d72897edaf4ce775bb7558d5b79d8126906a14ea5ed1635921406c0387a"}, 584 | {file = "typing_extensions-4.11.0.tar.gz", hash = "sha256:83f085bd5ca59c80295fc2a82ab5dac679cbe02b9f33f7d83af68e241bea51b0"}, 585 | ] 586 | 587 | [[package]] 588 | name = "uc-micro-py" 589 | version = "1.0.3" 590 | description = "Micro subset of unicode data files for linkify-it-py projects." 591 | optional = false 592 | python-versions = ">=3.7" 593 | files = [ 594 | {file = "uc-micro-py-1.0.3.tar.gz", hash = "sha256:d321b92cff673ec58027c04015fcaa8bb1e005478643ff4a500882eaab88c48a"}, 595 | {file = "uc_micro_py-1.0.3-py3-none-any.whl", hash = "sha256:db1dffff340817673d7b466ec86114a9dc0e9d4d9b5ba229d9d60e5c12600cd5"}, 596 | ] 597 | 598 | [package.extras] 599 | test = ["coverage", "pytest", "pytest-cov"] 600 | 601 | [[package]] 602 | name = "yarl" 603 | version = "1.9.4" 604 | description = "Yet another URL library" 605 | optional = false 606 | python-versions = ">=3.7" 607 | files = [ 608 | {file = "yarl-1.9.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a8c1df72eb746f4136fe9a2e72b0c9dc1da1cbd23b5372f94b5820ff8ae30e0e"}, 609 | {file = "yarl-1.9.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a3a6ed1d525bfb91b3fc9b690c5a21bb52de28c018530ad85093cc488bee2dd2"}, 610 | {file = "yarl-1.9.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c38c9ddb6103ceae4e4498f9c08fac9b590c5c71b0370f98714768e22ac6fa66"}, 611 | {file = "yarl-1.9.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d9e09c9d74f4566e905a0b8fa668c58109f7624db96a2171f21747abc7524234"}, 612 | {file = "yarl-1.9.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b8477c1ee4bd47c57d49621a062121c3023609f7a13b8a46953eb6c9716ca392"}, 613 | {file = "yarl-1.9.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5ff2c858f5f6a42c2a8e751100f237c5e869cbde669a724f2062d4c4ef93551"}, 614 | {file = "yarl-1.9.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:357495293086c5b6d34ca9616a43d329317feab7917518bc97a08f9e55648455"}, 615 | {file = "yarl-1.9.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:54525ae423d7b7a8ee81ba189f131054defdb122cde31ff17477951464c1691c"}, 616 | {file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:801e9264d19643548651b9db361ce3287176671fb0117f96b5ac0ee1c3530d53"}, 617 | {file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e516dc8baf7b380e6c1c26792610230f37147bb754d6426462ab115a02944385"}, 618 | {file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:7d5aaac37d19b2904bb9dfe12cdb08c8443e7ba7d2852894ad448d4b8f442863"}, 619 | {file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:54beabb809ffcacbd9d28ac57b0db46e42a6e341a030293fb3185c409e626b8b"}, 620 | {file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bac8d525a8dbc2a1507ec731d2867025d11ceadcb4dd421423a5d42c56818541"}, 621 | {file = "yarl-1.9.4-cp310-cp310-win32.whl", hash = "sha256:7855426dfbddac81896b6e533ebefc0af2f132d4a47340cee6d22cac7190022d"}, 622 | {file = "yarl-1.9.4-cp310-cp310-win_amd64.whl", hash = "sha256:848cd2a1df56ddbffeb375535fb62c9d1645dde33ca4d51341378b3f5954429b"}, 623 | {file = "yarl-1.9.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:35a2b9396879ce32754bd457d31a51ff0a9d426fd9e0e3c33394bf4b9036b099"}, 624 | {file = "yarl-1.9.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4c7d56b293cc071e82532f70adcbd8b61909eec973ae9d2d1f9b233f3d943f2c"}, 625 | {file = "yarl-1.9.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d8a1c6c0be645c745a081c192e747c5de06e944a0d21245f4cf7c05e457c36e0"}, 626 | {file = "yarl-1.9.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4b3c1ffe10069f655ea2d731808e76e0f452fc6c749bea04781daf18e6039525"}, 627 | {file = "yarl-1.9.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:549d19c84c55d11687ddbd47eeb348a89df9cb30e1993f1b128f4685cd0ebbf8"}, 628 | {file = "yarl-1.9.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a7409f968456111140c1c95301cadf071bd30a81cbd7ab829169fb9e3d72eae9"}, 629 | {file = "yarl-1.9.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e23a6d84d9d1738dbc6e38167776107e63307dfc8ad108e580548d1f2c587f42"}, 630 | {file = "yarl-1.9.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d8b889777de69897406c9fb0b76cdf2fd0f31267861ae7501d93003d55f54fbe"}, 631 | {file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:03caa9507d3d3c83bca08650678e25364e1843b484f19986a527630ca376ecce"}, 632 | {file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:4e9035df8d0880b2f1c7f5031f33f69e071dfe72ee9310cfc76f7b605958ceb9"}, 633 | {file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:c0ec0ed476f77db9fb29bca17f0a8fcc7bc97ad4c6c1d8959c507decb22e8572"}, 634 | {file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:ee04010f26d5102399bd17f8df8bc38dc7ccd7701dc77f4a68c5b8d733406958"}, 635 | {file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:49a180c2e0743d5d6e0b4d1a9e5f633c62eca3f8a86ba5dd3c471060e352ca98"}, 636 | {file = "yarl-1.9.4-cp311-cp311-win32.whl", hash = "sha256:81eb57278deb6098a5b62e88ad8281b2ba09f2f1147c4767522353eaa6260b31"}, 637 | {file = "yarl-1.9.4-cp311-cp311-win_amd64.whl", hash = "sha256:d1d2532b340b692880261c15aee4dc94dd22ca5d61b9db9a8a361953d36410b1"}, 638 | {file = "yarl-1.9.4-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0d2454f0aef65ea81037759be5ca9947539667eecebca092733b2eb43c965a81"}, 639 | {file = "yarl-1.9.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:44d8ffbb9c06e5a7f529f38f53eda23e50d1ed33c6c869e01481d3fafa6b8142"}, 640 | {file = "yarl-1.9.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:aaaea1e536f98754a6e5c56091baa1b6ce2f2700cc4a00b0d49eca8dea471074"}, 641 | {file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3777ce5536d17989c91696db1d459574e9a9bd37660ea7ee4d3344579bb6f129"}, 642 | {file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9fc5fc1eeb029757349ad26bbc5880557389a03fa6ada41703db5e068881e5f2"}, 643 | {file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea65804b5dc88dacd4a40279af0cdadcfe74b3e5b4c897aa0d81cf86927fee78"}, 644 | {file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa102d6d280a5455ad6a0f9e6d769989638718e938a6a0a2ff3f4a7ff8c62cc4"}, 645 | {file = "yarl-1.9.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09efe4615ada057ba2d30df871d2f668af661e971dfeedf0c159927d48bbeff0"}, 646 | {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:008d3e808d03ef28542372d01057fd09168419cdc8f848efe2804f894ae03e51"}, 647 | {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:6f5cb257bc2ec58f437da2b37a8cd48f666db96d47b8a3115c29f316313654ff"}, 648 | {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:992f18e0ea248ee03b5a6e8b3b4738850ae7dbb172cc41c966462801cbf62cf7"}, 649 | {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:0e9d124c191d5b881060a9e5060627694c3bdd1fe24c5eecc8d5d7d0eb6faabc"}, 650 | {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3986b6f41ad22988e53d5778f91855dc0399b043fc8946d4f2e68af22ee9ff10"}, 651 | {file = "yarl-1.9.4-cp312-cp312-win32.whl", hash = "sha256:4b21516d181cd77ebd06ce160ef8cc2a5e9ad35fb1c5930882baff5ac865eee7"}, 652 | {file = "yarl-1.9.4-cp312-cp312-win_amd64.whl", hash = "sha256:a9bd00dc3bc395a662900f33f74feb3e757429e545d831eef5bb280252631984"}, 653 | {file = "yarl-1.9.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:63b20738b5aac74e239622d2fe30df4fca4942a86e31bf47a81a0e94c14df94f"}, 654 | {file = "yarl-1.9.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7d7f7de27b8944f1fee2c26a88b4dabc2409d2fea7a9ed3df79b67277644e17"}, 655 | {file = "yarl-1.9.4-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c74018551e31269d56fab81a728f683667e7c28c04e807ba08f8c9e3bba32f14"}, 656 | {file = "yarl-1.9.4-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ca06675212f94e7a610e85ca36948bb8fc023e458dd6c63ef71abfd482481aa5"}, 657 | {file = "yarl-1.9.4-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5aef935237d60a51a62b86249839b51345f47564208c6ee615ed2a40878dccdd"}, 658 | {file = "yarl-1.9.4-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2b134fd795e2322b7684155b7855cc99409d10b2e408056db2b93b51a52accc7"}, 659 | {file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d25039a474c4c72a5ad4b52495056f843a7ff07b632c1b92ea9043a3d9950f6e"}, 660 | {file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:f7d6b36dd2e029b6bcb8a13cf19664c7b8e19ab3a58e0fefbb5b8461447ed5ec"}, 661 | {file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:957b4774373cf6f709359e5c8c4a0af9f6d7875db657adb0feaf8d6cb3c3964c"}, 662 | {file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:d7eeb6d22331e2fd42fce928a81c697c9ee2d51400bd1a28803965883e13cead"}, 663 | {file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:6a962e04b8f91f8c4e5917e518d17958e3bdee71fd1d8b88cdce74dd0ebbf434"}, 664 | {file = "yarl-1.9.4-cp37-cp37m-win32.whl", hash = "sha256:f3bc6af6e2b8f92eced34ef6a96ffb248e863af20ef4fde9448cc8c9b858b749"}, 665 | {file = "yarl-1.9.4-cp37-cp37m-win_amd64.whl", hash = "sha256:ad4d7a90a92e528aadf4965d685c17dacff3df282db1121136c382dc0b6014d2"}, 666 | {file = "yarl-1.9.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ec61d826d80fc293ed46c9dd26995921e3a82146feacd952ef0757236fc137be"}, 667 | {file = "yarl-1.9.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8be9e837ea9113676e5754b43b940b50cce76d9ed7d2461df1af39a8ee674d9f"}, 668 | {file = "yarl-1.9.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:bef596fdaa8f26e3d66af846bbe77057237cb6e8efff8cd7cc8dff9a62278bbf"}, 669 | {file = "yarl-1.9.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2d47552b6e52c3319fede1b60b3de120fe83bde9b7bddad11a69fb0af7db32f1"}, 670 | {file = "yarl-1.9.4-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84fc30f71689d7fc9168b92788abc977dc8cefa806909565fc2951d02f6b7d57"}, 671 | {file = "yarl-1.9.4-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4aa9741085f635934f3a2583e16fcf62ba835719a8b2b28fb2917bb0537c1dfa"}, 672 | {file = "yarl-1.9.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:206a55215e6d05dbc6c98ce598a59e6fbd0c493e2de4ea6cc2f4934d5a18d130"}, 673 | {file = "yarl-1.9.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07574b007ee20e5c375a8fe4a0789fad26db905f9813be0f9fef5a68080de559"}, 674 | {file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5a2e2433eb9344a163aced6a5f6c9222c0786e5a9e9cac2c89f0b28433f56e23"}, 675 | {file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:6ad6d10ed9b67a382b45f29ea028f92d25bc0bc1daf6c5b801b90b5aa70fb9ec"}, 676 | {file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:6fe79f998a4052d79e1c30eeb7d6c1c1056ad33300f682465e1b4e9b5a188b78"}, 677 | {file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:a825ec844298c791fd28ed14ed1bffc56a98d15b8c58a20e0e08c1f5f2bea1be"}, 678 | {file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8619d6915b3b0b34420cf9b2bb6d81ef59d984cb0fde7544e9ece32b4b3043c3"}, 679 | {file = "yarl-1.9.4-cp38-cp38-win32.whl", hash = "sha256:686a0c2f85f83463272ddffd4deb5e591c98aac1897d65e92319f729c320eece"}, 680 | {file = "yarl-1.9.4-cp38-cp38-win_amd64.whl", hash = "sha256:a00862fb23195b6b8322f7d781b0dc1d82cb3bcac346d1e38689370cc1cc398b"}, 681 | {file = "yarl-1.9.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:604f31d97fa493083ea21bd9b92c419012531c4e17ea6da0f65cacdcf5d0bd27"}, 682 | {file = "yarl-1.9.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8a854227cf581330ffa2c4824d96e52ee621dd571078a252c25e3a3b3d94a1b1"}, 683 | {file = "yarl-1.9.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ba6f52cbc7809cd8d74604cce9c14868306ae4aa0282016b641c661f981a6e91"}, 684 | {file = "yarl-1.9.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a6327976c7c2f4ee6816eff196e25385ccc02cb81427952414a64811037bbc8b"}, 685 | {file = "yarl-1.9.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8397a3817d7dcdd14bb266283cd1d6fc7264a48c186b986f32e86d86d35fbac5"}, 686 | {file = "yarl-1.9.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e0381b4ce23ff92f8170080c97678040fc5b08da85e9e292292aba67fdac6c34"}, 687 | {file = "yarl-1.9.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23d32a2594cb5d565d358a92e151315d1b2268bc10f4610d098f96b147370136"}, 688 | {file = "yarl-1.9.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ddb2a5c08a4eaaba605340fdee8fc08e406c56617566d9643ad8bf6852778fc7"}, 689 | {file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:26a1dc6285e03f3cc9e839a2da83bcbf31dcb0d004c72d0730e755b33466c30e"}, 690 | {file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:18580f672e44ce1238b82f7fb87d727c4a131f3a9d33a5e0e82b793362bf18b4"}, 691 | {file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:29e0f83f37610f173eb7e7b5562dd71467993495e568e708d99e9d1944f561ec"}, 692 | {file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:1f23e4fe1e8794f74b6027d7cf19dc25f8b63af1483d91d595d4a07eca1fb26c"}, 693 | {file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:db8e58b9d79200c76956cefd14d5c90af54416ff5353c5bfd7cbe58818e26ef0"}, 694 | {file = "yarl-1.9.4-cp39-cp39-win32.whl", hash = "sha256:c7224cab95645c7ab53791022ae77a4509472613e839dab722a72abe5a684575"}, 695 | {file = "yarl-1.9.4-cp39-cp39-win_amd64.whl", hash = "sha256:824d6c50492add5da9374875ce72db7a0733b29c2394890aef23d533106e2b15"}, 696 | {file = "yarl-1.9.4-py3-none-any.whl", hash = "sha256:928cecb0ef9d5a7946eb6ff58417ad2fe9375762382f1bf5c55e61645f2c43ad"}, 697 | {file = "yarl-1.9.4.tar.gz", hash = "sha256:566db86717cf8080b99b58b083b773a908ae40f06681e87e589a976faf8246bf"}, 698 | ] 699 | 700 | [package.dependencies] 701 | idna = ">=2.0" 702 | multidict = ">=4.0" 703 | 704 | [metadata] 705 | lock-version = "2.0" 706 | python-versions = "^3.8" 707 | content-hash = "58d9a6258e13953abdd4fc0a2ad25d2945791398a1edcb17633e1e6687b3c2ef" 708 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "toolong" 3 | version = "1.5.0" 4 | description = "A terminal log file viewer / tailer / analyzer" 5 | authors = ["Will McGugan "] 6 | license = "MIT" 7 | readme = "README.md" 8 | 9 | homepage = "https://github.com/textualize/toolong" 10 | repository = "https://github.com/textualize/toolong" 11 | documentation = "https://github.com/textualize/toolong" 12 | 13 | [tool.poetry.dependencies] 14 | python = "^3.8" 15 | click = "^8.1.7" 16 | textual = "^0.58.0" 17 | typing-extensions = "^4.9.0" 18 | 19 | [tool.poetry.group.dev.dependencies] 20 | textual-dev = "^1.4.0" 21 | 22 | [tool.poetry.scripts] 23 | tl = "toolong.cli:run" 24 | 25 | [build-system] 26 | requires = ["poetry-core"] 27 | build-backend = "poetry.core.masonry.api" 28 | 29 | -------------------------------------------------------------------------------- /src/toolong/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Textualize/toolong/5aa22ee878026f46d4d265905c4e1df4d37842ae/src/toolong/__init__.py -------------------------------------------------------------------------------- /src/toolong/__main__.py: -------------------------------------------------------------------------------- 1 | from toolong.cli import run 2 | 3 | if __name__ == "__main__": 4 | run() 5 | -------------------------------------------------------------------------------- /src/toolong/cli.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from importlib.metadata import version 4 | import os 5 | import sys 6 | 7 | import click 8 | 9 | from toolong.ui import UI 10 | 11 | 12 | @click.command() 13 | @click.version_option(version("toolong")) 14 | @click.argument("files", metavar="FILE1 FILE2", nargs=-1) 15 | @click.option("-m", "--merge", is_flag=True, help="Merge files.") 16 | @click.option( 17 | "-o", 18 | "--output-merge", 19 | metavar="PATH", 20 | nargs=1, 21 | help="Path to save merged file (requires -m).", 22 | ) 23 | def run(files: list[str], merge: bool, output_merge: str) -> None: 24 | """View / tail / search log files.""" 25 | stdin_tty = sys.__stdin__.isatty() 26 | if not files and stdin_tty: 27 | ctx = click.get_current_context() 28 | click.echo(ctx.get_help()) 29 | ctx.exit() 30 | if stdin_tty: 31 | try: 32 | ui = UI(files, merge=merge, save_merge=output_merge) 33 | ui.run() 34 | except Exception: 35 | pass 36 | else: 37 | import signal 38 | import selectors 39 | import subprocess 40 | import tempfile 41 | 42 | def request_exit(*args) -> None: 43 | """Don't write anything when a signal forces an error.""" 44 | sys.stderr.write("^C") 45 | 46 | signal.signal(signal.SIGINT, request_exit) 47 | signal.signal(signal.SIGTERM, request_exit) 48 | 49 | # Write piped data to a temporary file 50 | with tempfile.NamedTemporaryFile( 51 | mode="w+b", buffering=0, prefix="tl_" 52 | ) as temp_file: 53 | 54 | # Get input directly from /dev/tty to free up stdin 55 | with open("/dev/tty", "rb", buffering=0) as tty_stdin: 56 | # Launch a new process to render the UI 57 | with subprocess.Popen( 58 | [sys.argv[0], temp_file.name], 59 | stdin=tty_stdin, 60 | close_fds=True, 61 | env={**os.environ, "TEXTUAL_ALLOW_SIGNALS": "1"}, 62 | ) as process: 63 | 64 | # Current process copies from stdin to the temp file 65 | selector = selectors.SelectSelector() 66 | selector.register(sys.stdin.fileno(), selectors.EVENT_READ) 67 | 68 | while process.poll() is None: 69 | for _, event in selector.select(0.1): 70 | if process.poll() is not None: 71 | break 72 | if event & selectors.EVENT_READ: 73 | if line := os.read(sys.stdin.fileno(), 1024 * 64): 74 | temp_file.write(line) 75 | else: 76 | break 77 | -------------------------------------------------------------------------------- /src/toolong/find_dialog.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | import re 3 | 4 | from textual import on 5 | from textual.app import ComposeResult 6 | from textual.binding import Binding 7 | from textual.message import Message 8 | from textual.suggester import Suggester 9 | from textual.validation import Validator, ValidationResult 10 | from textual.widget import Widget 11 | from textual.widgets import Input, Checkbox 12 | 13 | 14 | class Regex(Validator): 15 | def validate(self, value: str) -> ValidationResult: 16 | """Check a string is equal to its reverse.""" 17 | try: 18 | re.compile(value) 19 | except Exception: 20 | return self.failure("Invalid regex") 21 | else: 22 | return self.success() 23 | 24 | 25 | class FindDialog(Widget, can_focus_children=True): 26 | DEFAULT_CSS = """ 27 | FindDialog { 28 | layout: horizontal; 29 | dock: top; 30 | padding-top: 1; 31 | width: 1fr; 32 | height: auto; 33 | max-height: 70%; 34 | display: none; 35 | & #find { 36 | width: 1fr; 37 | } 38 | &.visible { 39 | display: block; 40 | } 41 | Input { 42 | width: 1fr; 43 | } 44 | Input#find-regex { 45 | display: none; 46 | } 47 | Input#find-text { 48 | display: block; 49 | } 50 | &.-find-regex { 51 | Input#find-regex { 52 | display: block; 53 | } 54 | Input#find-text { 55 | display: none; 56 | } 57 | } 58 | } 59 | """ 60 | BINDINGS = [ 61 | Binding("escape", "dismiss_find", "Dismiss", key_display="esc", show=False), 62 | Binding("down,j", "pointer_down", "Next", key_display="↓"), 63 | Binding("up,k", "pointer_up", "Previous", key_display="↑"), 64 | Binding("j", "pointer_down", "Next", key_display="↓", show=False), 65 | Binding("k", "pointer_up", "Previous", key_display="↑", show=False), 66 | ] 67 | DEFAULT_CLASSES = "float" 68 | BORDER_TITLE = "Find" 69 | 70 | @dataclass 71 | class Update(Message): 72 | find: str 73 | regex: bool 74 | case_sensitive: bool 75 | 76 | class Dismiss(Message): 77 | pass 78 | 79 | @dataclass 80 | class MovePointer(Message): 81 | direction: int = 1 82 | 83 | class SelectLine(Message): 84 | pass 85 | 86 | def __init__(self, suggester: Suggester) -> None: 87 | self.suggester = suggester 88 | super().__init__() 89 | 90 | def compose(self) -> ComposeResult: 91 | yield Input( 92 | placeholder="Regex", 93 | id="find-regex", 94 | suggester=self.suggester, 95 | validators=[Regex()], 96 | ) 97 | yield Input( 98 | placeholder="Find", 99 | id="find-text", 100 | suggester=self.suggester, 101 | ) 102 | yield Checkbox("Case sensitive", id="case-sensitive") 103 | yield Checkbox("Regex", id="regex") 104 | 105 | def focus_input(self) -> None: 106 | if self.has_class("find-regex"): 107 | self.query_one("#find-regex").focus() 108 | else: 109 | self.query_one("#find-text").focus() 110 | 111 | def get_value(self) -> str: 112 | if self.has_class("find-regex"): 113 | return self.query_one("#find-regex", Input).value 114 | else: 115 | return self.query_one("#find-text", Input).value 116 | 117 | @on(Checkbox.Changed, "#regex") 118 | def on_checkbox_changed_regex(self, event: Checkbox.Changed): 119 | if event.value: 120 | self.query_one("#find-regex", Input).value = self.query_one( 121 | "#find-text", Input 122 | ).value 123 | else: 124 | self.query_one("#find-text", Input).value = self.query_one( 125 | "#find-regex", Input 126 | ).value 127 | self.set_class(event.value, "-find-regex") 128 | 129 | @on(Input.Changed) 130 | @on(Checkbox.Changed) 131 | def input_change(self, event: Input.Changed) -> None: 132 | event.stop() 133 | self.post_update() 134 | 135 | @on(Input.Submitted) 136 | def input_submitted(self, event: Input.Changed) -> None: 137 | event.stop() 138 | self.post_message(self.SelectLine()) 139 | 140 | def post_update(self) -> None: 141 | update = FindDialog.Update( 142 | find=self.get_value(), 143 | regex=self.query_one("#regex", Checkbox).value, 144 | case_sensitive=self.query_one("#case-sensitive", Checkbox).value, 145 | ) 146 | self.post_message(update) 147 | 148 | def allow_focus_children(self) -> bool: 149 | return self.has_class("visible") 150 | 151 | def action_dismiss_find(self) -> None: 152 | self.post_message(FindDialog.Dismiss()) 153 | 154 | def action_pointer_down(self) -> None: 155 | self.post_message(self.MovePointer(direction=+1)) 156 | 157 | def action_pointer_up(self) -> None: 158 | self.post_message(self.MovePointer(direction=-1)) 159 | -------------------------------------------------------------------------------- /src/toolong/format_parser.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from datetime import datetime 3 | import json 4 | import re 5 | from typing_extensions import TypeAlias 6 | 7 | from rich.highlighter import JSONHighlighter 8 | import rich.repr 9 | from rich.text import Text 10 | 11 | from toolong.highlighter import LogHighlighter 12 | from toolong import timestamps 13 | from typing import Optional 14 | 15 | 16 | ParseResult: TypeAlias = "tuple[Optional[datetime], str, Text]" 17 | 18 | 19 | @rich.repr.auto 20 | class LogFormat: 21 | def parse(self, line: str) -> ParseResult | None: 22 | raise NotImplementedError() 23 | 24 | 25 | HTTP_GROUPS = { 26 | "1": "cyan", 27 | "2": "green", 28 | "3": "yellow", 29 | "4": "red", 30 | "5": "reverse red", 31 | } 32 | 33 | 34 | class RegexLogFormat(LogFormat): 35 | REGEX = re.compile(".*?") 36 | HIGHLIGHT_WORDS = [ 37 | "GET", 38 | "POST", 39 | "PUT", 40 | "HEAD", 41 | "POST", 42 | "DELETE", 43 | "OPTIONS", 44 | "PATCH", 45 | ] 46 | 47 | highlighter = LogHighlighter() 48 | 49 | def parse(self, line: str) -> ParseResult | None: 50 | match = self.REGEX.fullmatch(line) 51 | if match is None: 52 | return None 53 | groups = match.groupdict() 54 | _, timestamp = timestamps.parse(groups["date"].strip("[]")) 55 | 56 | text = Text.from_ansi(line) 57 | if not text.spans: 58 | text = self.highlighter(text) 59 | if status := groups.get("status", None): 60 | text.highlight_words([f" {status} "], HTTP_GROUPS.get(status[0], "magenta")) 61 | text.highlight_words(self.HIGHLIGHT_WORDS, "bold yellow") 62 | 63 | return timestamp, line, text 64 | 65 | 66 | class CommonLogFormat(RegexLogFormat): 67 | REGEX = re.compile( 68 | r'(?P.*?) (?P.*?) (?P.*?) (?P\[.*?(?= ).*?\]) "(?P.*?) (?P.*?)(?P HTTP\/.*)?" (?P.*?) (?P.*?) "(?P.*?)"' 69 | ) 70 | 71 | 72 | class CombinedLogFormat(RegexLogFormat): 73 | REGEX = re.compile( 74 | r'(?P.*?) (?P.*?) (?P.*?) \[(?P.*?)(?= ) (?P.*?)\] "(?P.*?) (?P.*?)(?P HTTP\/.*)?" (?P.*?) (?P.*?) "(?P.*?)" "(?P.*?)" (?P.*?) (?P.*?) (?P.*)' 75 | ) 76 | 77 | 78 | class DefaultLogFormat(LogFormat): 79 | highlighter = LogHighlighter() 80 | 81 | def parse(self, line: str) -> ParseResult | None: 82 | text = Text.from_ansi(line) 83 | if not text.spans: 84 | text = self.highlighter(text) 85 | return None, line, text 86 | 87 | 88 | class JSONLogFormat(LogFormat): 89 | highlighter = JSONHighlighter() 90 | 91 | def parse(self, line: str) -> ParseResult | None: 92 | line = line.strip() 93 | if not line: 94 | return None 95 | try: 96 | json.loads(line) 97 | except Exception: 98 | return None 99 | _, timestamp = timestamps.parse(line) 100 | text = Text.from_ansi(line) 101 | if not text.spans: 102 | text = self.highlighter(text) 103 | return timestamp, line, text 104 | 105 | 106 | FORMATS = [ 107 | JSONLogFormat(), 108 | CommonLogFormat(), 109 | CombinedLogFormat(), 110 | # DefaultLogFormat(), 111 | ] 112 | 113 | default_log_format = DefaultLogFormat() 114 | 115 | 116 | class FormatParser: 117 | """Parses a log line.""" 118 | 119 | def __init__(self) -> None: 120 | self._formats = FORMATS.copy() 121 | 122 | def parse(self, line: str) -> ParseResult: 123 | """Parse a line.""" 124 | if len(line) > 10_000: 125 | line = line[:10_000] 126 | if line.strip(): 127 | for index, format in enumerate(self._formats): 128 | parse_result = format.parse(line) 129 | if parse_result is not None: 130 | if index: 131 | self._formats = [*self._formats[index:], *self._formats[:index]] 132 | return parse_result 133 | parse_result = default_log_format.parse(line) 134 | if parse_result is not None: 135 | return parse_result 136 | return None, "", Text() 137 | -------------------------------------------------------------------------------- /src/toolong/goto_screen.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from textual.app import ComposeResult 6 | from textual.screen import ModalScreen 7 | from textual.containers import Horizontal 8 | from textual.widgets import Input, Label 9 | from textual.validation import Integer 10 | 11 | if TYPE_CHECKING: 12 | from toolong.log_lines import LogLines 13 | 14 | 15 | class GotoScreen(ModalScreen): 16 | 17 | BINDINGS = [("escape", "dismiss")] 18 | 19 | DEFAULT_CSS = """ 20 | 21 | GotoScreen { 22 | background: black 20%; 23 | align: right bottom; 24 | #goto { 25 | width: auto; 26 | height: auto; 27 | margin: 3 3; 28 | Label { 29 | margin: 1; 30 | } 31 | Input { 32 | width: 16; 33 | } 34 | } 35 | } 36 | 37 | """ 38 | 39 | def __init__(self, log_lines: LogLines) -> None: 40 | self.log_lines = log_lines 41 | super().__init__() 42 | 43 | def compose(self) -> ComposeResult: 44 | log_lines = self.log_lines 45 | with Horizontal(id="goto"): 46 | yield Input( 47 | ( 48 | str( 49 | log_lines.pointer_line + 1 50 | if log_lines.pointer_line is not None 51 | else log_lines.scroll_offset.y + 1 52 | ) 53 | ), 54 | placeholder="Enter line number", 55 | type="integer", 56 | ) 57 | 58 | def on_input_changed(self, event: Input.Changed) -> None: 59 | try: 60 | line_no = int(event.value) - 1 61 | except Exception: 62 | self.log_lines.pointer_line = None 63 | else: 64 | self.log_lines.pointer_line = line_no 65 | self.log_lines.scroll_pointer_to_center() 66 | -------------------------------------------------------------------------------- /src/toolong/help.py: -------------------------------------------------------------------------------- 1 | import webbrowser 2 | from importlib.metadata import version 3 | 4 | from rich.text import Text 5 | 6 | from textual import on 7 | from textual.app import ComposeResult 8 | from textual.containers import Center, VerticalScroll 9 | from textual.screen import ModalScreen 10 | from textual.widgets import Static, Markdown, Footer 11 | 12 | TEXTUAL_LINK = "https://www.textualize.io/" 13 | REPOSITORY_LINK = "https://github.com/Textualize/toolong" 14 | LOGMERGER_LINK = "https://github.com/ptmcg/logmerger" 15 | 16 | HELP_MD = """ 17 | TooLong is a log file viewer / navigator for the terminal. 18 | 19 | Built with [Textual](https://www.textualize.io/) 20 | 21 | Repository: [https://github.com/Textualize/toolong](https://github.com/Textualize/toolong) Author: [Will McGugan](https://www.willmcgugan.com) 22 | 23 | --- 24 | 25 | ### Navigation 26 | 27 | - `tab` / `shift+tab` to navigate between widgets. 28 | - `home` or `G` / `end` or `g` Jump to start or end of file. Press `end` a second time to *tail* the current file. 29 | - `page up` / `page down` or `space` to go to the next / previous page. 30 | - `←` or `h` / `→` or `l` Scroll left / right. 31 | - `↑` or `w` or `k` / `↓` or `s` or `j` Move up / down a line. 32 | - `m` / `M` Advance +1 / -1 minutes. 33 | - `o` / `O` Advance +1 / -1 hours. 34 | - `d` / `D` Advance +1 / -1 days. 35 | - `enter` Toggle pointer mode. 36 | - `escape` Dismiss. 37 | 38 | ### Other keys 39 | 40 | - `ctrl+f` or `/` Show find dialog. 41 | - `ctrl+l` Toggle line numbers. 42 | - `ctrl+t` Tail current file. 43 | - `ctrl+c` Exit the app. 44 | 45 | ### Opening Files 46 | 47 | Open files from the command line. 48 | 49 | ```bash 50 | $ tl foo.log bar.log 51 | ``` 52 | 53 | If you specify more than one file, they will be displayed within tabs. 54 | 55 | #### Opening compressed files 56 | 57 | If a file is compressed with BZip or GZip, it will be uncompressed automatically: 58 | 59 | ```bash 60 | $ tl foo.log.2.gz 61 | ``` 62 | 63 | #### Merging files 64 | 65 | Multiple files will open in tabs. 66 | If you add the `--merge` switch, TooLong will merge all the log files based on their timestamps: 67 | 68 | ```bash 69 | $ tl mysite.log* --merge 70 | ``` 71 | 72 | ### Pointer mode 73 | 74 | Pointer mode lets you navigate by line. 75 | To enter pointer mode, press `enter` or click a line. 76 | When in pointer mode, the navigation keys will move this pointer rather than scroll the log file. 77 | 78 | Press `enter` again or click the line a second time to expand the line in to a new panel. 79 | 80 | Press `escape` to hide the line panel if it is visible, or to leave pointer mode if the line panel is not visible. 81 | 82 | 83 | ### Credits 84 | 85 | Inspiration and regexes taken from [LogMerger](https://github.com/ptmcg/logmerger) by Paul McGuire. 86 | 87 | 88 | ### License 89 | 90 | Copyright 2024 Will McGugan 91 | 92 | 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: 93 | 94 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 95 | 96 | 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. 97 | 98 | """ 99 | 100 | TITLE = rf""" 101 | _______ _ 102 | |__ __| | | Built with Textual 103 | | | ___ ___ | | ___ _ __ __ _ 104 | | |/ _ \ / _ \| | / _ \| '_ \ / _` | 105 | | | (_) | (_) | |___| (_) | | | | (_| | 106 | |_|\___/ \___/|______\___/|_| |_|\__, | 107 | __/ | 108 | Moving at Terminal velocity |___/ v{version('toolong')} 109 | 110 | """ 111 | 112 | 113 | COLORS = [ 114 | "#881177", 115 | "#aa3355", 116 | "#cc6666", 117 | "#ee9944", 118 | "#eedd00", 119 | "#99dd55", 120 | "#44dd88", 121 | "#22ccbb", 122 | "#00bbcc", 123 | "#0099cc", 124 | "#3366bb", 125 | "#663399", 126 | ] 127 | 128 | 129 | def get_title() -> Text: 130 | """Get the title, with a rainbow effect.""" 131 | lines = TITLE.splitlines(keepends=True) 132 | return Text.assemble(*zip(lines, COLORS)) 133 | 134 | 135 | class HelpScreen(ModalScreen): 136 | """Simple Help screen with Markdown and a few links.""" 137 | 138 | CSS = """ 139 | HelpScreen VerticalScroll { 140 | background: $surface; 141 | margin: 4 8; 142 | border: heavy $accent; 143 | height: 1fr; 144 | .title { 145 | width: auto; 146 | } 147 | scrollbar-gutter: stable; 148 | Markdown { 149 | margin:0 2; 150 | } 151 | Markdown .code_inline { 152 | background: $primary-darken-1; 153 | text-style: bold; 154 | } 155 | } 156 | """ 157 | 158 | BINDINGS = [ 159 | ("escape", "dismiss"), 160 | ("a", "go('https://www.willmcgugan.com')", "Author"), 161 | ("t", f"go({TEXTUAL_LINK!r})", "Textual"), 162 | ("r", f"go({REPOSITORY_LINK!r})", "Repository"), 163 | ("l", f"go({LOGMERGER_LINK!r})", "Logmerger"), 164 | ] 165 | 166 | def compose(self) -> ComposeResult: 167 | yield Footer() 168 | with VerticalScroll() as vertical_scroll: 169 | with Center(): 170 | yield Static(get_title(), classes="title") 171 | yield Markdown(HELP_MD) 172 | vertical_scroll.border_title = "Help" 173 | vertical_scroll.border_subtitle = "ESCAPE to dismiss" 174 | 175 | @on(Markdown.LinkClicked) 176 | def on_markdown_link_clicked(self, event: Markdown.LinkClicked) -> None: 177 | self.action_go(event.href) 178 | 179 | def action_go(self, href: str) -> None: 180 | self.notify(f"Opening {href}", title="Link") 181 | webbrowser.open(href) 182 | -------------------------------------------------------------------------------- /src/toolong/highlighter.py: -------------------------------------------------------------------------------- 1 | from rich.highlighter import RegexHighlighter 2 | from rich.text import Text 3 | 4 | 5 | def _combine_regex(*regexes: str) -> str: 6 | """Combine a number of regexes in to a single regex. 7 | 8 | Returns: 9 | str: New regex with all regexes ORed together. 10 | """ 11 | return "|".join(regexes) 12 | 13 | 14 | class LogHighlighter(RegexHighlighter): 15 | """Highlights the text typically produced from ``__repr__`` methods.""" 16 | 17 | base_style = "repr." 18 | highlights = [ 19 | _combine_regex( 20 | r"(?P[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3})", 21 | r"(?P([A-Fa-f0-9]{1,4}::?){1,7}[A-Fa-f0-9]{1,4})", 22 | r"(?P(?:[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})", 23 | r"(?P(?:[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})", 24 | r"(?P[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})", 25 | r"\b(?PTrue)\b|\b(?PFalse)\b|\b(?PNone)\b", 26 | r"(?P(?b?'''.*?(?(file|https|http|ws|wss)://[-0-9a-zA-Z$_+!`(),.?/;:&=%#~]*)", 29 | r"(?P\[.*?\])", 30 | ), 31 | ] 32 | 33 | def highlight(self, text: Text) -> None: 34 | """Highlight :class:`rich.text.Text` using regular expressions. 35 | 36 | Args: 37 | text (~Text): Text to highlighted. 38 | 39 | """ 40 | if len(text) >= 10_000: 41 | return 42 | 43 | highlight_regex = text.highlight_regex 44 | for re_highlight in self.highlights: 45 | highlight_regex(re_highlight, style_prefix=self.base_style) 46 | -------------------------------------------------------------------------------- /src/toolong/line_panel.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from datetime import datetime 3 | import json 4 | 5 | from rich.json import JSON 6 | from rich.text import Text 7 | 8 | from textual.app import ComposeResult 9 | from textual.containers import ScrollableContainer 10 | 11 | from textual.widget import Widget 12 | from textual.widgets import Label, Static 13 | 14 | 15 | class LineDisplay(Widget): 16 | DEFAULT_CSS = """ 17 | LineDisplay { 18 | padding: 0 1; 19 | margin: 1 0; 20 | width: auto; 21 | height: auto; 22 | Label { 23 | width: 1fr; 24 | } 25 | .json { 26 | width: auto; 27 | } 28 | .nl { 29 | width: auto; 30 | } 31 | } 32 | """ 33 | 34 | def __init__(self, line: str, text: Text, timestamp: datetime | None) -> None: 35 | self.line = line 36 | self.text = text 37 | self.timestamp = timestamp 38 | super().__init__() 39 | 40 | def compose(self) -> ComposeResult: 41 | try: 42 | json_data = json.loads(self.line) 43 | except Exception: 44 | pass 45 | else: 46 | yield Static(JSON.from_data(json_data), expand=True, classes="json") 47 | return 48 | 49 | if "\\n" in self.text.plain: 50 | lines = self.text.split("\\n") 51 | text = Text("\n", no_wrap=True).join(lines) 52 | yield Label(text, classes="nl") 53 | else: 54 | yield Label(self.text) 55 | 56 | 57 | class LinePanel(ScrollableContainer): 58 | DEFAULT_CSS = """ 59 | LinePanel { 60 | background: $panel; 61 | overflow-y: auto; 62 | overflow-x: auto; 63 | border: blank transparent; 64 | scrollbar-gutter: stable; 65 | &:focus { 66 | border: heavy $accent; 67 | } 68 | } 69 | """ 70 | 71 | async def update(self, line: str, text: Text, timestamp: datetime | None) -> None: 72 | with self.app.batch_update(): 73 | await self.query(LineDisplay).remove() 74 | await self.mount(LineDisplay(line, text, timestamp)) 75 | -------------------------------------------------------------------------------- /src/toolong/log_file.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from datetime import datetime 4 | import os 5 | import mmap 6 | import mimetypes 7 | import platform 8 | import time 9 | from pathlib import Path 10 | from typing import IO, Iterable 11 | from threading import Event, Lock 12 | 13 | import rich.repr 14 | 15 | from toolong.format_parser import FormatParser, ParseResult 16 | from toolong.timestamps import TimestampScanner 17 | 18 | 19 | IS_WINDOWS = platform.system() == "Windows" 20 | 21 | 22 | class LogError(Exception): 23 | """An error related to logs.""" 24 | 25 | 26 | @rich.repr.auto(angular=True) 27 | class LogFile: 28 | """A single log file.""" 29 | 30 | def __init__(self, path: str) -> None: 31 | self.path = Path(path) 32 | self.name = self.path.name 33 | self.file: IO[bytes] | None = None 34 | self.size = 0 35 | self.can_tail = False 36 | self.timestamp_scanner = TimestampScanner() 37 | self.format_parser = FormatParser() 38 | self._lock = Lock() 39 | 40 | def __rich_repr__(self) -> rich.repr.Result: 41 | yield self.name 42 | yield "size", self.size 43 | 44 | @property 45 | def is_open(self) -> bool: 46 | return self.file is not None 47 | 48 | @property 49 | def fileno(self) -> int: 50 | assert self.file is not None 51 | return self.file.fileno() 52 | 53 | @property 54 | def is_compressed(self) -> bool: 55 | _, encoding = mimetypes.guess_type(self.path.name, strict=False) 56 | return encoding in ("gzip", "bzip2") 57 | 58 | def parse(self, line: str) -> ParseResult: 59 | """Parse a line.""" 60 | return self.format_parser.parse(line) 61 | 62 | def get_create_time(self) -> datetime | None: 63 | try: 64 | stat_result = self.path.stat() 65 | except Exception: 66 | return None 67 | try: 68 | # This works on Mac 69 | create_time_seconds = stat_result.st_birthtime 70 | except AttributeError: 71 | # No birthtime for Linux, so we assume the epoch start 72 | return datetime.fromtimestamp(0) 73 | timestamp = datetime.fromtimestamp(create_time_seconds) 74 | return timestamp 75 | 76 | def open(self, exit_event: Event) -> bool: 77 | # Check for compressed files 78 | _, encoding = mimetypes.guess_type(self.path.name, strict=False) 79 | 80 | # Open compressed files 81 | if encoding in ("gzip", "bzip2"): 82 | return self.open_compressed(exit_event, encoding) 83 | 84 | # Open uncompressed file 85 | self.file = open(self.path, "rb", buffering=0) 86 | 87 | self.file.seek(0, os.SEEK_END) 88 | self.size = self.file.tell() 89 | self.can_tail = True 90 | return True 91 | 92 | def open_compressed(self, exit_event: Event, encoding: str) -> bool: 93 | from tempfile import TemporaryFile 94 | 95 | chunk_size = 1024 * 256 96 | 97 | temp_file = TemporaryFile("wb+") 98 | 99 | compressed_file: IO[bytes] 100 | if encoding == "gzip": 101 | import gzip 102 | 103 | compressed_file = gzip.open(self.path, "rb") 104 | elif encoding == "bzip2": 105 | import bz2 106 | 107 | compressed_file = bz2.open(self.path, "rb") 108 | else: 109 | # Shouldn't get here 110 | raise AssertionError("Not supported") 111 | 112 | try: 113 | while data := compressed_file.read(chunk_size): 114 | temp_file.write(data) 115 | if exit_event.is_set(): 116 | temp_file.close() 117 | return False 118 | finally: 119 | compressed_file.close() 120 | 121 | temp_file.flush() 122 | self.file = temp_file 123 | self.size = temp_file.tell() 124 | self.can_tail = False 125 | return True 126 | 127 | def close(self) -> None: 128 | if self.file is not None: 129 | self.file.close() 130 | self.file = None 131 | 132 | if IS_WINDOWS: 133 | 134 | def get_raw(self, start: int, end: int) -> bytes: 135 | with self._lock: 136 | if start >= end or self.file is None: 137 | return b"" 138 | position = os.lseek(self.fileno, 0, os.SEEK_CUR) 139 | try: 140 | os.lseek(self.fileno, start, os.SEEK_SET) 141 | return os.read(self.fileno, end - start) 142 | finally: 143 | os.lseek(self.fileno, position, os.SEEK_SET) 144 | 145 | else: 146 | 147 | def get_raw(self, start: int, end: int) -> bytes: 148 | if start >= end or self.file is None: 149 | return b"" 150 | return os.pread(self.fileno, end - start, start) 151 | 152 | def get_line(self, start: int, end: int) -> str: 153 | return ( 154 | self.get_raw(start, end) 155 | .decode("utf-8", errors="replace") 156 | .strip("\n\r") 157 | .expandtabs(4) 158 | ) 159 | 160 | def scan_line_breaks( 161 | self, batch_time: float = 0.25 162 | ) -> Iterable[tuple[int, list[int]]]: 163 | """Scan the file for line breaks. 164 | 165 | Args: 166 | batch_time: Time to group the batches. 167 | 168 | Returns: 169 | An iterable of tuples, containing the scan position and a list of offsets of new lines. 170 | """ 171 | fileno = self.fileno 172 | size = self.size 173 | if not size: 174 | return 175 | if IS_WINDOWS: 176 | log_mmap = mmap.mmap(fileno, size, access=mmap.ACCESS_READ) 177 | else: 178 | log_mmap = mmap.mmap(fileno, size, prot=mmap.PROT_READ) 179 | try: 180 | rfind = log_mmap.rfind 181 | position = size 182 | batch: list[int] = [] 183 | append = batch.append 184 | get_length = batch.__len__ 185 | monotonic = time.monotonic 186 | break_time = monotonic() 187 | 188 | if log_mmap[-1] != "\n": 189 | batch.append(position) 190 | 191 | while (position := rfind(b"\n", 0, position)) != -1: 192 | append(position) 193 | if get_length() % 1000 == 0 and monotonic() - break_time > batch_time: 194 | break_time = monotonic() 195 | yield (position, batch) 196 | batch = [] 197 | append = batch.append 198 | yield (0, batch) 199 | finally: 200 | log_mmap.close() 201 | 202 | def scan_timestamps( 203 | self, batch_time: float = 0.25 204 | ) -> Iterable[list[tuple[int, int, float]]]: 205 | size = self.size 206 | if not size: 207 | return 208 | fileno = self.fileno 209 | if IS_WINDOWS: 210 | log_mmap = mmap.mmap(fileno, size, access=mmap.ACCESS_READ) 211 | else: 212 | log_mmap = mmap.mmap(fileno, size, prot=mmap.PROT_READ) 213 | 214 | monotonic = time.monotonic 215 | scan_time = monotonic() 216 | scan = self.timestamp_scanner.scan 217 | line_no = 0 218 | position = 0 219 | results: list[tuple[int, int, float]] = [] 220 | append = results.append 221 | get_length = results.__len__ 222 | while line_bytes := log_mmap.readline(): 223 | line = line_bytes.decode("utf-8", errors="replace") 224 | timestamp = scan(line) 225 | position += len(line_bytes) 226 | append((line_no, position, timestamp.timestamp() if timestamp else 0.0)) 227 | line_no += 1 228 | if ( 229 | results 230 | and get_length() % 1000 == 0 231 | and monotonic() - scan_time > batch_time 232 | ): 233 | scan_time = monotonic() 234 | yield results 235 | results = [] 236 | append = results.append 237 | if results: 238 | yield results 239 | -------------------------------------------------------------------------------- /src/toolong/log_lines.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from queue import Empty, Queue 5 | from operator import itemgetter 6 | import platform 7 | from threading import Event, RLock, Thread 8 | 9 | from textual.message import Message 10 | from textual.suggester import Suggester 11 | from toolong.scan_progress_bar import ScanProgressBar 12 | from toolong.find_dialog import FindDialog 13 | from toolong.log_file import LogFile 14 | from toolong.messages import ( 15 | DismissOverlay, 16 | FileError, 17 | NewBreaks, 18 | PendingLines, 19 | PointerMoved, 20 | ScanComplete, 21 | ScanProgress, 22 | TailFile, 23 | ) 24 | from toolong.watcher import WatcherBase 25 | 26 | 27 | from rich.segment import Segment 28 | from rich.style import Style 29 | from rich.text import Text 30 | from textual import events, on, scrollbar, work 31 | from textual.app import ComposeResult 32 | from textual.binding import Binding 33 | from textual.cache import LRUCache 34 | from textual.geometry import Region, Size, clamp 35 | from textual.reactive import reactive 36 | from textual.scroll_view import ScrollView 37 | from textual.strip import Strip 38 | from textual.worker import Worker, get_current_worker 39 | 40 | 41 | import mmap 42 | import re 43 | import time 44 | from datetime import datetime, timedelta 45 | from typing import Iterable, Literal, Mapping 46 | 47 | SPLIT_REGEX = r"[\s/\[\]\(\)\"\/]" 48 | 49 | MAX_LINE_LENGTH = 1000 50 | 51 | 52 | @dataclass 53 | class LineRead(Message): 54 | """A line has been read from the file.""" 55 | 56 | index: int 57 | log_file: LogFile 58 | start: int 59 | end: int 60 | line: str 61 | 62 | 63 | class LineReader(Thread): 64 | """A thread which read lines from log files. 65 | 66 | This allows lines to be loaded lazily, i.e. without blocking. 67 | 68 | """ 69 | 70 | def __init__(self, log_lines: LogLines) -> None: 71 | self.log_lines = log_lines 72 | self.queue: Queue[tuple[LogFile | None, int, int, int]] = Queue(maxsize=1000) 73 | self.exit_event = Event() 74 | self.pending: set[tuple[LogFile | None, int, int, int]] = set() 75 | super().__init__() 76 | 77 | def request_line(self, log_file: LogFile, index: int, start: int, end: int) -> None: 78 | request = (log_file, index, start, end) 79 | if request not in self.pending: 80 | self.pending.add(request) 81 | self.queue.put(request) 82 | 83 | def stop(self) -> None: 84 | """Stop the thread and join.""" 85 | self.exit_event.set() 86 | self.queue.put((None, -1, 0, 0)) 87 | self.join() 88 | 89 | def run(self) -> None: 90 | log_lines = self.log_lines 91 | while not self.exit_event.is_set(): 92 | try: 93 | request = self.queue.get(timeout=0.2) 94 | except Empty: 95 | continue 96 | else: 97 | self.pending.discard(request) 98 | log_file, index, start, end = request 99 | self.queue.task_done() 100 | if self.exit_event.is_set() or log_file is None: 101 | break 102 | log_lines.post_message( 103 | LineRead( 104 | index, 105 | log_file, 106 | start, 107 | end, 108 | log_file.get_line(start, end), 109 | ) 110 | ) 111 | 112 | 113 | class SearchSuggester(Suggester): 114 | def __init__(self, search_index: Mapping[str, str]) -> None: 115 | self.search_index = search_index 116 | super().__init__(use_cache=False, case_sensitive=True) 117 | 118 | async def get_suggestion(self, value: str) -> str | None: 119 | word = re.split(SPLIT_REGEX, value)[-1] 120 | start = value[: -len(word)] 121 | 122 | if not word: 123 | return None 124 | search_hit = self.search_index.get(word.lower(), None) 125 | if search_hit is None: 126 | return None 127 | return start + search_hit 128 | 129 | 130 | class LogLines(ScrollView, inherit_bindings=False): 131 | BINDINGS = [ 132 | Binding("up,w,k", "scroll_up", "Scroll Up", show=False), 133 | Binding("down,s,j", "scroll_down", "Scroll Down", show=False), 134 | Binding("left,h", "scroll_left", "Scroll Left", show=False), 135 | Binding("right,l", "scroll_right", "Scroll Right", show=False), 136 | Binding("home,G", "scroll_home", "Scroll Home", show=False), 137 | Binding("end,g", "scroll_end", "Scroll End", show=False), 138 | Binding("pageup,b", "page_up", "Page Up", show=False), 139 | Binding("pagedown,space", "page_down", "Page Down", show=False), 140 | Binding("enter", "select", "Select line", show=False), 141 | Binding("escape", "dismiss", "Dismiss", show=False, priority=True), 142 | Binding("m", "navigate(+1, 'm')"), 143 | Binding("M", "navigate(-1, 'm')"), 144 | Binding("o", "navigate(+1, 'h')"), 145 | Binding("O", "navigate(-1, 'h')"), 146 | Binding("d", "navigate(+1, 'd')"), 147 | Binding("D", "navigate(-1, 'd')"), 148 | ] 149 | 150 | DEFAULT_CSS = """ 151 | LogLines { 152 | scrollbar-gutter: stable; 153 | overflow: scroll; 154 | border: heavy transparent; 155 | .loglines--filter-highlight { 156 | background: $secondary; 157 | color: auto; 158 | } 159 | .loglines--pointer-highlight { 160 | background: $primary; 161 | } 162 | &:focus { 163 | border: heavy $accent; 164 | } 165 | 166 | border-subtitle-color: $success; 167 | border-subtitle-align: center; 168 | align: center middle; 169 | 170 | &.-scanning { 171 | tint: $background 30%; 172 | } 173 | .loglines--line-numbers { 174 | color: $warning 70%; 175 | } 176 | .loglines--line-numbers-active { 177 | color: $warning; 178 | text-style: bold; 179 | } 180 | } 181 | """ 182 | COMPONENT_CLASSES = { 183 | "loglines--filter-highlight", 184 | "loglines--pointer-highlight", 185 | "loglines--line-numbers", 186 | "loglines--line-numbers-active", 187 | } 188 | 189 | show_find = reactive(False) 190 | find = reactive("") 191 | case_sensitive = reactive(False) 192 | regex = reactive(False) 193 | show_gutter = reactive(False) 194 | pointer_line: reactive[int | None] = reactive(None, repaint=False) 195 | is_scrolling: reactive[int] = reactive(int) 196 | pending_lines: reactive[int] = reactive(int) 197 | tail: reactive[bool] = reactive(True) 198 | can_tail: reactive[bool] = reactive(True) 199 | show_line_numbers: reactive[bool] = reactive(False) 200 | 201 | def __init__(self, watcher: WatcherBase, file_paths: list[str]) -> None: 202 | super().__init__() 203 | self.watcher = watcher 204 | self.file_paths = file_paths 205 | self.log_files = [LogFile(path) for path in file_paths] 206 | self._render_line_cache: LRUCache[ 207 | tuple[LogFile, int, int, bool, str], Strip 208 | ] = LRUCache(maxsize=1000) 209 | self._max_width = 0 210 | self._search_index: LRUCache[str, str] = LRUCache(maxsize=10000) 211 | self._suggester = SearchSuggester(self._search_index) 212 | self.icons: dict[int, str] = {} 213 | self._line_breaks: dict[LogFile, list[int]] = {} 214 | self._line_cache: LRUCache[tuple[LogFile, int, int], str] = LRUCache(10000) 215 | self._text_cache: LRUCache[ 216 | tuple[LogFile, int, int, bool], tuple[str, Text, datetime | None] 217 | ] = LRUCache(1000) 218 | self.initial_scan_worker: Worker | None = None 219 | self._line_count = 0 220 | self._scanned_size = 0 221 | self._scan_start = 0 222 | self._gutter_width = 0 223 | self._line_reader = LineReader(self) 224 | self._merge_lines: list[tuple[float, int, LogFile]] | None = None 225 | self._lock = RLock() 226 | 227 | @property 228 | def log_file(self) -> LogFile: 229 | return self.log_files[0] 230 | 231 | @property 232 | def line_count(self) -> int: 233 | with self._lock: 234 | if self._merge_lines is not None: 235 | return len(self._merge_lines) 236 | return self._line_count 237 | 238 | @property 239 | def gutter_width(self) -> int: 240 | return self._gutter_width 241 | 242 | @property 243 | def focusable(self) -> bool: 244 | """Can this widget currently be focused?""" 245 | return self.can_focus and self.visible and not self._self_or_ancestors_disabled 246 | 247 | def compose(self) -> ComposeResult: 248 | yield ScanProgressBar() 249 | 250 | def clear_caches(self) -> None: 251 | self._line_cache.clear() 252 | self._text_cache.clear() 253 | 254 | def notify_style_update(self) -> None: 255 | self.clear_caches() 256 | 257 | def validate_pointer_line(self, pointer_line: int | None) -> int | None: 258 | if pointer_line is None: 259 | return None 260 | if pointer_line < 0: 261 | return 0 262 | if pointer_line >= self.line_count: 263 | return self.line_count - 1 264 | return pointer_line 265 | 266 | def on_mount(self) -> None: 267 | self.loading = True 268 | self.add_class("-scanning") 269 | self._line_reader.start() 270 | self.initial_scan_worker = self.run_scan(self.app.save_merge) 271 | 272 | def start_tail(self) -> None: 273 | def size_changed(size: int, breaks: list[int]) -> None: 274 | """Callback when the file changes size.""" 275 | with self._lock: 276 | for offset, _ in enumerate(breaks, 1): 277 | self.get_line_from_index(self.line_count - offset) 278 | self.post_message(NewBreaks(self.log_file, breaks, size, tail=True)) 279 | if self.message_queue_size > 10: 280 | while self.message_queue_size > 2: 281 | time.sleep(0.1) 282 | 283 | def watch_error(error: Exception) -> None: 284 | """Callback when there is an error watching the file.""" 285 | self.post_message(FileError(error)) 286 | 287 | self.watcher.add( 288 | self.log_file, 289 | size_changed, 290 | watch_error, 291 | ) 292 | 293 | @work(thread=True) 294 | def run_scan(self, save_merge: str | None = None) -> None: 295 | worker = get_current_worker() 296 | 297 | if len(self.log_files) > 1: 298 | self.merge_log_files() 299 | if save_merge is not None: 300 | self.call_later(self.save, save_merge, self.line_count) 301 | return 302 | 303 | try: 304 | if not self.log_file.open(worker.cancelled_event): 305 | self.loading = False 306 | return 307 | except FileNotFoundError: 308 | self.notify( 309 | f"File {self.log_file.path.name!r} not found.", severity="error" 310 | ) 311 | self.loading = False 312 | return 313 | except Exception as error: 314 | self.notify( 315 | f"Failed to open {self.log_file.path.name!r}; {error}", severity="error" 316 | ) 317 | self.loading = False 318 | return 319 | 320 | size = self.log_file.size 321 | 322 | if not size: 323 | self.post_message(ScanComplete(0, 0)) 324 | return 325 | 326 | position = size 327 | line_count = 0 328 | 329 | for position, breaks in self.log_file.scan_line_breaks(): 330 | line_count_thousands = line_count // 1000 331 | message = f"Scanning… ({line_count_thousands:,}K lines)- ESCAPE to cancel" 332 | 333 | self.post_message(ScanProgress(message, 1 - (position / size), position)) 334 | if breaks: 335 | self.post_message(NewBreaks(self.log_file, breaks)) 336 | line_count += len(breaks) 337 | if worker.is_cancelled: 338 | break 339 | self.post_message(ScanComplete(size, position)) 340 | 341 | def merge_log_files(self) -> None: 342 | worker = get_current_worker() 343 | self._merge_lines = [] 344 | merge_lines = self._merge_lines 345 | 346 | for log_file in self.log_files: 347 | try: 348 | log_file.open(worker.cancelled_event) 349 | except Exception as error: 350 | self.notify( 351 | f"Failed to open {log_file.name!r}; {error}", severity="error" 352 | ) 353 | else: 354 | self._line_breaks[log_file] = [] 355 | 356 | self.loading = False 357 | 358 | total_size = sum(log_file.size for log_file in self.log_files) 359 | position = 0 360 | 361 | for log_file in self.log_files: 362 | if not log_file.is_open: 363 | continue 364 | line_breaks = self._line_breaks[log_file] 365 | append = line_breaks.append 366 | meta: list[tuple[float, int, LogFile]] = [] 367 | append_meta = meta.append 368 | for timestamps in log_file.scan_timestamps(): 369 | break_position = 0 370 | 371 | for line_no, break_position, timestamp in timestamps: 372 | append_meta((timestamp, line_no, log_file)) 373 | append(break_position) 374 | append(log_file.size) 375 | 376 | self.post_message( 377 | ScanProgress( 378 | f"Merging {log_file.name} - ESCAPE to cancel", 379 | (position + break_position) / total_size, 380 | ) 381 | ) 382 | if worker.is_cancelled: 383 | self.post_message( 384 | ScanComplete(total_size, position + break_position) 385 | ) 386 | return 387 | 388 | # Header may be missing timestamp, so we will attempt to back fill timestamps 389 | seconds = 0.0 390 | for offset, (seconds, line_no, log_file) in enumerate(meta): 391 | if seconds: 392 | for index, (_seconds, line_no, log_file) in zip( 393 | range(offset), meta 394 | ): 395 | meta[index] = (seconds, line_no, log_file) 396 | break 397 | if offset > 10: 398 | # May be pointless to scan the entire thing 399 | break 400 | self._merge_lines.extend(meta) 401 | 402 | position += log_file.size 403 | 404 | merge_lines.sort(key=itemgetter(0, 1)) 405 | 406 | self.post_message(ScanComplete(total_size, total_size)) 407 | 408 | @classmethod 409 | def _scan_file( 410 | cls, fileno: int, size: int, batch_time: float = 0.25 411 | ) -> Iterable[tuple[int, list[int]]]: 412 | """Find line breaks in a file. 413 | 414 | Yields lists of offsets. 415 | """ 416 | if platform.system() == "Windows": 417 | log_mmap = mmap.mmap(fileno, size, access=mmap.ACCESS_READ) 418 | else: 419 | log_mmap = mmap.mmap(fileno, size, prot=mmap.PROT_READ) 420 | rfind = log_mmap.rfind 421 | position = size 422 | batch: list[int] = [] 423 | append = batch.append 424 | get_length = batch.__len__ 425 | monotonic = time.monotonic 426 | break_time = monotonic() 427 | 428 | while (position := rfind(b"\n", 0, position)) != -1: 429 | append(position) 430 | if get_length() % 1000 == 0 and monotonic() - break_time > batch_time: 431 | yield (position, batch) 432 | batch = [] 433 | yield (0, batch) 434 | 435 | @work(thread=True) 436 | def save(self, path: str, line_count: int) -> None: 437 | """Save visible lines (used to export merged lines). 438 | 439 | Args: 440 | path: Path to save to. 441 | line_count: Number of lines to save. 442 | """ 443 | try: 444 | with open(path, "w") as file_out: 445 | for line_no in range(line_count): 446 | line = self.get_line_from_index_blocking(line_no) 447 | if line: 448 | file_out.write(f"{line}\n") 449 | except Exception as error: 450 | self.notify(f"Failed to save {path!r}; {error}", severity="error") 451 | else: 452 | self.notify(f"Saved merged log files to {path!r}") 453 | 454 | def get_log_file_from_index(self, index: int) -> tuple[LogFile, int]: 455 | if self._merge_lines is not None: 456 | try: 457 | _, index, log_file = self._merge_lines[index] 458 | except IndexError: 459 | return self.log_files[0], index 460 | return log_file, index 461 | return self.log_files[0], index 462 | 463 | def index_to_span(self, index: int) -> tuple[LogFile, int, int]: 464 | log_file, index = self.get_log_file_from_index(index) 465 | line_breaks = self._line_breaks.setdefault(log_file, []) 466 | scan_start = 0 if self._merge_lines else self._scan_start 467 | if not line_breaks: 468 | return (log_file, scan_start, self._scan_start) 469 | index = clamp(index, 0, len(line_breaks)) 470 | if index == 0: 471 | return (log_file, scan_start, line_breaks[0]) 472 | start = line_breaks[index - 1] 473 | end = ( 474 | line_breaks[index] 475 | if index < len(line_breaks) 476 | else max(0, self._scanned_size - 1) 477 | ) 478 | return (log_file, start, end) 479 | 480 | def get_line_from_index_blocking(self, index: int) -> str | None: 481 | with self._lock: 482 | log_file, start, end = self.index_to_span(index) 483 | return log_file.get_line(start, end) 484 | 485 | def get_line_from_index(self, index: int) -> str | None: 486 | with self._lock: 487 | log_file, start, end = self.index_to_span(index) 488 | return self.get_line(log_file, index, start, end) 489 | 490 | def _get_line(self, log_file: LogFile, start: int, end: int) -> str: 491 | return log_file.get_line(start, end) 492 | 493 | def get_line( 494 | self, log_file: LogFile, index: int, start: int, end: int 495 | ) -> str | None: 496 | cache_key = (log_file, start, end) 497 | with self._lock: 498 | try: 499 | line = self._line_cache[cache_key] 500 | except KeyError: 501 | self._line_reader.request_line(log_file, index, start, end) 502 | return None 503 | return line 504 | 505 | def get_line_blocking( 506 | self, log_file: LogFile, index: int, start: int, end: int 507 | ) -> str: 508 | with self._lock: 509 | cache_key = (log_file, start, end) 510 | try: 511 | line = self._line_cache[cache_key] 512 | except KeyError: 513 | line = self._get_line(log_file, start, end) 514 | self._line_cache[cache_key] = line 515 | return line 516 | 517 | def get_text( 518 | self, 519 | line_index: int, 520 | abbreviate: bool = False, 521 | block: bool = False, 522 | max_line_length=MAX_LINE_LENGTH, 523 | ) -> tuple[str, Text, datetime | None]: 524 | log_file, start, end = self.index_to_span(line_index) 525 | cache_key = (log_file, start, end, abbreviate) 526 | try: 527 | line, text, timestamp = self._text_cache[cache_key] 528 | except KeyError: 529 | new_line: str | None 530 | if block: 531 | new_line = self.get_line_blocking(log_file, line_index, start, end) 532 | else: 533 | new_line = self.get_line(log_file, line_index, start, end) 534 | if new_line is None: 535 | return "", Text(""), None 536 | line = new_line 537 | timestamp, line, text = log_file.parse(line) 538 | if abbreviate and len(text) > max_line_length: 539 | text = text[:max_line_length] + "…" 540 | self._text_cache[cache_key] = (line, text, timestamp) 541 | return line, text.copy(), timestamp 542 | 543 | def get_timestamp(self, line_index: int) -> datetime | None: 544 | """Get a timestamp for the given line, or `None` if no timestamp detected. 545 | 546 | Args: 547 | line_index: Index of line. 548 | 549 | Returns: 550 | A datetime or `None`. 551 | """ 552 | log_file, start, end = self.index_to_span(line_index) 553 | line = log_file.get_line(start, end) 554 | timestamp = log_file.timestamp_scanner.scan(line) 555 | return timestamp 556 | 557 | def on_unmount(self) -> None: 558 | self._line_reader.stop() 559 | self.log_file.close() 560 | 561 | def on_idle(self) -> None: 562 | self.update_virtual_size() 563 | 564 | def update_virtual_size(self) -> None: 565 | self.virtual_size = Size( 566 | self._max_width 567 | + (self.gutter_width if self.show_gutter or self.show_line_numbers else 0), 568 | self.line_count, 569 | ) 570 | 571 | def render_lines(self, crop: Region) -> list[Strip]: 572 | self.update_virtual_size() 573 | 574 | page_height = self.scrollable_content_region.height 575 | scroll_y = self.scroll_offset.y 576 | line_count = self.line_count 577 | index_to_span = self.index_to_span 578 | for index in range( 579 | max(0, scroll_y - page_height), 580 | min(line_count, scroll_y + page_height + page_height), 581 | ): 582 | log_file_span = index_to_span(index) 583 | if log_file_span not in self._line_cache: 584 | log_file, *span = log_file_span 585 | self._line_reader.request_line(log_file, index, *span) 586 | if self.show_line_numbers: 587 | max_line_no = self.scroll_offset.y + page_height 588 | self._gutter_width = len(f"{max_line_no+1} ") 589 | else: 590 | self._gutter_width = 0 591 | if self.pointer_line is not None: 592 | self._gutter_width += 3 593 | 594 | return super().render_lines(crop) 595 | 596 | def render_line(self, y: int) -> Strip: 597 | scroll_x, scroll_y = self.scroll_offset 598 | index = y + scroll_y 599 | style = self.rich_style 600 | width, height = self.size 601 | if index >= self.line_count: 602 | return Strip.blank(width, style) 603 | 604 | log_file_span = self.index_to_span(index) 605 | 606 | is_pointer = self.pointer_line is not None and index == self.pointer_line 607 | cache_key = (*log_file_span, is_pointer, self.find) 608 | 609 | try: 610 | strip = self._render_line_cache[cache_key] 611 | except KeyError: 612 | line, text, timestamp = self.get_text(index, abbreviate=True, block=True) 613 | text.stylize_before(style) 614 | 615 | if is_pointer: 616 | pointer_style = self.get_component_rich_style( 617 | "loglines--pointer-highlight" 618 | ) 619 | text.stylize(Style(bgcolor=pointer_style.bgcolor, bold=True)) 620 | 621 | search_index = self._search_index 622 | 623 | for word in re.split(SPLIT_REGEX, text.plain): 624 | if len(word) <= 1: 625 | continue 626 | for offset in range(1, len(word) - 1): 627 | sub_word = word[:offset] 628 | if sub_word in search_index: 629 | if len(search_index[sub_word]) < len(word): 630 | search_index[sub_word.lower()] = word 631 | else: 632 | search_index[sub_word.lower()] = word 633 | 634 | if self.find and self.show_find: 635 | self.highlight_find(text) 636 | strip = Strip(text.render(self.app.console), text.cell_len) 637 | self._max_width = max(self._max_width, strip.cell_length) 638 | self._render_line_cache[cache_key] = strip 639 | 640 | if is_pointer: 641 | pointer_style = self.get_component_rich_style("loglines--pointer-highlight") 642 | strip = strip.crop_extend(scroll_x, scroll_x + width, pointer_style) 643 | else: 644 | strip = strip.crop_extend(scroll_x, scroll_x + width, None) 645 | 646 | if self.show_gutter or self.show_line_numbers: 647 | line_number_style = self.get_component_rich_style( 648 | "loglines--line-numbers-active" 649 | if index == self.pointer_line 650 | else "loglines--line-numbers" 651 | ) 652 | if self.pointer_line is not None and index == self.pointer_line: 653 | icon = "👉" 654 | else: 655 | icon = self.icons.get(index, " ") 656 | 657 | if self.show_line_numbers: 658 | segments = [Segment(f"{index+1} ", line_number_style), Segment(icon)] 659 | else: 660 | segments = [Segment(icon)] 661 | icon_strip = Strip(segments) 662 | icon_strip = icon_strip.adjust_cell_length(self._gutter_width) 663 | strip = Strip.join([icon_strip, strip]) 664 | 665 | return strip 666 | 667 | def highlight_find(self, text: Text) -> None: 668 | filter_style = self.get_component_rich_style("loglines--filter-highlight") 669 | if self.regex: 670 | try: 671 | re.compile(self.find) 672 | except Exception: 673 | # Invalid regex 674 | return 675 | matches = list( 676 | re.finditer( 677 | self.find, 678 | text.plain, 679 | flags=0 if self.case_sensitive else re.IGNORECASE, 680 | ) 681 | ) 682 | if matches: 683 | for match in matches: 684 | text.stylize(filter_style, *match.span()) 685 | else: 686 | text.stylize("dim") 687 | else: 688 | if not text.highlight_words( 689 | [self.find], filter_style, case_sensitive=self.case_sensitive 690 | ): 691 | text.stylize("dim") 692 | 693 | def check_match(self, line: str) -> bool: 694 | if not line: 695 | return True 696 | if self.regex: 697 | try: 698 | return ( 699 | re.match( 700 | self.find, 701 | line, 702 | flags=0 if self.case_sensitive else re.IGNORECASE, 703 | ) 704 | is not None 705 | ) 706 | except Exception: 707 | self.notify("Regex is invalid!", severity="error") 708 | return True 709 | else: 710 | if self.case_sensitive: 711 | return self.find in line 712 | else: 713 | return self.find.lower() in line.lower() 714 | 715 | def advance_search(self, direction: int = 1) -> None: 716 | first = self.pointer_line is None 717 | start_line = ( 718 | ( 719 | self.scroll_offset.y 720 | if direction == 1 721 | else self.scroll_offset.y + self.scrollable_content_region.height - 1 722 | ) 723 | if self.pointer_line is None 724 | else self.pointer_line + direction 725 | ) 726 | if direction == 1: 727 | line_range = range(start_line, self.line_count) 728 | else: 729 | line_range = range(start_line, -1, -1) 730 | 731 | scroll_y = self.scroll_offset.y 732 | max_scroll_y = scroll_y + self.scrollable_content_region.height - 1 733 | if self.show_find: 734 | check_match = self.check_match 735 | index_to_span = self.index_to_span 736 | with self._lock: 737 | for line_no in line_range: 738 | log_file, start, end = index_to_span(line_no) 739 | line = log_file.get_raw(start, end).decode( 740 | "utf-8", errors="replace" 741 | ) 742 | if check_match(line): 743 | self.pointer_line = line_no 744 | self.scroll_pointer_to_center() 745 | return 746 | self.app.bell() 747 | else: 748 | self.pointer_line = next( 749 | iter(line_range), self.pointer_line or self.scroll_offset.y 750 | ) 751 | if first: 752 | self.refresh() 753 | else: 754 | if self.pointer_line is not None and ( 755 | self.pointer_line < scroll_y or self.pointer_line > max_scroll_y 756 | ): 757 | self.scroll_pointer_to_center() 758 | 759 | def scroll_pointer_to_center(self, animate: bool = True): 760 | if self.pointer_line is None: 761 | return 762 | y_offset = self.pointer_line - self.scrollable_content_region.height // 2 763 | scroll_distance = abs(y_offset - self.scroll_offset.y) 764 | self.scroll_to( 765 | y=y_offset, 766 | animate=animate and 100 > scroll_distance > 1, 767 | duration=0.2, 768 | ) 769 | 770 | def watch_show_find(self, show_find: bool) -> None: 771 | self.clear_caches() 772 | if not show_find: 773 | self.pointer_line = None 774 | 775 | def watch_find(self, find: str) -> None: 776 | if not find: 777 | self.pointer_line = None 778 | 779 | def watch_case_sensitive(self) -> None: 780 | self.clear_caches() 781 | 782 | def watch_regex(self) -> None: 783 | self.clear_caches() 784 | 785 | def watch_pointer_line( 786 | self, old_pointer_line: int | None, pointer_line: int | None 787 | ) -> None: 788 | if old_pointer_line is not None: 789 | self.refresh_line(old_pointer_line) 790 | if pointer_line is not None: 791 | self.refresh_line(pointer_line) 792 | self.show_gutter = pointer_line is not None 793 | self.post_message(PointerMoved(pointer_line)) 794 | 795 | def action_scroll_up(self) -> None: 796 | if self.pointer_line is None: 797 | super().action_scroll_up() 798 | else: 799 | self.advance_search(-1) 800 | self.post_message(TailFile(False)) 801 | 802 | def action_scroll_down(self) -> None: 803 | if self.pointer_line is None: 804 | super().action_scroll_down() 805 | else: 806 | self.advance_search(+1) 807 | 808 | def action_scroll_home(self) -> None: 809 | if self.pointer_line is not None: 810 | self.pointer_line = 0 811 | self.scroll_to(y=0, duration=0) 812 | self.post_message(TailFile(False)) 813 | 814 | def action_scroll_end(self) -> None: 815 | if self.pointer_line is not None: 816 | self.pointer_line = self.line_count 817 | if self.scroll_offset.y == self.max_scroll_y: 818 | self.post_message(TailFile(True)) 819 | else: 820 | self.scroll_to(y=self.max_scroll_y, duration=0) 821 | self.post_message(TailFile(False)) 822 | 823 | def action_page_down(self) -> None: 824 | if self.pointer_line is None: 825 | super().action_page_down() 826 | else: 827 | self.pointer_line = ( 828 | self.pointer_line + self.scrollable_content_region.height 829 | ) 830 | self.scroll_pointer_to_center() 831 | self.post_message(TailFile(False)) 832 | 833 | def action_page_up(self) -> None: 834 | if self.pointer_line is None: 835 | super().action_page_up() 836 | else: 837 | self.pointer_line = ( 838 | self.pointer_line - self.scrollable_content_region.height 839 | ) 840 | self.scroll_pointer_to_center() 841 | self.post_message(TailFile(False)) 842 | 843 | def on_click(self, event: events.Click) -> None: 844 | if self.loading: 845 | return 846 | new_pointer_line = event.y + self.scroll_offset.y - self.gutter.top 847 | if new_pointer_line == self.pointer_line: 848 | self.post_message(FindDialog.SelectLine()) 849 | self.pointer_line = new_pointer_line 850 | self.post_message(TailFile(False)) 851 | 852 | def action_select(self): 853 | if self.pointer_line is None: 854 | self.pointer_line = self.scroll_offset.y 855 | else: 856 | self.post_message(FindDialog.SelectLine()) 857 | 858 | def action_dismiss(self): 859 | if self.initial_scan_worker is not None and self.initial_scan_worker.is_running: 860 | self.initial_scan_worker.cancel() 861 | self.notify( 862 | "Stopped scanning. Some lines may not be available.", severity="warning" 863 | ) 864 | else: 865 | self.post_message(DismissOverlay()) 866 | 867 | # @work(thread=True) 868 | def action_navigate(self, steps: int, unit: Literal["m", "h", "d"]) -> None: 869 | initial_line_no = line_no = ( 870 | self.scroll_offset.y if self.pointer_line is None else self.pointer_line 871 | ) 872 | 873 | count = 0 874 | # If the current line doesn't have a timestamp, try to find the next one 875 | while (timestamp := self.get_timestamp(line_no)) is None: 876 | line_no += 1 877 | count += 1 878 | if count >= self.line_count or count > 10: 879 | self.app.bell() 880 | return 881 | 882 | direction = +1 if steps > 0 else -1 883 | line_no += direction 884 | 885 | if unit == "m": 886 | target_timestamp = timestamp + timedelta(minutes=steps) 887 | elif unit == "h": 888 | target_timestamp = timestamp + timedelta(hours=steps) 889 | elif unit == "d": 890 | target_timestamp = timestamp + timedelta(hours=steps * 24) 891 | 892 | if direction == +1: 893 | line_count = self.line_count 894 | while line_no < line_count: 895 | timestamp = self.get_timestamp(line_no) 896 | if timestamp is not None and timestamp >= target_timestamp: 897 | break 898 | line_no += 1 899 | else: 900 | while line_no > 0: 901 | timestamp = self.get_timestamp(line_no) 902 | if timestamp is not None and timestamp <= target_timestamp: 903 | break 904 | line_no -= 1 905 | 906 | self.pointer_line = line_no 907 | self.scroll_pointer_to_center(animate=abs(initial_line_no - line_no) < 100) 908 | 909 | def watch_tail(self, tail: bool) -> None: 910 | self.set_class(tail, "-tail") 911 | if tail: 912 | self.update_line_count() 913 | self.scroll_to(y=self.max_scroll_y, animate=False) 914 | if tail: 915 | self.pointer_line = None 916 | 917 | def update_line_count(self) -> None: 918 | line_count = len(self._line_breaks.get(self.log_file, [])) 919 | line_count = max(1, line_count) 920 | self._line_count = line_count 921 | 922 | @on(NewBreaks) 923 | def on_new_breaks(self, event: NewBreaks) -> None: 924 | line_breaks = self._line_breaks.setdefault(event.log_file, []) 925 | first = not line_breaks 926 | event.stop() 927 | self._scanned_size = max(self._scanned_size, event.scanned_size) 928 | 929 | if not self.tail and event.tail: 930 | self.post_message(PendingLines(len(line_breaks) - self._line_count + 1)) 931 | 932 | line_breaks.extend(event.breaks) 933 | if not event.tail: 934 | line_breaks.sort() 935 | 936 | pointer_distance_from_end = ( 937 | None 938 | if self.pointer_line is None 939 | else self.virtual_size.height - self.pointer_line 940 | ) 941 | self.loading = False 942 | 943 | if not event.tail or self.tail or first: 944 | self.update_line_count() 945 | 946 | if self.tail: 947 | if self.pointer_line is not None and pointer_distance_from_end is not None: 948 | self.pointer_line = self.virtual_size.height - pointer_distance_from_end 949 | self.update_virtual_size() 950 | self.scroll_to(y=self.max_scroll_y, animate=False, force=True) 951 | 952 | def watch_scroll_y(self, old_value: float, new_value: float) -> None: 953 | self.post_message(PointerMoved(self.pointer_line)) 954 | super().watch_scroll_y(old_value, new_value) 955 | 956 | @on(scrollbar.ScrollTo) 957 | def on_scroll_to(self, event: scrollbar.ScrollTo) -> None: 958 | # Stop tail when scrolling in the Y direction only 959 | if event.y: 960 | self.post_message(TailFile(False)) 961 | 962 | @on(scrollbar.ScrollUp) 963 | @on(scrollbar.ScrollDown) 964 | @on(events.MouseScrollDown) 965 | @on(events.MouseScrollUp) 966 | def on_scroll(self, event: events.Event) -> None: 967 | self.post_message(TailFile(False)) 968 | 969 | @on(ScanComplete) 970 | def on_scan_complete(self, event: ScanComplete) -> None: 971 | self._scanned_size = max(self._scanned_size, event.size) 972 | self._scan_start = event.scan_start 973 | self.update_line_count() 974 | self.refresh() 975 | if len(self.log_files) == 1 and self.can_tail: 976 | self.start_tail() 977 | 978 | @on(ScanProgress) 979 | def on_scan_progress(self, event: ScanProgress): 980 | if event.scan_start is not None: 981 | self._scan_start = event.scan_start 982 | 983 | @on(LineRead) 984 | def on_line_read(self, event: LineRead) -> None: 985 | event.stop() 986 | start = event.start 987 | end = event.end 988 | log_file = event.log_file 989 | self._render_line_cache.discard((log_file, start, end, True, self.find)) 990 | self._render_line_cache.discard((log_file, start, end, False, self.find)) 991 | self._line_cache[(log_file, start, end)] = event.line 992 | self._text_cache.discard((log_file, start, end, False)) 993 | self._text_cache.discard((log_file, start, end, True)) 994 | self.refresh_lines(event.index, 1) 995 | -------------------------------------------------------------------------------- /src/toolong/log_view.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from asyncio import Lock 4 | from datetime import datetime 5 | 6 | from textual import on 7 | from textual.app import ComposeResult 8 | from textual.binding import Binding 9 | from textual.containers import Horizontal 10 | from textual.dom import NoScreen 11 | from textual import events 12 | from textual.reactive import reactive 13 | from textual.widget import Widget 14 | from textual.widgets import Label 15 | 16 | 17 | from toolong.scan_progress_bar import ScanProgressBar 18 | 19 | from toolong.messages import ( 20 | DismissOverlay, 21 | Goto, 22 | PendingLines, 23 | PointerMoved, 24 | ScanComplete, 25 | ScanProgress, 26 | TailFile, 27 | ) 28 | from toolong.find_dialog import FindDialog 29 | from toolong.line_panel import LinePanel 30 | from toolong.watcher import WatcherBase 31 | from toolong.log_lines import LogLines 32 | 33 | 34 | SPLIT_REGEX = r"[\s/\[\]]" 35 | 36 | MAX_DETAIL_LINE_LENGTH = 100_000 37 | 38 | 39 | class InfoOverlay(Widget): 40 | """Displays text under the lines widget when there are new lines.""" 41 | 42 | DEFAULT_CSS = """ 43 | InfoOverlay { 44 | display: none; 45 | dock: bottom; 46 | layer: overlay; 47 | width: 1fr; 48 | visibility: hidden; 49 | offset-y: -1; 50 | text-style: bold; 51 | } 52 | 53 | InfoOverlay Horizontal { 54 | width: 1fr; 55 | align: center bottom; 56 | } 57 | 58 | InfoOverlay Label { 59 | visibility: visible; 60 | width: auto; 61 | height: 1; 62 | background: $panel; 63 | color: $success; 64 | padding: 0 1; 65 | 66 | &:hover { 67 | background: $success; 68 | color: auto 90%; 69 | text-style: bold; 70 | } 71 | } 72 | """ 73 | 74 | message = reactive("") 75 | tail = reactive(False) 76 | 77 | def compose(self) -> ComposeResult: 78 | self.tooltip = "Click to tail file" 79 | with Horizontal(): 80 | yield Label("") 81 | 82 | def watch_message(self, message: str) -> None: 83 | self.display = bool(message.strip()) 84 | self.query_one(Label).update(message) 85 | 86 | def watch_tail(self, tail: bool) -> None: 87 | if not tail: 88 | self.message = "" 89 | self.display = bool(self.message.strip() and not tail) 90 | 91 | def on_click(self) -> None: 92 | self.post_message(TailFile()) 93 | 94 | 95 | class FooterKey(Label): 96 | """Displays a clickable label for a key.""" 97 | 98 | DEFAULT_CSS = """ 99 | FooterKey { 100 | color: $success; 101 | &:light { 102 | color: $primary; 103 | } 104 | padding: 0 1 0 0; 105 | &:hover { 106 | text-style: bold underline; 107 | } 108 | } 109 | """ 110 | DEFAULT_CLASSES = "key" 111 | 112 | def __init__(self, key: str, key_display: str, description: str) -> None: 113 | self.key = key 114 | self.key_display = key_display 115 | self.description = description 116 | super().__init__() 117 | 118 | def render(self) -> str: 119 | return f"[reverse]{self.key_display}[/reverse] {self.description}" 120 | 121 | async def on_click(self) -> None: 122 | await self.app.check_bindings(self.key) 123 | 124 | 125 | class MetaLabel(Label): 126 | 127 | DEFAULT_CSS = """ 128 | MetaLabel { 129 | margin-left: 1; 130 | } 131 | MetaLabel:hover { 132 | text-style: underline; 133 | } 134 | """ 135 | 136 | def on_click(self) -> None: 137 | self.post_message(Goto()) 138 | 139 | 140 | class LogFooter(Widget): 141 | """Shows a footer with information about the file and keys.""" 142 | 143 | DEFAULT_CSS = """ 144 | LogFooter { 145 | layout: horizontal; 146 | height: 1; 147 | width: 1fr; 148 | dock: bottom; 149 | Horizontal { 150 | width: 1fr; 151 | height: 1; 152 | } 153 | 154 | .key { 155 | color: $warning; 156 | } 157 | 158 | .meta { 159 | width: auto; 160 | height: 1; 161 | color: $success; 162 | padding: 0 1 0 0; 163 | } 164 | 165 | .tail { 166 | padding: 0 1; 167 | margin: 0 1; 168 | background: $success 15%; 169 | color: $success; 170 | text-style: bold; 171 | display: none; 172 | &.on { 173 | display: block; 174 | } 175 | } 176 | } 177 | """ 178 | line_no: reactive[int | None] = reactive(None) 179 | filename: reactive[str] = reactive("") 180 | timestamp: reactive[datetime | None] = reactive(None) 181 | tail: reactive[bool] = reactive(False) 182 | can_tail: reactive[bool] = reactive(False) 183 | 184 | def __init__(self) -> None: 185 | self.lock = Lock() 186 | super().__init__() 187 | 188 | def compose(self) -> ComposeResult: 189 | with Horizontal(classes="key-container"): 190 | pass 191 | yield Label("TAIL", classes="tail") 192 | yield MetaLabel("", classes="meta") 193 | 194 | async def mount_keys(self) -> None: 195 | try: 196 | if self.screen != self.app.screen: 197 | return 198 | except NoScreen: 199 | pass 200 | async with self.lock: 201 | with self.app.batch_update(): 202 | key_container = self.query_one(".key-container") 203 | await key_container.query("*").remove() 204 | bindings = [ 205 | binding 206 | for (_, binding) in self.app.namespace_bindings.values() 207 | if binding.show 208 | ] 209 | 210 | await key_container.mount_all( 211 | [ 212 | FooterKey( 213 | binding.key, 214 | binding.key_display or binding.key, 215 | binding.description, 216 | ) 217 | for binding in bindings 218 | if binding.action != "toggle_tail" 219 | or (binding.action == "toggle_tail" and self.can_tail) 220 | ] 221 | ) 222 | 223 | async def on_mount(self): 224 | self.watch(self.screen, "focused", self.mount_keys) 225 | self.watch(self.screen, "stack_updates", self.mount_keys) 226 | self.call_after_refresh(self.mount_keys) 227 | 228 | def update_meta(self) -> None: 229 | meta: list[str] = [] 230 | if self.filename: 231 | meta.append(self.filename) 232 | if self.timestamp is not None: 233 | meta.append(f"{self.timestamp:%x %X}") 234 | if self.line_no is not None: 235 | meta.append(f"{self.line_no + 1}") 236 | 237 | meta_line = " • ".join(meta) 238 | self.query_one(".meta", Label).update(meta_line) 239 | 240 | def watch_tail(self, tail: bool) -> None: 241 | self.query(".tail").set_class(tail and self.can_tail, "on") 242 | 243 | async def watch_can_tail(self, can_tail: bool) -> None: 244 | await self.mount_keys() 245 | 246 | def watch_filename(self, filename: str) -> None: 247 | self.update_meta() 248 | 249 | def watch_line_no(self, line_no: int | None) -> None: 250 | self.update_meta() 251 | 252 | def watch_timestamp(self, timestamp: datetime | None) -> None: 253 | self.update_meta() 254 | 255 | 256 | class LogView(Horizontal): 257 | """Widget that contains log lines and associated widgets.""" 258 | 259 | DEFAULT_CSS = """ 260 | LogView { 261 | &.show-panel { 262 | LinePanel { 263 | display: block; 264 | } 265 | } 266 | LogLines { 267 | width: 1fr; 268 | } 269 | LinePanel { 270 | width: 50%; 271 | display: none; 272 | } 273 | } 274 | """ 275 | 276 | BINDINGS = [ 277 | Binding("ctrl+t", "toggle_tail", "Tail", key_display="^t"), 278 | Binding("ctrl+l", "toggle('show_line_numbers')", "Line nos.", key_display="^l"), 279 | Binding("ctrl+f", "show_find_dialog", "Find", key_display="^f"), 280 | Binding("slash", "show_find_dialog", "Find", key_display="^f", show=False), 281 | Binding("ctrl+g", "goto", "Go to", key_display="^g"), 282 | ] 283 | 284 | show_find: reactive[bool] = reactive(False) 285 | show_panel: reactive[bool] = reactive(False) 286 | show_line_numbers: reactive[bool] = reactive(False) 287 | tail: reactive[bool] = reactive(False) 288 | can_tail: reactive[bool] = reactive(True) 289 | 290 | def __init__( 291 | self, file_paths: list[str], watcher: WatcherBase, can_tail: bool = True 292 | ) -> None: 293 | self.file_paths = file_paths 294 | self.watcher = watcher 295 | super().__init__() 296 | self.can_tail = can_tail 297 | 298 | def compose(self) -> ComposeResult: 299 | yield ( 300 | log_lines := LogLines(self.watcher, self.file_paths).data_bind( 301 | LogView.tail, 302 | LogView.show_line_numbers, 303 | LogView.show_find, 304 | LogView.can_tail, 305 | ) 306 | ) 307 | yield LinePanel() 308 | yield FindDialog(log_lines._suggester) 309 | yield InfoOverlay().data_bind(LogView.tail) 310 | yield LogFooter().data_bind(LogView.tail, LogView.can_tail) 311 | 312 | @on(FindDialog.Update) 313 | def filter_dialog_update(self, event: FindDialog.Update) -> None: 314 | log_lines = self.query_one(LogLines) 315 | log_lines.find = event.find 316 | log_lines.regex = event.regex 317 | log_lines.case_sensitive = event.case_sensitive 318 | 319 | async def watch_show_find(self, show_find: bool) -> None: 320 | if not self.is_mounted: 321 | return 322 | filter_dialog = self.query_one(FindDialog) 323 | filter_dialog.set_class(show_find, "visible") 324 | if show_find: 325 | filter_dialog.focus_input() 326 | else: 327 | self.query_one(LogLines).focus() 328 | 329 | async def watch_show_panel(self, show_panel: bool) -> None: 330 | self.set_class(show_panel, "show-panel") 331 | await self.update_panel() 332 | 333 | @on(FindDialog.Dismiss) 334 | def dismiss_filter_dialog(self, event: FindDialog.Dismiss) -> None: 335 | event.stop() 336 | self.show_find = False 337 | 338 | @on(FindDialog.MovePointer) 339 | def move_pointer(self, event: FindDialog.MovePointer) -> None: 340 | event.stop() 341 | log_lines = self.query_one(LogLines) 342 | log_lines.advance_search(event.direction) 343 | 344 | @on(FindDialog.SelectLine) 345 | def select_line(self) -> None: 346 | self.show_panel = not self.show_panel 347 | 348 | @on(DismissOverlay) 349 | def dismiss_overlay(self) -> None: 350 | if self.show_find: 351 | self.show_find = False 352 | elif self.show_panel: 353 | self.show_panel = False 354 | else: 355 | self.query_one(LogLines).pointer_line = None 356 | 357 | @on(TailFile) 358 | def on_tail_file(self, event: TailFile) -> None: 359 | self.tail = event.tail 360 | event.stop() 361 | 362 | async def update_panel(self) -> None: 363 | if not self.show_panel: 364 | return 365 | pointer_line = self.query_one(LogLines).pointer_line 366 | if pointer_line is not None: 367 | line, text, timestamp = self.query_one(LogLines).get_text( 368 | pointer_line, 369 | block=True, 370 | abbreviate=True, 371 | max_line_length=MAX_DETAIL_LINE_LENGTH, 372 | ) 373 | await self.query_one(LinePanel).update(line, text, timestamp) 374 | 375 | @on(PointerMoved) 376 | async def pointer_moved(self, event: PointerMoved): 377 | if event.pointer_line is None: 378 | self.show_panel = False 379 | if self.show_panel: 380 | await self.update_panel() 381 | 382 | log_lines = self.query_one(LogLines) 383 | pointer_line = ( 384 | log_lines.scroll_offset.y 385 | if event.pointer_line is None 386 | else event.pointer_line 387 | ) 388 | log_file, _, _ = log_lines.index_to_span(pointer_line) 389 | log_footer = self.query_one(LogFooter) 390 | log_footer.line_no = pointer_line 391 | if len(log_lines.log_files) > 1: 392 | log_footer.filename = log_file.name 393 | 394 | timestamp = log_lines.get_timestamp(pointer_line) 395 | log_footer.timestamp = timestamp 396 | 397 | @on(PendingLines) 398 | def on_pending_lines(self, event: PendingLines) -> None: 399 | if self.app._exit: 400 | return 401 | event.stop() 402 | self.query_one(InfoOverlay).message = f"+{event.count:,} lines" 403 | 404 | @on(ScanProgress) 405 | def on_scan_progress(self, event: ScanProgress): 406 | event.stop() 407 | scan_progress_bar = self.query_one(ScanProgressBar) 408 | scan_progress_bar.message = event.message 409 | scan_progress_bar.complete = event.complete 410 | 411 | @on(ScanComplete) 412 | async def on_scan_complete(self, event: ScanComplete) -> None: 413 | self.query_one(ScanProgressBar).remove() 414 | log_lines = self.query_one(LogLines) 415 | log_lines.loading = False 416 | self.query_one("LogLines").remove_class("-scanning") 417 | self.post_message(PointerMoved(log_lines.pointer_line)) 418 | self.tail = True 419 | 420 | footer = self.query_one(LogFooter) 421 | footer.call_after_refresh(footer.mount_keys) 422 | 423 | @on(events.DescendantFocus) 424 | @on(events.DescendantBlur) 425 | def on_descendant_focus(self, event: events.DescendantBlur) -> None: 426 | self.set_class(isinstance(self.screen.focused, LogLines), "lines-view") 427 | 428 | def action_toggle_tail(self) -> None: 429 | if not self.can_tail: 430 | self.notify("Can't tail merged files", title="Tail", severity="error") 431 | else: 432 | self.tail = not self.tail 433 | 434 | def action_show_find_dialog(self) -> None: 435 | find_dialog = self.query_one(FindDialog) 436 | if not self.show_find or not any( 437 | input.has_focus for input in find_dialog.query("Input") 438 | ): 439 | self.show_find = True 440 | find_dialog.focus_input() 441 | 442 | @on(Goto) 443 | def on_goto(self) -> None: 444 | self.action_goto() 445 | 446 | def action_goto(self) -> None: 447 | from toolong.goto_screen import GotoScreen 448 | 449 | self.app.push_screen(GotoScreen(self.query_one(LogLines))) 450 | -------------------------------------------------------------------------------- /src/toolong/messages.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from dataclasses import dataclass 3 | 4 | import rich.repr 5 | from textual.message import Message 6 | 7 | from toolong.log_file import LogFile 8 | 9 | 10 | @dataclass 11 | class Goto(Message): 12 | pass 13 | 14 | 15 | @dataclass 16 | class SizeChanged(Message, bubble=False): 17 | """File size has changed.""" 18 | 19 | size: int 20 | 21 | def can_replace(self, message: Message) -> bool: 22 | return isinstance(message, SizeChanged) 23 | 24 | 25 | @dataclass 26 | class FileError(Message, bubble=False): 27 | """An error occurred watching a file.""" 28 | 29 | error: Exception 30 | 31 | 32 | @dataclass 33 | class PendingLines(Message): 34 | """Pending lines detected.""" 35 | 36 | count: int 37 | 38 | def can_replace(self, message: Message) -> bool: 39 | return isinstance(message, PendingLines) 40 | 41 | 42 | @rich.repr.auto 43 | @dataclass 44 | class NewBreaks(Message): 45 | """New line break to add.""" 46 | 47 | log_file: LogFile 48 | breaks: list[int] 49 | scanned_size: int = 0 50 | tail: bool = False 51 | 52 | def __rich_repr__(self) -> rich.repr.Result: 53 | yield "scanned_size", self.scanned_size 54 | yield "tail", self.tail 55 | 56 | 57 | class DismissOverlay(Message): 58 | """Request to dismiss overlay.""" 59 | 60 | 61 | @dataclass 62 | class TailFile(Message): 63 | """Set file tailing.""" 64 | 65 | tail: bool = True 66 | 67 | 68 | @dataclass 69 | class ScanProgress(Message): 70 | """Update scan progress bar.""" 71 | 72 | message: str 73 | complete: float 74 | scan_start: int | None = None 75 | 76 | 77 | @dataclass 78 | class ScanComplete(Message): 79 | """Scan has completed.""" 80 | 81 | size: int 82 | scan_start: int 83 | 84 | 85 | @dataclass 86 | class PointerMoved(Message): 87 | """Pointer has moved.""" 88 | 89 | pointer_line: int | None 90 | 91 | def can_replace(self, message: Message) -> bool: 92 | return isinstance(message, PointerMoved) 93 | -------------------------------------------------------------------------------- /src/toolong/poll_watcher.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from os import lseek, read, SEEK_CUR 4 | import time 5 | 6 | 7 | from toolong.watcher import WatcherBase 8 | 9 | 10 | class PollWatcher(WatcherBase): 11 | """A watcher that simply polls.""" 12 | 13 | def run(self) -> None: 14 | chunk_size = 64 * 1024 15 | scan_chunk = self.scan_chunk 16 | 17 | while not self._exit_event.is_set(): 18 | successful_read = False 19 | for fileno, watched_file in self._file_descriptors.items(): 20 | try: 21 | position = lseek(fileno, 0, SEEK_CUR) 22 | if chunk := read(fileno, chunk_size): 23 | successful_read = True 24 | breaks = scan_chunk(chunk, position) 25 | watched_file.callback(position + len(chunk), breaks) 26 | position += len(chunk) 27 | except Exception as error: 28 | watched_file.error_callback(error) 29 | self._file_descriptors.pop(fileno, None) 30 | break 31 | else: 32 | if not successful_read: 33 | time.sleep(0.05) 34 | -------------------------------------------------------------------------------- /src/toolong/scan_progress_bar.py: -------------------------------------------------------------------------------- 1 | from textual.app import ComposeResult 2 | from textual.containers import Center, Vertical 3 | from textual.reactive import reactive 4 | from textual.widgets import Label, ProgressBar 5 | 6 | 7 | class ScanProgressBar(Vertical): 8 | SCOPED_CSS = False 9 | DEFAULT_CSS = """ 10 | ScanProgressBar { 11 | width: 100%; 12 | height: auto; 13 | margin: 2 4; 14 | dock: top; 15 | padding: 1 2; 16 | background: $primary; 17 | display: block; 18 | text-align: center; 19 | display: none; 20 | align: center top; 21 | ProgressBar { 22 | margin: 1 0; 23 | } 24 | } 25 | 26 | LogLines:focus ScanProgressBar.-has-content { 27 | display: block; 28 | } 29 | """ 30 | 31 | message = reactive("") 32 | complete = reactive(0.0) 33 | 34 | def watch_message(self, message: str) -> None: 35 | self.query_one(".message", Label).update(message) 36 | self.set_class(bool(message), "-has-content") 37 | 38 | def compose(self) -> ComposeResult: 39 | with Center(): 40 | yield Label(classes="message") 41 | with Center(): 42 | yield ProgressBar(total=1.0, show_eta=True, show_percentage=True).data_bind( 43 | progress=ScanProgressBar.complete 44 | ) 45 | -------------------------------------------------------------------------------- /src/toolong/selector_watcher.py: -------------------------------------------------------------------------------- 1 | from selectors import DefaultSelector, EVENT_READ 2 | from typing import Callable 3 | import os 4 | 5 | from toolong.log_file import LogFile 6 | from toolong.watcher import WatcherBase, WatchedFile 7 | 8 | 9 | class SelectorWatcher(WatcherBase): 10 | """Watches files for changes.""" 11 | 12 | def __init__(self) -> None: 13 | self._selector = DefaultSelector() 14 | super().__init__() 15 | 16 | def close(self) -> None: 17 | if not self._exit_event.is_set(): 18 | self._exit_event.set() 19 | 20 | def add( 21 | self, 22 | log_file: LogFile, 23 | callback: Callable[[int, list[int]], None], 24 | error_callback: Callable[[Exception], None], 25 | ) -> None: 26 | """Add a file to the watcher.""" 27 | super().add(log_file, callback, error_callback) 28 | fileno = log_file.fileno 29 | size = log_file.size 30 | os.lseek(fileno, size, os.SEEK_SET) 31 | self._selector.register(fileno, EVENT_READ) 32 | 33 | def run(self) -> None: 34 | """Thread runner.""" 35 | chunk_size = 64 * 1024 36 | scan_chunk = self.scan_chunk 37 | 38 | while not self._exit_event.is_set(): 39 | for key, mask in self._selector.select(timeout=0.1): 40 | if self._exit_event.is_set(): 41 | break 42 | if mask & EVENT_READ: 43 | fileno = key.fileobj 44 | assert isinstance(fileno, int) 45 | watched_file = self._file_descriptors.get(fileno, None) 46 | if watched_file is None: 47 | continue 48 | 49 | try: 50 | position = os.lseek(fileno, 0, os.SEEK_CUR) 51 | chunk = os.read(fileno, chunk_size) 52 | if chunk: 53 | breaks = scan_chunk(chunk, position) 54 | watched_file.callback(position + len(chunk), breaks) 55 | 56 | except Exception as error: 57 | watched_file.error_callback(error) 58 | self._file_descriptors.pop(fileno, None) 59 | self._selector.unregister(fileno) 60 | -------------------------------------------------------------------------------- /src/toolong/timestamps.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from datetime import datetime 3 | import re 4 | from typing import Callable, NamedTuple 5 | 6 | 7 | class TimestampFormat(NamedTuple): 8 | regex: str 9 | parser: Callable[[str], datetime | None] 10 | 11 | 12 | def parse_timestamp(format: str) -> Callable[[str], datetime | None]: 13 | def parse(timestamp: str) -> datetime | None: 14 | try: 15 | return datetime.strptime(timestamp, format) 16 | except ValueError: 17 | return None 18 | 19 | return parse 20 | 21 | 22 | # Info taken from logmerger project https://github.com/ptmcg/logmerger/blob/main/logmerger/timestamp_wrapper.py 23 | 24 | TIMESTAMP_FORMATS = [ 25 | TimestampFormat( 26 | r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3}\s?(?:Z|[+-]\d{4})", 27 | datetime.fromisoformat, 28 | ), 29 | TimestampFormat( 30 | r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3}", 31 | datetime.fromisoformat, 32 | ), 33 | TimestampFormat( 34 | r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}\s?(?:Z|[+-]\d{4})", 35 | datetime.fromisoformat, 36 | ), 37 | TimestampFormat( 38 | r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}", 39 | datetime.fromisoformat, 40 | ), 41 | TimestampFormat( 42 | r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\s?(?:Z|[+-]\d{4})", 43 | datetime.fromisoformat, 44 | ), 45 | TimestampFormat( 46 | r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}", 47 | datetime.fromisoformat, 48 | ), 49 | TimestampFormat( 50 | r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2},\d{3}\s?(?:Z|[+-]\d{4})", 51 | datetime.fromisoformat, 52 | ), 53 | TimestampFormat( 54 | r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2},\d{3}", 55 | datetime.fromisoformat, 56 | ), 57 | TimestampFormat( 58 | r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}\s?(?:Z|[+-]\d{4}Z?)", 59 | datetime.fromisoformat, 60 | ), 61 | TimestampFormat( 62 | r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}", 63 | datetime.fromisoformat, 64 | ), 65 | TimestampFormat( 66 | r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\s?(?:Z|[+-]\d{4})", 67 | datetime.fromisoformat, 68 | ), 69 | TimestampFormat( 70 | r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}", 71 | datetime.fromisoformat, 72 | ), 73 | TimestampFormat( 74 | r"[JFMASOND][a-z]{2}\s(\s|\d)\d \d{2}:\d{2}:\d{2}", 75 | parse_timestamp("%b %d %H:%M:%S"), 76 | ), 77 | TimestampFormat( 78 | r"\d{2}\/\w+\/\d{4} \d{2}:\d{2}:\d{2}", 79 | parse_timestamp( 80 | "%d/%b/%Y %H:%M:%S", 81 | ), 82 | ), 83 | TimestampFormat( 84 | r"\d{2}\/\w+\/\d{4}:\d{2}:\d{2}:\d{2} [+-]\d{4}", 85 | parse_timestamp("%d/%b/%Y:%H:%M:%S %z"), 86 | ), 87 | TimestampFormat( 88 | r"\d{10}\.\d+", 89 | lambda s: datetime.fromtimestamp(float(s)), 90 | ), 91 | TimestampFormat( 92 | r"\d{13}", 93 | lambda s: datetime.fromtimestamp(int(s)), 94 | ), 95 | ] 96 | 97 | 98 | def parse(line: str) -> tuple[TimestampFormat | None, datetime | None]: 99 | """Attempt to parse a timestamp.""" 100 | for timestamp in TIMESTAMP_FORMATS: 101 | regex, parse_callable = timestamp 102 | match = re.search(regex, line) 103 | if match is not None: 104 | try: 105 | return timestamp, parse_callable(match.string) 106 | except ValueError: 107 | continue 108 | return None, None 109 | 110 | 111 | class TimestampScanner: 112 | """Scan a line for something that looks like a timestamp.""" 113 | 114 | def __init__(self) -> None: 115 | self._timestamp_formats = TIMESTAMP_FORMATS.copy() 116 | 117 | def scan(self, line: str) -> datetime | None: 118 | """Scan a line. 119 | 120 | Args: 121 | line: A log line with a timestamp. 122 | 123 | Returns: 124 | A datetime or `None` if no timestamp was found. 125 | """ 126 | if len(line) > 10_000: 127 | line = line[:10000] 128 | for index, timestamp_format in enumerate(self._timestamp_formats): 129 | regex, parse_callable = timestamp_format 130 | if (match := re.search(regex, line)) is not None: 131 | try: 132 | if (timestamp := parse_callable(match.group(0))) is None: 133 | continue 134 | except Exception: 135 | continue 136 | if index: 137 | # Put matched format at the top so that 138 | # the next line will be matched quicker 139 | del self._timestamp_formats[index : index + 1] 140 | self._timestamp_formats.insert(0, timestamp_format) 141 | 142 | return timestamp 143 | return None 144 | 145 | 146 | if __name__ == "__main__": 147 | # print(parse_timestamp("%Y-%m-%d %H:%M:%S%z")("2024-01-08 13:31:48+00")) 148 | print(parse("29/Jan/2024:13:48:00 +0000")) 149 | 150 | scanner = TimestampScanner() 151 | 152 | LINES = """\ 153 | 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" 154 | 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)" 155 | 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" 156 | 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" 157 | 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)" 158 | 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; )" 159 | 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" 160 | 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" 161 | 3.37.46.91 - - [29/Jan/2024:13:48:19 +0000] "GET /blog/rootblog/feeds/posts/ HTTP/1.1" 200 107059 "-" "node 162 | """.splitlines() 163 | 164 | for line in LINES: 165 | print(scanner.scan(line)) 166 | -------------------------------------------------------------------------------- /src/toolong/ui.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import locale 4 | 5 | from pathlib import Path 6 | 7 | from rich import terminal_theme 8 | from textual.app import App, ComposeResult 9 | from textual.binding import Binding 10 | from textual.lazy import Lazy 11 | from textual.screen import Screen 12 | from textual.widgets import TabbedContent, TabPane 13 | 14 | from toolong.log_view import LogView 15 | from toolong.watcher import get_watcher 16 | from toolong.help import HelpScreen 17 | 18 | 19 | locale.setlocale(locale.LC_ALL, "") 20 | 21 | 22 | class LogScreen(Screen): 23 | 24 | BINDINGS = [ 25 | Binding("f1", "help", "Help"), 26 | ] 27 | 28 | CSS = """ 29 | LogScreen { 30 | layers: overlay; 31 | & TabPane { 32 | padding: 0; 33 | } 34 | & Tabs:focus Underline > .underline--bar { 35 | color: $accent; 36 | } 37 | Underline > .underline--bar { 38 | color: $panel; 39 | } 40 | } 41 | """ 42 | 43 | def compose(self) -> ComposeResult: 44 | assert isinstance(self.app, UI) 45 | with TabbedContent(): 46 | if self.app.merge and len(self.app.file_paths) > 1: 47 | tab_name = " + ".join(Path(path).name for path in self.app.file_paths) 48 | with TabPane(tab_name): 49 | yield Lazy( 50 | LogView( 51 | self.app.file_paths, 52 | self.app.watcher, 53 | can_tail=False, 54 | ) 55 | ) 56 | else: 57 | for path in self.app.file_paths: 58 | with TabPane(path): 59 | yield Lazy( 60 | LogView( 61 | [path], 62 | self.app.watcher, 63 | can_tail=True, 64 | ) 65 | ) 66 | 67 | def on_mount(self) -> None: 68 | assert isinstance(self.app, UI) 69 | self.query("TabbedContent Tabs").set(display=len(self.query(TabPane)) > 1) 70 | active_pane = self.query_one(TabbedContent).active_pane 71 | if active_pane is not None: 72 | active_pane.query("LogView > LogLines").focus() 73 | 74 | def action_help(self) -> None: 75 | self.app.push_screen(HelpScreen()) 76 | 77 | 78 | from functools import total_ordering 79 | 80 | 81 | @total_ordering 82 | class CompareTokens: 83 | """Compare filenames.""" 84 | 85 | def __init__(self, path: str) -> None: 86 | self.tokens = [ 87 | int(token) if token.isdigit() else token.lower() 88 | for token in path.split("/")[-1].split(".") 89 | ] 90 | 91 | def __eq__(self, other: object) -> bool: 92 | return self.tokens == other.tokens 93 | 94 | def __lt__(self, other: CompareTokens) -> bool: 95 | for token1, token2 in zip(self.tokens, other.tokens): 96 | try: 97 | if token1 < token2: 98 | return True 99 | except TypeError: 100 | if str(token1) < str(token2): 101 | return True 102 | return len(self.tokens) < len(other.tokens) 103 | 104 | 105 | class UI(App): 106 | """The top level App object.""" 107 | 108 | @classmethod 109 | def sort_paths(cls, paths: list[str]) -> list[str]: 110 | return sorted(paths, key=CompareTokens) 111 | 112 | def __init__( 113 | self, file_paths: list[str], merge: bool = False, save_merge: str | None = None 114 | ) -> None: 115 | self.file_paths = self.sort_paths(file_paths) 116 | self.merge = merge 117 | self.save_merge = save_merge 118 | self.watcher = get_watcher() 119 | super().__init__() 120 | 121 | async def on_mount(self) -> None: 122 | self.ansi_theme_dark = terminal_theme.DIMMED_MONOKAI 123 | await self.push_screen(LogScreen()) 124 | self.screen.query("LogLines").focus() 125 | self.watcher.start() 126 | 127 | def on_unmount(self) -> None: 128 | self.watcher.close() 129 | -------------------------------------------------------------------------------- /src/toolong/watcher.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import rich.repr 4 | 5 | from abc import ABC, abstractmethod 6 | from dataclasses import dataclass 7 | import platform 8 | from threading import Event, Lock, Thread 9 | from typing import Callable, TYPE_CHECKING 10 | 11 | 12 | def get_watcher() -> WatcherBase: 13 | """Return an Watcher appropriate for the OS.""" 14 | 15 | if platform.system() == "Darwin": 16 | from toolong.selector_watcher import SelectorWatcher 17 | 18 | return SelectorWatcher() 19 | else: 20 | from toolong.poll_watcher import PollWatcher 21 | 22 | return PollWatcher() 23 | 24 | 25 | if TYPE_CHECKING: 26 | from .log_file import LogFile 27 | 28 | 29 | @dataclass 30 | @rich.repr.auto 31 | class WatchedFile: 32 | """A currently watched file.""" 33 | 34 | log_file: LogFile 35 | callback: Callable[[int, list[int]], None] 36 | error_callback: Callable[[Exception], None] 37 | 38 | 39 | class WatcherBase(ABC): 40 | """Watches files for changes.""" 41 | 42 | def __init__(self) -> None: 43 | self._file_descriptors: dict[int, WatchedFile] = {} 44 | self._thread: Thread | None = None 45 | self._exit_event = Event() 46 | super().__init__() 47 | 48 | @classmethod 49 | def scan_chunk(cls, chunk: bytes, position: int) -> list[int]: 50 | """Scan line breaks in a binary chunk, 51 | 52 | Args: 53 | chunk: A binary chunk. 54 | position: Offset within the file 55 | 56 | Returns: 57 | A list of indices with new lines. 58 | """ 59 | breaks: list[int] = [] 60 | offset = 0 61 | append = breaks.append 62 | while (offset := chunk.find(b"\n", offset)) != -1: 63 | append(position + offset) 64 | offset += 1 65 | return breaks 66 | 67 | def close(self) -> None: 68 | if not self._exit_event.is_set(): 69 | self._exit_event.set() 70 | self._thread = None 71 | 72 | def start(self) -> None: 73 | assert self._thread is None 74 | self._thread = Thread(target=self.run, name=repr(self)) 75 | self._thread.start() 76 | 77 | def add( 78 | self, 79 | log_file: LogFile, 80 | callback: Callable[[int, list[int]], None], 81 | error_callback: Callable[[Exception], None], 82 | ) -> None: 83 | """Add a file to the watcher.""" 84 | fileno = log_file.fileno 85 | self._file_descriptors[fileno] = WatchedFile(log_file, callback, error_callback) 86 | 87 | @abstractmethod 88 | def run(self) -> None: 89 | """Thread runner.""" 90 | --------------------------------------------------------------------------------