├── .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 |
4 |
5 |
6 |
7 | [](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 |
20 |
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 |
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 |
56 |
57 | |
58 |
59 |
60 | |
61 |
62 |
63 |
64 |
65 | |
66 |
67 |
68 | |
69 |
70 |
71 |
72 |
73 | ### Videos
74 |
75 |
76 | 🎬 Merging multiple (compressed) files
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 | 🎬 Viewing JSONL files
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 | 🎬 Live Tailing a file
100 |
101 |
102 |
103 |
104 |
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 |
--------------------------------------------------------------------------------