Compare commits
50 Commits
bec7ba5ec0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c6edfa5572 | |||
| ac99643dbc | |||
| 889ac62a9a | |||
| 0bf6db7980 | |||
| 6aa4ebb33f | |||
| ca43ea8858 | |||
| 733e35b0d2 | |||
| f3573dfffc | |||
| d17cb6f4d2 | |||
| 6e3eb87f76 | |||
| b5f82d6e33 | |||
| 8bddca2f75 | |||
| bb8571df8a | |||
| f528df49a9 | |||
| d40ad4534a | |||
| c9a8764286 | |||
| 1976b5d88c | |||
| a8e3972f34 | |||
| eea6f26bcf | |||
| ca70661bf6 | |||
| 7930bf6941 | |||
| 6d3e818b01 | |||
| 02c6e4cb88 | |||
| b63956c08f | |||
| f024128f85 | |||
| 6d246944a3 | |||
| e975654d87 | |||
| fbad34cc24 | |||
| c6a1374e21 | |||
| db92450c7e | |||
| c0004c554f | |||
| f565ee9dc9 | |||
| 67c44b2cb7 | |||
| 7128e3e7d4 | |||
| 290e76d289 | |||
| 678f3dac77 | |||
| 24146c8db6 | |||
| d996b1d523 | |||
| cf3dc315d7 | |||
| 3806c35140 | |||
| 974c671012 | |||
| 0cf9884c6c | |||
| 124a962d72 | |||
| bcad61d78a | |||
| f9c4771ee4 | |||
| 964b888e4c | |||
| e620ea8369 | |||
| c1dd38fbe6 | |||
| fca7329ba1 | |||
| 8fdd517933 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,2 +1,3 @@
|
||||
.venv
|
||||
auditui.egg-info
|
||||
__pycache__
|
||||
|
||||
@@ -1 +1 @@
|
||||
3.13
|
||||
3.12
|
||||
193
README.md
193
README.md
@@ -1,73 +1,170 @@
|
||||
# auditui
|
||||
|
||||
A terminal-based user interface (TUI) client for Audible, written in Python 3 : listen to your audiobooks (even offline), browse and manage your library, and more!
|
||||
A terminal-based user interface (TUI) client for [Audible](https://www.audible.fr/), written in Python 3.
|
||||
|
||||
Currently, the only available theme is Catppuccin Mocha, following their [style guide](https://github.com/catppuccin/catppuccin/blob/main/docs/style-guide.md), as it's my preferred theme across most of my tools.
|
||||
|
||||
Look at the [roadmap](#roadmap) for more details.
|
||||
## Requirements
|
||||
|
||||
It's still a work in progress, so expect bugs and missing features.
|
||||
- [Python](https://www.python.org/) 3.10-3.12 (3.13+ is not yet supported by `audible` module)
|
||||
- [ffmpeg](https://ffmpeg.org/) installed to play audio files.
|
||||
|
||||
## How to run
|
||||
## Features
|
||||
|
||||
- **Browse your library**: View all your Audible audiobooks in a clean, terminal-based interface
|
||||
- **Offline playback**: Download audiobooks to your local cache and listen without an internet connection
|
||||
- **Playback controls**: Play, pause, seek, adjust playback speed, and navigate between chapters
|
||||
- **Library management**: Filter your library, sort by name or progress, and mark books as finished
|
||||
- **Progress tracking**: See your listening progress for each book and resume where you left off
|
||||
- **Statistics**: View listening statistics and library overview
|
||||
- **Keyboard-driven**: Fully navigable with keyboard shortcuts for efficient use
|
||||
- **Two-factor authentication**: Supports OTP for secure login
|
||||
|
||||
## Installation
|
||||
|
||||
Use [`pipx`](https://pipx.pypa.io/latest/installation/) to install `auditui`:
|
||||
|
||||
```bash
|
||||
pipx install git+https://git.kharec.info/Kharec/auditui.git
|
||||
```
|
||||
|
||||
Check the version to ensure installation was successful:
|
||||
|
||||
```bash
|
||||
auditui --version
|
||||
```
|
||||
|
||||
All set, run `auditui configure` to set up authentication, and then `auditui` to start the TUI.
|
||||
|
||||
### Workaround for Python 3.13 linux distribution
|
||||
|
||||
On some Linux distributions, Python 3.13 is already the default. So you have to install Python 3.12 manually before using `pipx`.
|
||||
|
||||
For Arch Linux:
|
||||
|
||||
```bash
|
||||
yay -S python312
|
||||
```
|
||||
|
||||
Once you have Python 3.12, run:
|
||||
|
||||
```bash
|
||||
pipx install git+https://git.kharec.info/Kharec/auditui.git --python python3.12
|
||||
```
|
||||
|
||||
As Python <3.14 is supported on `master` branch of the upstream [`audible`](https://github.com/mkb79/Audible), this should be temporary until the next version.
|
||||
|
||||
## Upgrade
|
||||
|
||||
Assuming it's already installed, use `pipx` to upgrade auditui:
|
||||
|
||||
```bash
|
||||
pipx upgrade auditui
|
||||
```
|
||||
|
||||
## Keybindings
|
||||
|
||||
| Key | Action |
|
||||
| ------------ | -------------------------- |
|
||||
| `/` | Filter library |
|
||||
| `?` | Show help screen |
|
||||
| `enter` | Play the selected book |
|
||||
| `space` | Pause/resume the playback |
|
||||
| `escape` | Clear filter |
|
||||
| `ctrl+left` | Go to the previous chapter |
|
||||
| `ctrl+right` | Go to the next chapter |
|
||||
| `up` | Increase playback speed |
|
||||
| `down` | Decrease playback speed |
|
||||
| `left` | Seek backward 30 seconds |
|
||||
| `right` | Seek forward 30 seconds |
|
||||
| `a` | Show all/unfinished |
|
||||
| `d` | Toggle download/delete |
|
||||
| `f` | Mark as finished |
|
||||
| `n` | Sort by name |
|
||||
| `p` | Sort by progress |
|
||||
| `q` | Quit the application |
|
||||
| `r` | Refresh view |
|
||||
| `s` | Show stats screen |
|
||||
|
||||
## Cache
|
||||
|
||||
Books are downloaded to `~/.cache/auditui/books`.
|
||||
|
||||
The `d` key toggles the download state for the selected book: if the book is not cached, pressing `d` will download it; if it's already cached, pressing `d` will delete it from the cache.
|
||||
|
||||
To check the total size of your cache:
|
||||
|
||||
```bash
|
||||
du -sh ~/.cache/auditui/books
|
||||
```
|
||||
|
||||
Or the size of individual books:
|
||||
|
||||
```bash
|
||||
du -h ~/.cache/auditui/books/*
|
||||
```
|
||||
|
||||
Clean all the cache (if necessary) with:
|
||||
|
||||
```bash
|
||||
rm -rf ~/.cache/auditui/books/*
|
||||
```
|
||||
|
||||
## Authentication / credentials
|
||||
|
||||
Login is handled and credentials are stored in `~/.config/auditui/auth.json`.
|
||||
|
||||
When running `auditui configure`, you will be prompted for:
|
||||
|
||||
- **Email**: Your Audible account email address
|
||||
- **Password**: Your Audible account password (input is hidden)
|
||||
- **Marketplace locale**: The regional marketplace you want to connect to (defaults to `US` if left empty)
|
||||
|
||||
The marketplace locale determines which Audible region you access, affecting available audiobooks in your library. Common marketplace codes include:
|
||||
|
||||
- `US` - United States (default)
|
||||
- `UK` - United Kingdom
|
||||
- `DE` - Germany
|
||||
- `FR` - France
|
||||
- `CA` - Canada
|
||||
- `AU` - Australia
|
||||
- `IT` - Italy
|
||||
- `ES` - Spain
|
||||
- `JP` - Japan
|
||||
|
||||
To change your marketplace after initial configuration, simply run `auditui configure` again and select a different locale when prompted. But you should probably just stick with the marketplace you used when you first created your Audible account.
|
||||
|
||||
OTP is supported if you use a two-factor authentication device.
|
||||
|
||||
## Hacking
|
||||
|
||||
This project uses [uv](https://github.com/astral-sh/uv) for dependency management.
|
||||
|
||||
```bash
|
||||
# install dependencies (creates .venv)
|
||||
$ uv sync
|
||||
|
||||
# run the TUI
|
||||
# modify the code...
|
||||
# ...and run the TUI
|
||||
$ uv run python -m auditui.cli
|
||||
```
|
||||
|
||||
Please also note that as of now, you need to have [ffmpeg](https://ffmpeg.org/) installed to play audio files.
|
||||
Don't forget to run the tests.
|
||||
|
||||
### Bindings
|
||||
## Testing
|
||||
|
||||
| Key | Action |
|
||||
| ------------ | --------------------------- |
|
||||
| `?` | Show help screen |
|
||||
| `n` | Sort by name |
|
||||
| `p` | Sort by progress |
|
||||
| `a` | Show all/unfinished |
|
||||
| `enter` | Play the selected book |
|
||||
| `space` | Pause/resume the playback |
|
||||
| `left` | Seek backward 30 seconds |
|
||||
| `right` | Seek forward 30 seconds |
|
||||
| `ctrl+left` | Go to the previous chapter |
|
||||
| `ctrl+right` | Go to the next chapter |
|
||||
| `up` | Increase playback speed |
|
||||
| `down` | Decrease playback speed |
|
||||
| `f` | Mark as finished/unfinished |
|
||||
| `d` | Download/delete from cache |
|
||||
| `s` | Show stats screen |
|
||||
| `q` | Quit the application |
|
||||
As usual, tests are located in `tests` directory and use `pytest`.
|
||||
|
||||
## Roadmap
|
||||
Get the dev dependencies:
|
||||
|
||||
- [x] list your library
|
||||
- [x] list your unfinished books with progress information
|
||||
- [x] play/pause a book
|
||||
- [x] catppuccin mocha theme
|
||||
- [x] print chapter and progress in the footer of the app while a book is playing
|
||||
- [x] chapter progress bar in footer
|
||||
- [x] add a control to jump 30s earlier/later
|
||||
- [x] add control to go to the previous/next chapter
|
||||
- [x] save/resume playback of a book from the last position, regardless of which device was used previously
|
||||
- [x] download/remove a book in the cache without having to play it
|
||||
- [x] add a help screen with all the keybindings
|
||||
- [x] increase/decrease reading speed
|
||||
- [x] mark a book as finished or unfinished
|
||||
- [x] make ui responsive
|
||||
- [x] get your stats in a separated pane
|
||||
- [ ] search/filter within your library
|
||||
- [ ] installation setup
|
||||
```bash
|
||||
uv sync --extra dev
|
||||
```
|
||||
|
||||
## Auth / credentials
|
||||
And run the tests:
|
||||
|
||||
Login is handled and credentials are stored in `~/.config/auditui/auth.json`.
|
||||
|
||||
OTP is supported if you use a two-factor authentication device.
|
||||
```bash
|
||||
uv run pytest
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
"""Auditui package"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
||||
__version__ = "0.1.4"
|
||||
|
||||
116
auditui/app.py
116
auditui/app.py
@@ -27,7 +27,8 @@ from .table_utils import (
|
||||
filter_unfinished_items,
|
||||
format_item_as_row,
|
||||
)
|
||||
from .ui import HelpScreen, StatsScreen
|
||||
from .search_utils import build_search_text, filter_items
|
||||
from .ui import FilterScreen, HelpScreen, StatsScreen
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from textual.widgets._data_table import ColumnKey
|
||||
@@ -42,9 +43,12 @@ class Auditui(App):
|
||||
BINDINGS = [
|
||||
("?", "show_help", "Help"),
|
||||
("s", "show_stats", "Stats"),
|
||||
("/", "filter", "Filter"),
|
||||
("escape", "clear_filter", "Clear filter"),
|
||||
("n", "sort", "Sort by name"),
|
||||
("p", "sort_by_progress", "Sort by progress"),
|
||||
("a", "show_all", "All/Unfinished"),
|
||||
("r", "refresh", "Refresh"),
|
||||
("enter", "play_selected", "Play"),
|
||||
("space", "toggle_playback", "Pause/Resume"),
|
||||
("left", "seek_backward", "-30s"),
|
||||
@@ -53,7 +57,7 @@ class Auditui(App):
|
||||
("ctrl+right", "next_chapter", "Next chapter"),
|
||||
("up", "increase_speed", "Increase speed"),
|
||||
("down", "decrease_speed", "Decrease speed"),
|
||||
("f", "toggle_finished", "Mark finished/unfinished"),
|
||||
("f", "toggle_finished", "Mark finished"),
|
||||
("d", "toggle_download", "Download/Delete"),
|
||||
("q", "quit", "Quit"),
|
||||
]
|
||||
@@ -73,7 +77,9 @@ class Auditui(App):
|
||||
|
||||
self.all_items: list[dict] = []
|
||||
self.current_items: list[dict] = []
|
||||
self._search_text_cache: dict[int, str] = {}
|
||||
self.show_all_mode = False
|
||||
self.filter_text = ""
|
||||
self.title_sort_reverse = False
|
||||
self.progress_sort_reverse = False
|
||||
self.title_column_key: ColumnKey | None = None
|
||||
@@ -92,7 +98,8 @@ class Auditui(App):
|
||||
table.cursor_type = "row"
|
||||
yield table
|
||||
yield Static("", id="progress_info")
|
||||
yield ProgressBar(id="progress_bar", show_eta=False, show_percentage=False, total=100)
|
||||
with Horizontal(id="progress_bar_container"):
|
||||
yield ProgressBar(id="progress_bar", show_eta=False, show_percentage=False, total=100)
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Initialize the table and start fetching library data."""
|
||||
@@ -220,8 +227,13 @@ class Auditui(App):
|
||||
def on_library_loaded(self, items: list[dict]) -> None:
|
||||
"""Handle successful library load."""
|
||||
self.all_items = items
|
||||
self._search_text_cache.clear()
|
||||
self._prime_search_cache(items)
|
||||
self.update_status(f"Loaded {len(items)} books")
|
||||
self.show_unfinished()
|
||||
if self.show_all_mode:
|
||||
self.show_all()
|
||||
else:
|
||||
self.show_unfinished()
|
||||
|
||||
def on_library_error(self, error: str) -> None:
|
||||
"""Handle library fetch error."""
|
||||
@@ -256,17 +268,14 @@ class Auditui(App):
|
||||
if not self.all_items:
|
||||
return
|
||||
self.show_all_mode = True
|
||||
self._populate_table(self.all_items)
|
||||
self._refresh_filtered_view()
|
||||
|
||||
def show_unfinished(self) -> None:
|
||||
"""Display only unfinished books in the table."""
|
||||
if not self.all_items or not self.library_client:
|
||||
return
|
||||
|
||||
self.show_all_mode = False
|
||||
unfinished_items = filter_unfinished_items(
|
||||
self.all_items, self.library_client)
|
||||
self._populate_table(unfinished_items)
|
||||
self._refresh_filtered_view()
|
||||
|
||||
def action_sort(self) -> None:
|
||||
"""Sort table by title, toggling direction on each press."""
|
||||
@@ -292,6 +301,14 @@ class Auditui(App):
|
||||
else:
|
||||
self.show_all()
|
||||
|
||||
def action_refresh(self) -> None:
|
||||
"""Refresh the library data from the API."""
|
||||
if not self.client:
|
||||
self.update_status("Not authenticated. Cannot refresh.")
|
||||
return
|
||||
self.update_status("Refreshing library...")
|
||||
self.fetch_library()
|
||||
|
||||
def action_play_selected(self) -> None:
|
||||
"""Start playing the selected book."""
|
||||
if not self.download_manager:
|
||||
@@ -400,14 +417,19 @@ class Auditui(App):
|
||||
is_currently_finished = self.library_client.is_finished(selected_item)
|
||||
|
||||
if is_currently_finished:
|
||||
success = self.library_client.mark_as_unfinished(asin)
|
||||
message = "Marked as unfinished" if success else "Failed to mark as unfinished"
|
||||
else:
|
||||
success = self.library_client.mark_as_finished(asin, selected_item)
|
||||
message = "Marked as finished" if success else "Failed to mark as finished"
|
||||
self.call_from_thread(self.update_status,
|
||||
"Already marked as finished")
|
||||
return
|
||||
|
||||
success = self.library_client.mark_as_finished(asin, selected_item)
|
||||
message = "Marked as finished" if success else "Failed to mark as finished"
|
||||
|
||||
self.call_from_thread(self.update_status, message)
|
||||
if success:
|
||||
if self.download_manager and self.download_manager.is_cached(asin):
|
||||
self.download_manager.remove_cached(
|
||||
asin, notify=self._thread_status_update
|
||||
)
|
||||
self.call_from_thread(self.fetch_library)
|
||||
|
||||
def _no_playback_message(self) -> None:
|
||||
@@ -422,6 +444,63 @@ class Auditui(App):
|
||||
"""Show the stats screen with listening statistics."""
|
||||
self.push_screen(StatsScreen())
|
||||
|
||||
def action_filter(self) -> None:
|
||||
"""Show the filter screen to search the library."""
|
||||
self.push_screen(
|
||||
FilterScreen(
|
||||
self.filter_text,
|
||||
on_change=self._apply_filter,
|
||||
),
|
||||
self._apply_filter,
|
||||
)
|
||||
|
||||
def action_clear_filter(self) -> None:
|
||||
"""Clear the current filter if active."""
|
||||
if self.filter_text:
|
||||
self.filter_text = ""
|
||||
self._refresh_filtered_view()
|
||||
self.update_status("Filter cleared")
|
||||
|
||||
def _apply_filter(self, filter_text: str) -> None:
|
||||
"""Apply the filter to the library."""
|
||||
self.filter_text = filter_text
|
||||
self._refresh_filtered_view()
|
||||
|
||||
def _refresh_filtered_view(self) -> None:
|
||||
"""Refresh the table with current filter and view mode."""
|
||||
if not self.all_items:
|
||||
return
|
||||
|
||||
items = self.all_items
|
||||
|
||||
if self.filter_text:
|
||||
items = filter_items(items, self.filter_text,
|
||||
self._get_search_text)
|
||||
self._populate_table(items)
|
||||
self.update_status(
|
||||
f"Filter: '{self.filter_text}' ({len(items)} books)")
|
||||
return
|
||||
|
||||
if not self.show_all_mode and self.library_client:
|
||||
items = filter_unfinished_items(items, self.library_client)
|
||||
|
||||
self._populate_table(items)
|
||||
|
||||
def _get_search_text(self, item: dict) -> str:
|
||||
"""Return cached search text for filtering."""
|
||||
cache_key = id(item)
|
||||
cached = self._search_text_cache.get(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
search_text = build_search_text(item, self.library_client)
|
||||
self._search_text_cache[cache_key] = search_text
|
||||
return search_text
|
||||
|
||||
def _prime_search_cache(self, items: list[dict]) -> None:
|
||||
"""Precompute search text for a list of items."""
|
||||
for item in items:
|
||||
self._get_search_text(item)
|
||||
|
||||
def _check_playback_status(self) -> None:
|
||||
"""Check if playback process has finished and update state accordingly."""
|
||||
message = self.playback.check_status()
|
||||
@@ -447,6 +526,8 @@ class Auditui(App):
|
||||
|
||||
progress_info = self.query_one("#progress_info", Static)
|
||||
progress_bar = self.query_one("#progress_bar", ProgressBar)
|
||||
progress_bar_container = self.query_one(
|
||||
"#progress_bar_container", Horizontal)
|
||||
|
||||
progress_percent = min(100.0, max(
|
||||
0.0, (chapter_elapsed / chapter_total) * 100.0))
|
||||
@@ -456,14 +537,15 @@ class Auditui(App):
|
||||
progress_info.update(
|
||||
f"{chapter_name} | {chapter_elapsed_str} / {chapter_total_str}")
|
||||
progress_info.display = True
|
||||
progress_bar.display = True
|
||||
progress_bar_container.display = True
|
||||
|
||||
def _hide_progress(self) -> None:
|
||||
"""Hide the progress widget."""
|
||||
progress_info = self.query_one("#progress_info", Static)
|
||||
progress_bar = self.query_one("#progress_bar", ProgressBar)
|
||||
progress_bar_container = self.query_one(
|
||||
"#progress_bar_container", Horizontal)
|
||||
progress_info.display = False
|
||||
progress_bar.display = False
|
||||
progress_bar_container.display = False
|
||||
|
||||
def _save_position_periodically(self) -> None:
|
||||
"""Periodically save playback position."""
|
||||
|
||||
@@ -61,8 +61,8 @@ Screen {
|
||||
DataTable {
|
||||
height: 1fr;
|
||||
background: #141622;
|
||||
color: #d6dbf2;
|
||||
border: solid #2b2f45;
|
||||
color: #c7cfe8;
|
||||
border: solid #262a3f;
|
||||
scrollbar-size-horizontal: 0;
|
||||
}
|
||||
|
||||
@@ -105,6 +105,14 @@ Static#progress_info {
|
||||
text-style: bold;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#progress_bar_container {
|
||||
align: center middle;
|
||||
width: 100%;
|
||||
height: 1;
|
||||
}
|
||||
|
||||
ProgressBar#progress_bar {
|
||||
@@ -112,8 +120,10 @@ ProgressBar#progress_bar {
|
||||
background: #10131f;
|
||||
border: none;
|
||||
margin: 0;
|
||||
padding: 0 1;
|
||||
align: center middle;
|
||||
padding: 0;
|
||||
width: auto;
|
||||
min-width: 40;
|
||||
max-width: 80;
|
||||
}
|
||||
|
||||
ProgressBar#progress_bar > .progress-bar--track {
|
||||
@@ -124,21 +134,16 @@ ProgressBar#progress_bar > .progress-bar--bar {
|
||||
background: #8bd5ca;
|
||||
}
|
||||
|
||||
HelpScreen {
|
||||
HelpScreen,
|
||||
StatsScreen,
|
||||
FilterScreen {
|
||||
align: center middle;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
HelpScreen Static {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
StatsScreen {
|
||||
align: center middle;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
StatsScreen Static {
|
||||
HelpScreen Static,
|
||||
StatsScreen Static,
|
||||
FilterScreen Static {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
@@ -173,25 +178,25 @@ StatsScreen .help_list > ListItem > Label {
|
||||
}
|
||||
|
||||
#help_container {
|
||||
width: 88%;
|
||||
max-width: 120;
|
||||
min-width: 48;
|
||||
width: 72%;
|
||||
max-width: 90;
|
||||
min-width: 44;
|
||||
height: auto;
|
||||
max-height: 80%;
|
||||
min-height: 14;
|
||||
background: #181a2a;
|
||||
border: heavy #7aa2f7;
|
||||
padding: 1 2;
|
||||
padding: 1 1;
|
||||
}
|
||||
|
||||
#help_title {
|
||||
width: 100%;
|
||||
height: 3;
|
||||
height: 2;
|
||||
text-align: center;
|
||||
text-style: bold;
|
||||
color: #7aa2f7;
|
||||
content-align: center middle;
|
||||
margin-bottom: 1;
|
||||
margin-bottom: 0;
|
||||
border-bottom: solid #4b5165;
|
||||
}
|
||||
|
||||
@@ -200,19 +205,21 @@ StatsScreen .help_list > ListItem > Label {
|
||||
height: auto;
|
||||
padding: 0;
|
||||
margin: 0 0 1 0;
|
||||
align: center middle;
|
||||
}
|
||||
|
||||
.help_list {
|
||||
width: 1fr;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
background: transparent;
|
||||
padding: 0 1;
|
||||
padding: 0;
|
||||
scrollbar-size: 0 0;
|
||||
}
|
||||
|
||||
.help_list > ListItem {
|
||||
background: #1b1f33;
|
||||
padding: 0 1;
|
||||
height: 1;
|
||||
}
|
||||
|
||||
.help_list > ListItem:hover {
|
||||
@@ -226,11 +233,43 @@ StatsScreen .help_list > ListItem > Label {
|
||||
|
||||
#help_footer {
|
||||
width: 100%;
|
||||
height: 3;
|
||||
height: 2;
|
||||
text-align: center;
|
||||
content-align: center middle;
|
||||
color: #bac2de;
|
||||
margin-top: 1;
|
||||
color: #b6bfdc;
|
||||
margin-top: 0;
|
||||
border-top: solid #4b5165;
|
||||
}
|
||||
|
||||
#filter_container {
|
||||
width: 60;
|
||||
height: auto;
|
||||
background: #181a2a;
|
||||
border: heavy #7aa2f7;
|
||||
padding: 1 2;
|
||||
}
|
||||
|
||||
#filter_title {
|
||||
width: 100%;
|
||||
height: 2;
|
||||
text-align: center;
|
||||
text-style: bold;
|
||||
color: #7aa2f7;
|
||||
content-align: center middle;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#filter_input {
|
||||
width: 100%;
|
||||
margin: 1 0;
|
||||
}
|
||||
|
||||
#filter_footer {
|
||||
width: 100%;
|
||||
height: 2;
|
||||
text-align: center;
|
||||
content-align: center middle;
|
||||
color: #b6bfdc;
|
||||
margin-top: 1;
|
||||
}
|
||||
"""
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Library helpers for fetching and formatting Audible data."""
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from typing import Callable
|
||||
|
||||
import audible
|
||||
@@ -13,44 +14,108 @@ class LibraryClient:
|
||||
|
||||
def __init__(self, client: audible.Client) -> None:
|
||||
self.client = client
|
||||
self._saved_positions: dict[str, float] = {}
|
||||
|
||||
def fetch_all_items(self, on_progress: ProgressCallback | None = None) -> list:
|
||||
"""Fetch all library items from the API."""
|
||||
response_groups = (
|
||||
"contributors,media,product_attrs,product_desc,product_details,"
|
||||
"rating,is_finished,listening_status,percent_complete"
|
||||
"is_finished,listening_status,percent_complete"
|
||||
)
|
||||
return self._fetch_all_pages(response_groups, on_progress)
|
||||
|
||||
def _fetch_page(
|
||||
self, page: int, page_size: int, response_groups: str
|
||||
) -> tuple[int, list[dict]]:
|
||||
"""Fetch a single page of library items."""
|
||||
library = self.client.get(
|
||||
path="library",
|
||||
num_results=page_size,
|
||||
page=page,
|
||||
response_groups=response_groups,
|
||||
)
|
||||
items = library.get("items", [])
|
||||
return page, list(items)
|
||||
|
||||
def _fetch_all_pages(
|
||||
self, response_groups: str, on_progress: ProgressCallback | None = None
|
||||
) -> list:
|
||||
"""Fetch all pages of library items from the API."""
|
||||
all_items: list[dict] = []
|
||||
page = 1
|
||||
page_size = 50
|
||||
"""Fetch all pages of library items from the API using maximum parallel fetching."""
|
||||
library_response = None
|
||||
page_size = 200
|
||||
|
||||
while True:
|
||||
library = self.client.get(
|
||||
path="library",
|
||||
num_results=page_size,
|
||||
page=page,
|
||||
response_groups=response_groups,
|
||||
)
|
||||
|
||||
items = list(library.get("items", []))
|
||||
if not items:
|
||||
for attempt_size in [200, 100, 50]:
|
||||
try:
|
||||
library_response = self.client.get(
|
||||
path="library",
|
||||
num_results=attempt_size,
|
||||
page=1,
|
||||
response_groups=response_groups,
|
||||
)
|
||||
page_size = attempt_size
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
all_items.extend(items)
|
||||
if on_progress:
|
||||
on_progress(f"Fetched page {page} ({len(items)} items)...")
|
||||
if not library_response:
|
||||
return []
|
||||
|
||||
if len(items) < page_size:
|
||||
break
|
||||
first_page_items = library_response.get("items", [])
|
||||
if not first_page_items:
|
||||
return []
|
||||
|
||||
page += 1
|
||||
all_items: list[dict] = list(first_page_items)
|
||||
if on_progress:
|
||||
on_progress(f"Fetched page 1 ({len(first_page_items)} items)...")
|
||||
|
||||
if len(first_page_items) < page_size:
|
||||
return all_items
|
||||
|
||||
total_items_estimate = library_response.get(
|
||||
"total_results") or library_response.get("total")
|
||||
if total_items_estimate:
|
||||
estimated_pages = (total_items_estimate +
|
||||
page_size - 1) // page_size
|
||||
estimated_pages = min(estimated_pages, 1000)
|
||||
else:
|
||||
estimated_pages = 500
|
||||
|
||||
max_workers = 50
|
||||
page_results: dict[int, list[dict]] = {}
|
||||
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
future_to_page: dict = {}
|
||||
|
||||
for page in range(2, estimated_pages + 1):
|
||||
future = executor.submit(
|
||||
self._fetch_page, page, page_size, response_groups
|
||||
)
|
||||
future_to_page[future] = page
|
||||
|
||||
completed_count = 0
|
||||
total_items = len(first_page_items)
|
||||
|
||||
for future in as_completed(future_to_page):
|
||||
page_num = future_to_page.pop(future)
|
||||
try:
|
||||
fetched_page, items = future.result()
|
||||
if not items or len(items) < page_size:
|
||||
for remaining_future in list(future_to_page.keys()):
|
||||
remaining_future.cancel()
|
||||
break
|
||||
|
||||
page_results[fetched_page] = items
|
||||
total_items += len(items)
|
||||
completed_count += 1
|
||||
if on_progress and completed_count % 20 == 0:
|
||||
on_progress(
|
||||
f"Fetched {completed_count} pages ({total_items} items)..."
|
||||
)
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for page_num in sorted(page_results.keys()):
|
||||
all_items.extend(page_results[page_num])
|
||||
|
||||
return all_items
|
||||
|
||||
@@ -231,61 +296,63 @@ class LibraryClient:
|
||||
return f"{hours}h{minutes:02d}" if minutes else f"{hours}h"
|
||||
return f"{minutes}m"
|
||||
|
||||
def _get_total_duration(self, asin: str, item: dict | None = None) -> float | None:
|
||||
"""Get total duration in seconds, trying item data first, then API."""
|
||||
if item:
|
||||
duration = self._get_total_duration_from_item(item)
|
||||
if duration:
|
||||
return duration
|
||||
return self._get_total_duration_from_api(asin)
|
||||
|
||||
def mark_as_finished(self, asin: str, item: dict | None = None) -> bool:
|
||||
"""Mark a book as finished (100% complete) by setting position near end."""
|
||||
current_position = self.get_last_position(asin)
|
||||
if current_position and current_position > 0:
|
||||
self._saved_positions[asin] = current_position
|
||||
"""Mark a book as finished by setting position to the end."""
|
||||
total_ms = self._get_runtime_ms(asin, item)
|
||||
if not total_ms:
|
||||
return False
|
||||
|
||||
total_duration_seconds = self._get_total_duration(asin, item)
|
||||
if total_duration_seconds and total_duration_seconds > 0:
|
||||
position_seconds = max(0, total_duration_seconds - 10)
|
||||
else:
|
||||
position_seconds = 999999
|
||||
position_ms = total_ms
|
||||
acr = self._get_acr(asin)
|
||||
if not acr:
|
||||
return False
|
||||
|
||||
return self._update_position(asin, position_seconds)
|
||||
try:
|
||||
self.client.put(
|
||||
path=f"1.0/lastpositions/{asin}",
|
||||
body={"asin": asin, "acr": acr, "position_ms": position_ms},
|
||||
)
|
||||
if item:
|
||||
item["is_finished"] = True
|
||||
listening_status = item.get("listening_status", {})
|
||||
if isinstance(listening_status, dict):
|
||||
listening_status["is_finished"] = True
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _get_runtime_ms(self, asin: str, item: dict | None = None) -> int | None:
|
||||
"""Get total runtime in milliseconds."""
|
||||
if item:
|
||||
runtime_min = self.extract_runtime_minutes(item)
|
||||
if runtime_min:
|
||||
return runtime_min * 60 * 1000
|
||||
|
||||
def _get_total_duration_from_api(self, asin: str) -> float | None:
|
||||
"""Get total duration in seconds from API."""
|
||||
try:
|
||||
response = self.client.get(
|
||||
path=f"1.0/content/{asin}/metadata",
|
||||
response_groups="runtime",
|
||||
response_groups="chapter_info",
|
||||
)
|
||||
content_metadata = response.get("content_metadata", {})
|
||||
runtime = content_metadata.get("runtime", {})
|
||||
if isinstance(runtime, dict):
|
||||
runtime_ms = runtime.get("runtime_ms")
|
||||
if runtime_ms:
|
||||
return float(runtime_ms) / 1000.0
|
||||
return None
|
||||
except (OSError, ValueError, KeyError):
|
||||
chapter_info = response.get(
|
||||
"content_metadata", {}).get("chapter_info", {})
|
||||
return chapter_info.get("runtime_length_ms")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def mark_as_unfinished(self, asin: str) -> bool:
|
||||
"""Mark a book as unfinished by restoring saved position."""
|
||||
saved_position = self._saved_positions.pop(asin, None)
|
||||
if saved_position is None:
|
||||
saved_position = self.get_last_position(asin)
|
||||
if saved_position is None or saved_position <= 0:
|
||||
return False
|
||||
|
||||
return self._update_position(asin, saved_position)
|
||||
|
||||
def _get_total_duration_from_item(self, item: dict) -> float | None:
|
||||
"""Get total duration in seconds from library item data."""
|
||||
runtime_minutes = self.extract_runtime_minutes(item)
|
||||
if runtime_minutes:
|
||||
return float(runtime_minutes * 60)
|
||||
return None
|
||||
def _get_acr(self, asin: str) -> str | None:
|
||||
"""Get ACR token needed for position updates."""
|
||||
try:
|
||||
response = self.client.post(
|
||||
path=f"1.0/content/{asin}/licenserequest",
|
||||
body={
|
||||
"response_groups": "content_reference",
|
||||
"consumption_type": "Download",
|
||||
"drm_type": "Adrm",
|
||||
},
|
||||
)
|
||||
return response.get("content_license", {}).get("acr")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def format_time(seconds: float) -> str:
|
||||
|
||||
34
auditui/search_utils.py
Normal file
34
auditui/search_utils.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""Search helpers for filtering library items."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable
|
||||
|
||||
from .library import LibraryClient
|
||||
|
||||
|
||||
def build_search_text(item: dict, library_client: LibraryClient | None) -> str:
|
||||
"""Build a lowercase search string for an item."""
|
||||
if library_client:
|
||||
title = library_client.extract_title(item)
|
||||
authors = library_client.extract_authors(item)
|
||||
else:
|
||||
title = item.get("title", "")
|
||||
authors = ", ".join(
|
||||
a.get("name", "")
|
||||
for a in item.get("authors", [])
|
||||
if isinstance(a, dict) and a.get("name")
|
||||
)
|
||||
return f"{title} {authors}".lower()
|
||||
|
||||
|
||||
def filter_items(
|
||||
items: list[dict],
|
||||
filter_text: str,
|
||||
get_search_text: Callable[[dict], str],
|
||||
) -> list[dict]:
|
||||
"""Filter items by a search string."""
|
||||
if not filter_text:
|
||||
return items
|
||||
filter_lower = filter_text.lower()
|
||||
return [item for item in items if filter_lower in get_search_text(item)]
|
||||
@@ -2,12 +2,13 @@
|
||||
|
||||
import json
|
||||
from datetime import date, datetime
|
||||
from typing import Any, Protocol, TYPE_CHECKING, cast
|
||||
from typing import Any, Callable, Protocol, TYPE_CHECKING, cast
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Container, Horizontal, Vertical
|
||||
from textual.containers import Container, Vertical
|
||||
from textual.screen import ModalScreen
|
||||
from textual.widgets import Label, ListItem, ListView, Static
|
||||
from textual.timer import Timer
|
||||
from textual.widgets import Input, Label, ListItem, ListView, Static
|
||||
|
||||
from .constants import AUTH_PATH, CONFIG_PATH
|
||||
|
||||
@@ -37,7 +38,7 @@ KEY_COLOR = "#f9e2af"
|
||||
DESC_COLOR = "#cdd6f4"
|
||||
|
||||
|
||||
class AppContextMixin:
|
||||
class AppContextMixin(ModalScreen):
|
||||
"""Mixin to provide a typed app accessor."""
|
||||
|
||||
def _app(self) -> _AppContext:
|
||||
@@ -68,23 +69,18 @@ class HelpScreen(AppContextMixin, ModalScreen):
|
||||
"""Create a ListItem for a single binding."""
|
||||
key, description = self._parse_binding(binding)
|
||||
key_display = self._format_key_display(key)
|
||||
text = f"[bold {KEY_COLOR}]{key_display:>12}[/] [{DESC_COLOR}]{description}[/]"
|
||||
text = f"[bold {KEY_COLOR}]{key_display:>16}[/] [{DESC_COLOR}]{description:<25}[/]"
|
||||
return ListItem(Label(text))
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
app = self._app()
|
||||
bindings = list(app.BINDINGS)
|
||||
mid = (len(bindings) + 1) // 2
|
||||
|
||||
with Container(id="help_container"):
|
||||
yield Static("Key Bindings", id="help_title")
|
||||
with Horizontal(id="help_content"):
|
||||
yield Static("Keybindings", id="help_title")
|
||||
with Vertical(id="help_content"):
|
||||
yield ListView(
|
||||
*[self._make_item(b) for b in bindings[:mid]],
|
||||
classes="help_list",
|
||||
)
|
||||
yield ListView(
|
||||
*[self._make_item(b) for b in bindings[mid:]],
|
||||
*[self._make_item(b) for b in bindings],
|
||||
classes="help_list",
|
||||
)
|
||||
yield Static(
|
||||
@@ -299,6 +295,7 @@ class StatsScreen(AppContextMixin, ModalScreen):
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
def _get_email_from_config(self, app: _AppContext) -> str | None:
|
||||
"""Extract email from the config file."""
|
||||
@@ -313,6 +310,7 @@ class StatsScreen(AppContextMixin, ModalScreen):
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
def _get_email_from_auth_file(self, app: _AppContext) -> str | None:
|
||||
"""Extract email from the auth file."""
|
||||
@@ -328,6 +326,7 @@ class StatsScreen(AppContextMixin, ModalScreen):
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
def _get_email_from_account_info(self, app: _AppContext) -> str | None:
|
||||
"""Extract email from the account info API."""
|
||||
@@ -352,6 +351,7 @@ class StatsScreen(AppContextMixin, ModalScreen):
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
def _first_email(self, *values: str | None) -> str | None:
|
||||
"""Return the first non-empty, non-Unknown email value."""
|
||||
@@ -509,3 +509,59 @@ class StatsScreen(AppContextMixin, ModalScreen):
|
||||
|
||||
async def action_dismiss(self, result: Any | None = None) -> None:
|
||||
await self.dismiss(result)
|
||||
|
||||
|
||||
class FilterScreen(ModalScreen[str]):
|
||||
"""Filter screen for searching the library."""
|
||||
|
||||
BINDINGS = [("escape", "cancel", "Cancel")]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
initial_filter: str = "",
|
||||
on_change: Callable[[str], None] | None = None,
|
||||
debounce_seconds: float = 0.2,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self._initial_filter = initial_filter
|
||||
self._on_change = on_change
|
||||
self._debounce_seconds = debounce_seconds
|
||||
self._debounce_timer: Timer | None = None
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Container(id="filter_container"):
|
||||
yield Static("Filter Library", id="filter_title")
|
||||
yield Input(
|
||||
value=self._initial_filter,
|
||||
placeholder="Type to filter by title or author...",
|
||||
id="filter_input",
|
||||
)
|
||||
yield Static(
|
||||
f"Press [bold {KEY_COLOR}]Enter[/] to apply, "
|
||||
f"[bold {KEY_COLOR}]Escape[/] to clear",
|
||||
id="filter_footer",
|
||||
)
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.query_one("#filter_input", Input).focus()
|
||||
|
||||
def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||
self.dismiss(event.value)
|
||||
|
||||
def on_input_changed(self, event: Input.Changed) -> None:
|
||||
if not self._on_change:
|
||||
return
|
||||
if self._debounce_timer:
|
||||
self._debounce_timer.stop()
|
||||
value = event.value
|
||||
self._debounce_timer = self.set_timer(
|
||||
self._debounce_seconds,
|
||||
lambda: self._on_change(value),
|
||||
)
|
||||
|
||||
def action_cancel(self) -> None:
|
||||
self.dismiss("")
|
||||
|
||||
def on_unmount(self) -> None:
|
||||
if self._debounce_timer:
|
||||
self._debounce_timer.stop()
|
||||
|
||||
@@ -1,10 +1,32 @@
|
||||
[project]
|
||||
name = "auditui"
|
||||
version = "0.1.0"
|
||||
version = "0.1.4"
|
||||
description = "An Audible TUI client"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.13"
|
||||
requires-python = ">=3.10,<3.13"
|
||||
dependencies = ["audible>=0.10.0", "httpx>=0.28.1", "textual>=6.7.1"]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"coverage[toml]>=7.0",
|
||||
"pytest>=7.0",
|
||||
"pytest-asyncio>=0.23",
|
||||
"httpx>=0.28.1",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
auditui = "auditui.cli:main"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
|
||||
[tool.coverage.run]
|
||||
branch = true
|
||||
source = ["auditui"]
|
||||
|
||||
[tool.coverage.report]
|
||||
show_missing = true
|
||||
skip_covered = true
|
||||
|
||||
[tool.uv]
|
||||
package = true
|
||||
|
||||
35
tests/conftest.py
Normal file
35
tests/conftest.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
|
||||
try:
|
||||
import audible # noqa: F401
|
||||
except ModuleNotFoundError:
|
||||
audible_stub = ModuleType("audible")
|
||||
|
||||
class Authenticator: # minimal stub for type usage
|
||||
pass
|
||||
|
||||
class Client: # minimal stub for type usage
|
||||
pass
|
||||
|
||||
audible_stub.Authenticator = Authenticator
|
||||
audible_stub.Client = Client
|
||||
|
||||
activation_bytes = ModuleType("audible.activation_bytes")
|
||||
|
||||
def get_activation_bytes(_auth: Authenticator | None = None) -> bytes:
|
||||
return b""
|
||||
|
||||
activation_bytes.get_activation_bytes = get_activation_bytes
|
||||
|
||||
sys.modules["audible"] = audible_stub
|
||||
sys.modules["audible.activation_bytes"] = activation_bytes
|
||||
50
tests/test_app_filter.py
Normal file
50
tests/test_app_filter.py
Normal file
@@ -0,0 +1,50 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from auditui.app import Auditui
|
||||
from auditui.search_utils import build_search_text, filter_items
|
||||
|
||||
|
||||
class StubLibrary:
|
||||
def extract_title(self, item: dict) -> str:
|
||||
return item.get("title", "")
|
||||
|
||||
def extract_authors(self, item: dict) -> str:
|
||||
return item.get("authors", "")
|
||||
|
||||
|
||||
def test_get_search_text_is_cached() -> None:
|
||||
class Dummy:
|
||||
def __init__(self) -> None:
|
||||
self._search_text_cache: dict[int, str] = {}
|
||||
self.library_client = StubLibrary()
|
||||
|
||||
item = {"title": "Title", "authors": "Author"}
|
||||
dummy = Dummy()
|
||||
first = Auditui._get_search_text(dummy, item)
|
||||
second = Auditui._get_search_text(dummy, item)
|
||||
assert first == "title author"
|
||||
assert first == second
|
||||
assert len(dummy._search_text_cache) == 1
|
||||
|
||||
|
||||
def test_filter_items_uses_cache() -> None:
|
||||
library = StubLibrary()
|
||||
cache: dict[int, str] = {}
|
||||
items = [
|
||||
{"title": "Alpha", "authors": "Author One"},
|
||||
{"title": "Beta", "authors": "Author Two"},
|
||||
]
|
||||
|
||||
def cached(item: dict) -> str:
|
||||
cache_key = id(item)
|
||||
if cache_key not in cache:
|
||||
cache[cache_key] = build_search_text(item, library)
|
||||
return cache[cache_key]
|
||||
|
||||
result = filter_items(items, "beta", cached)
|
||||
assert result == [items[1]]
|
||||
|
||||
|
||||
def test_build_search_text_without_library() -> None:
|
||||
item = {"title": "Title", "authors": [{"name": "A"}, {"name": "B"}]}
|
||||
assert build_search_text(item, None) == "title a, b"
|
||||
48
tests/test_downloads.py
Normal file
48
tests/test_downloads.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from auditui import downloads
|
||||
from auditui.constants import MIN_FILE_SIZE
|
||||
|
||||
|
||||
def test_sanitize_filename() -> None:
|
||||
dm = downloads.DownloadManager.__new__(downloads.DownloadManager)
|
||||
assert dm._sanitize_filename('a<>:"/\\|?*b') == "a_________b"
|
||||
|
||||
|
||||
def test_validate_download_url() -> None:
|
||||
dm = downloads.DownloadManager.__new__(downloads.DownloadManager)
|
||||
assert dm._validate_download_url("https://example.com/file") is True
|
||||
assert dm._validate_download_url("http://example.com/file") is True
|
||||
assert dm._validate_download_url("ftp://example.com/file") is False
|
||||
|
||||
|
||||
def test_cached_path_and_remove(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
dm = downloads.DownloadManager.__new__(downloads.DownloadManager)
|
||||
dm.cache_dir = tmp_path
|
||||
|
||||
monkeypatch.setattr(dm, "_get_name_from_asin", lambda asin: "My Book")
|
||||
safe_name = dm._sanitize_filename("My Book")
|
||||
cached_path = tmp_path / f"{safe_name}.aax"
|
||||
cached_path.write_bytes(b"0" * MIN_FILE_SIZE)
|
||||
|
||||
assert dm.get_cached_path("ASIN123") == cached_path
|
||||
assert dm.is_cached("ASIN123") is True
|
||||
|
||||
messages: list[str] = []
|
||||
assert dm.remove_cached("ASIN123", notify=messages.append) is True
|
||||
assert not cached_path.exists()
|
||||
assert messages and "Removed from cache" in messages[-1]
|
||||
|
||||
|
||||
def test_cached_path_ignores_small_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
dm = downloads.DownloadManager.__new__(downloads.DownloadManager)
|
||||
dm.cache_dir = tmp_path
|
||||
|
||||
monkeypatch.setattr(dm, "_get_name_from_asin", lambda asin: "My Book")
|
||||
safe_name = dm._sanitize_filename("My Book")
|
||||
cached_path = tmp_path / f"{safe_name}.aax"
|
||||
cached_path.write_bytes(b"0" * (MIN_FILE_SIZE - 1))
|
||||
|
||||
assert dm.get_cached_path("ASIN123") is None
|
||||
129
tests/test_library.py
Normal file
129
tests/test_library.py
Normal file
@@ -0,0 +1,129 @@
|
||||
from auditui.library import LibraryClient
|
||||
|
||||
|
||||
class MockClient:
|
||||
def __init__(self) -> None:
|
||||
self.put_calls: list[tuple[str, dict]] = []
|
||||
self.post_calls: list[tuple[str, dict]] = []
|
||||
self._post_response: dict = {}
|
||||
self.raise_on_put = False
|
||||
|
||||
def put(self, path: str, body: dict) -> dict:
|
||||
if self.raise_on_put:
|
||||
raise RuntimeError("put failed")
|
||||
self.put_calls.append((path, body))
|
||||
return {}
|
||||
|
||||
def post(self, path: str, body: dict) -> dict:
|
||||
self.post_calls.append((path, body))
|
||||
return self._post_response
|
||||
|
||||
def get(self, path: str, **kwargs: dict) -> dict:
|
||||
return {}
|
||||
|
||||
|
||||
def test_extract_title_prefers_product() -> None:
|
||||
client = MockClient()
|
||||
library = LibraryClient(client) # type: ignore[arg-type]
|
||||
item = build_item(title="Outer", product_title="Inner")
|
||||
assert library.extract_title(item) == "Inner"
|
||||
|
||||
|
||||
def test_extract_authors_joins_names() -> None:
|
||||
client = MockClient()
|
||||
library = LibraryClient(client) # type: ignore[arg-type]
|
||||
item = build_item(authors=[{"name": "A"}, {"name": "B"}])
|
||||
assert library.extract_authors(item) == "A, B"
|
||||
|
||||
|
||||
def test_extract_runtime_minutes_from_dict() -> None:
|
||||
client = MockClient()
|
||||
library = LibraryClient(client) # type: ignore[arg-type]
|
||||
item = build_item(runtime_min=12)
|
||||
assert library.extract_runtime_minutes(item) == 12
|
||||
|
||||
|
||||
def test_extract_progress_info_from_listening_status() -> None:
|
||||
client = MockClient()
|
||||
library = LibraryClient(client) # type: ignore[arg-type]
|
||||
item = build_item(listening_status={"percent_complete": 25.0})
|
||||
assert library.extract_progress_info(item) == 25.0
|
||||
|
||||
|
||||
def test_is_finished_with_percent_complete() -> None:
|
||||
client = MockClient()
|
||||
library = LibraryClient(client) # type: ignore[arg-type]
|
||||
item = build_item(percent_complete=100)
|
||||
assert library.is_finished(item)
|
||||
|
||||
|
||||
def test_format_duration_and_time() -> None:
|
||||
client = MockClient()
|
||||
library = LibraryClient(client) # type: ignore[arg-type]
|
||||
assert library.format_duration(61) == "1h01"
|
||||
assert library.format_time(3661) == "01:01:01"
|
||||
|
||||
|
||||
def test_mark_as_finished_success_updates_item() -> None:
|
||||
client = MockClient()
|
||||
client._post_response = {"content_license": {"acr": "token"}}
|
||||
library = LibraryClient(client) # type: ignore[arg-type]
|
||||
item = build_item(runtime_min=1, listening_status={})
|
||||
ok = library.mark_as_finished("ASIN", item)
|
||||
assert ok
|
||||
assert client.put_calls
|
||||
path, body = client.put_calls[0]
|
||||
assert path == "1.0/lastpositions/ASIN"
|
||||
assert body["acr"] == "token"
|
||||
assert body["position_ms"] == 60_000
|
||||
assert item["is_finished"] is True
|
||||
assert item["listening_status"]["is_finished"] is True
|
||||
|
||||
|
||||
def test_mark_as_finished_fails_without_acr() -> None:
|
||||
client = MockClient()
|
||||
client._post_response = {}
|
||||
library = LibraryClient(client) # type: ignore[arg-type]
|
||||
item = build_item(runtime_min=1)
|
||||
ok = library.mark_as_finished("ASIN", item)
|
||||
assert ok is False
|
||||
|
||||
|
||||
def test_mark_as_finished_handles_put_error() -> None:
|
||||
client = MockClient()
|
||||
client._post_response = {"content_license": {"acr": "token"}}
|
||||
client.raise_on_put = True
|
||||
library = LibraryClient(client) # type: ignore[arg-type]
|
||||
item = build_item(runtime_min=1)
|
||||
ok = library.mark_as_finished("ASIN", item)
|
||||
assert ok is False
|
||||
|
||||
|
||||
def build_item(
|
||||
*,
|
||||
title: str | None = None,
|
||||
product_title: str | None = None,
|
||||
authors: list[dict] | None = None,
|
||||
runtime_min: int | None = None,
|
||||
listening_status: dict | None = None,
|
||||
percent_complete: int | float | None = None,
|
||||
) -> dict:
|
||||
item: dict = {}
|
||||
if title is not None:
|
||||
item["title"] = title
|
||||
if percent_complete is not None:
|
||||
item["percent_complete"] = percent_complete
|
||||
if listening_status is not None:
|
||||
item["listening_status"] = listening_status
|
||||
product: dict = {}
|
||||
if product_title is not None:
|
||||
product["title"] = product_title
|
||||
if runtime_min is not None:
|
||||
product["runtime_length"] = {"min": runtime_min}
|
||||
if authors is not None:
|
||||
product["authors"] = authors
|
||||
if product:
|
||||
item["product"] = product
|
||||
if runtime_min is not None and "runtime_length_min" not in item:
|
||||
item["runtime_length_min"] = runtime_min
|
||||
return item
|
||||
80
tests/test_table_utils.py
Normal file
80
tests/test_table_utils.py
Normal file
@@ -0,0 +1,80 @@
|
||||
from auditui import table_utils
|
||||
|
||||
|
||||
class StubLibrary:
|
||||
def extract_title(self, item: dict) -> str:
|
||||
return item.get("title", "")
|
||||
|
||||
def extract_authors(self, item: dict) -> str:
|
||||
return item.get("authors", "")
|
||||
|
||||
def extract_runtime_minutes(self, item: dict) -> int | None:
|
||||
return item.get("minutes")
|
||||
|
||||
def format_duration(
|
||||
self, value: int | None, unit: str = "minutes", default_none: str | None = None
|
||||
) -> str | None:
|
||||
if value is None:
|
||||
return default_none
|
||||
return f"{value}m"
|
||||
|
||||
def extract_progress_info(self, item: dict) -> float | None:
|
||||
return item.get("percent")
|
||||
|
||||
def extract_asin(self, item: dict) -> str | None:
|
||||
return item.get("asin")
|
||||
|
||||
|
||||
class StubDownloads:
|
||||
def __init__(self, cached: set[str]) -> None:
|
||||
self._cached = cached
|
||||
|
||||
def is_cached(self, asin: str) -> bool:
|
||||
return asin in self._cached
|
||||
|
||||
|
||||
def test_create_title_sort_key_normalizes_accents() -> None:
|
||||
key_fn, _ = table_utils.create_title_sort_key()
|
||||
assert key_fn(["École"]) == "ecole"
|
||||
assert key_fn(["Zoo"]) == "zoo"
|
||||
|
||||
|
||||
def test_create_progress_sort_key_parses_percent() -> None:
|
||||
key_fn, _ = table_utils.create_progress_sort_key()
|
||||
assert key_fn(["0", "0", "0", "42.5%"]) == 42.5
|
||||
assert key_fn(["0", "0", "0", "bad"]) == 0.0
|
||||
|
||||
|
||||
def test_truncate_author_name() -> None:
|
||||
long_name = "A" * (table_utils.AUTHOR_NAME_MAX_LENGTH + 5)
|
||||
truncated = table_utils.truncate_author_name(long_name)
|
||||
assert truncated.endswith("...")
|
||||
assert len(truncated) <= table_utils.AUTHOR_NAME_MAX_LENGTH
|
||||
|
||||
|
||||
def test_format_item_as_row_with_downloaded() -> None:
|
||||
library = StubLibrary()
|
||||
downloads = StubDownloads({"ASIN123"})
|
||||
item = {
|
||||
"title": "Title",
|
||||
"authors": "Author One",
|
||||
"minutes": 90,
|
||||
"percent": 12.34,
|
||||
"asin": "ASIN123",
|
||||
}
|
||||
title, author, runtime, progress, downloaded = table_utils.format_item_as_row(
|
||||
item, library, downloads
|
||||
)
|
||||
assert title == "Title"
|
||||
assert author == "Author One"
|
||||
assert runtime == "90m"
|
||||
assert progress == "12.3%"
|
||||
assert downloaded == "âś“"
|
||||
|
||||
|
||||
def test_format_item_as_row_zero_progress() -> None:
|
||||
library = StubLibrary()
|
||||
item = {"title": "Title", "authors": "Author",
|
||||
"minutes": 30, "percent": 0.0}
|
||||
_, _, _, progress, _ = table_utils.format_item_as_row(item, library, None)
|
||||
assert progress == "0%"
|
||||
62
tests/test_ui_email.py
Normal file
62
tests/test_ui_email.py
Normal file
@@ -0,0 +1,62 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from auditui import ui
|
||||
|
||||
|
||||
class DummyApp:
|
||||
def __init__(self) -> None:
|
||||
self.client = None
|
||||
self.auth = None
|
||||
self.library_client = None
|
||||
self.all_items = []
|
||||
self.BINDINGS = []
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dummy_app() -> DummyApp:
|
||||
return DummyApp()
|
||||
|
||||
|
||||
def test_find_email_in_data() -> None:
|
||||
screen = ui.StatsScreen()
|
||||
data = {"a": {"b": ["nope", "user@example.com"]}}
|
||||
assert screen._find_email_in_data(data) == "user@example.com"
|
||||
|
||||
|
||||
def test_get_email_from_config(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, dummy_app: DummyApp
|
||||
) -> None:
|
||||
screen = ui.StatsScreen()
|
||||
config_path = tmp_path / "config.json"
|
||||
config_path.write_text(json.dumps({"email": "config@example.com"}))
|
||||
monkeypatch.setattr(ui, "CONFIG_PATH", config_path)
|
||||
|
||||
email = screen._get_email_from_config(dummy_app)
|
||||
assert email == "config@example.com"
|
||||
|
||||
|
||||
def test_get_email_from_auth_file(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, dummy_app: DummyApp
|
||||
) -> None:
|
||||
screen = ui.StatsScreen()
|
||||
auth_path = tmp_path / "auth.json"
|
||||
auth_path.write_text(json.dumps({"email": "auth@example.com"}))
|
||||
monkeypatch.setattr(ui, "AUTH_PATH", auth_path)
|
||||
|
||||
email = screen._get_email_from_auth_file(dummy_app)
|
||||
assert email == "auth@example.com"
|
||||
|
||||
|
||||
def test_get_email_from_auth(dummy_app: DummyApp) -> None:
|
||||
screen = ui.StatsScreen()
|
||||
|
||||
class Auth:
|
||||
username = "user@example.com"
|
||||
login = None
|
||||
email = None
|
||||
|
||||
dummy_app.auth = Auth()
|
||||
assert screen._get_email_from_auth(dummy_app) == "user@example.com"
|
||||
44
tests/test_ui_filter.py
Normal file
44
tests/test_ui_filter.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from auditui.ui import FilterScreen
|
||||
|
||||
|
||||
class DummyEvent:
|
||||
def __init__(self, value: str) -> None:
|
||||
self.value = value
|
||||
|
||||
|
||||
class FakeTimer:
|
||||
def __init__(self, callback) -> None:
|
||||
self.callback = callback
|
||||
self.stopped = False
|
||||
|
||||
def stop(self) -> None:
|
||||
self.stopped = True
|
||||
|
||||
|
||||
def test_filter_debounce_uses_latest_value(monkeypatch) -> None:
|
||||
seen: list[str] = []
|
||||
timers: list[FakeTimer] = []
|
||||
|
||||
def on_change(value: str) -> None:
|
||||
seen.append(value)
|
||||
|
||||
screen = FilterScreen(on_change=on_change, debounce_seconds=0.2)
|
||||
|
||||
def fake_set_timer(_delay: float, callback):
|
||||
timer = FakeTimer(callback)
|
||||
timers.append(timer)
|
||||
return timer
|
||||
|
||||
monkeypatch.setattr(screen, "set_timer", fake_set_timer)
|
||||
|
||||
screen.on_input_changed(DummyEvent("a"))
|
||||
screen.on_input_changed(DummyEvent("ab"))
|
||||
|
||||
assert len(timers) == 2
|
||||
assert timers[0].stopped is True
|
||||
assert timers[1].stopped is False
|
||||
|
||||
timers[1].callback()
|
||||
assert seen == ["ab"]
|
||||
277
uv.lock
generated
277
uv.lock
generated
@@ -1,13 +1,15 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.13"
|
||||
requires-python = ">=3.10, <3.13"
|
||||
|
||||
[[package]]
|
||||
name = "anyio"
|
||||
version = "4.12.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
|
||||
{ name = "idna" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/16/ce/8a777047513153587e5434fd752e89334ac33e379aa3497db860eeb60377/anyio-4.12.0.tar.gz", hash = "sha256:73c693b567b0c55130c104d0b43a9baf3aa6a31fc6110116509f27bf75e21ec0", size = 228266, upload-time = "2025-11-28T23:37:38.911Z" }
|
||||
wheels = [
|
||||
@@ -33,20 +35,42 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "auditui"
|
||||
version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
version = "0.1.4"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "audible" },
|
||||
{ name = "httpx" },
|
||||
{ name = "textual" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
dev = [
|
||||
{ name = "coverage", extra = ["toml"] },
|
||||
{ name = "httpx" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "audible", specifier = ">=0.10.0" },
|
||||
{ name = "coverage", extras = ["toml"], marker = "extra == 'dev'", specifier = ">=7.0" },
|
||||
{ name = "httpx", specifier = ">=0.28.1" },
|
||||
{ name = "httpx", marker = "extra == 'dev'", specifier = ">=0.28.1" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0" },
|
||||
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" },
|
||||
{ name = "textual", specifier = ">=6.7.1" },
|
||||
]
|
||||
provides-extras = ["dev"]
|
||||
|
||||
[[package]]
|
||||
name = "backports-asyncio-runner"
|
||||
version = "1.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "beautifulsoup4"
|
||||
@@ -70,6 +94,79 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coverage"
|
||||
version = "7.13.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/23/f9/e92df5e07f3fc8d4c7f9a0f146ef75446bf870351cd37b788cf5897f8079/coverage-7.13.1.tar.gz", hash = "sha256:b7593fe7eb5feaa3fbb461ac79aac9f9fc0387a5ca8080b0c6fe2ca27b091afd", size = 825862, upload-time = "2025-12-28T15:42:56.969Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/9a/3742e58fd04b233df95c012ee9f3dfe04708a5e1d32613bd2d47d4e1be0d/coverage-7.13.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e1fa280b3ad78eea5be86f94f461c04943d942697e0dac889fa18fff8f5f9147", size = 218633, upload-time = "2025-12-28T15:40:10.165Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/45/7e6bdc94d89cd7c8017ce735cf50478ddfe765d4fbf0c24d71d30ea33d7a/coverage-7.13.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c3d8c679607220979434f494b139dfb00131ebf70bb406553d69c1ff01a5c33d", size = 219147, upload-time = "2025-12-28T15:40:12.069Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/38/0d6a258625fd7f10773fe94097dc16937a5f0e3e0cdf3adef67d3ac6baef/coverage-7.13.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:339dc63b3eba969067b00f41f15ad161bf2946613156fb131266d8debc8e44d0", size = 245894, upload-time = "2025-12-28T15:40:13.556Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/58/409d15ea487986994cbd4d06376e9860e9b157cfbfd402b1236770ab8dd2/coverage-7.13.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:db622b999ffe49cb891f2fff3b340cdc2f9797d01a0a202a0973ba2562501d90", size = 247721, upload-time = "2025-12-28T15:40:15.37Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/bf/6e8056a83fd7a96c93341f1ffe10df636dd89f26d5e7b9ca511ce3bcf0df/coverage-7.13.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1443ba9acbb593fa7c1c29e011d7c9761545fe35e7652e85ce7f51a16f7e08d", size = 249585, upload-time = "2025-12-28T15:40:17.226Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/15/e1daff723f9f5959acb63cbe35b11203a9df77ee4b95b45fffd38b318390/coverage-7.13.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c832ec92c4499ac463186af72f9ed4d8daec15499b16f0a879b0d1c8e5cf4a3b", size = 246597, upload-time = "2025-12-28T15:40:19.028Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/a6/1efd31c5433743a6ddbc9d37ac30c196bb07c7eab3d74fbb99b924c93174/coverage-7.13.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:562ec27dfa3f311e0db1ba243ec6e5f6ab96b1edfcfc6cf86f28038bc4961ce6", size = 247626, upload-time = "2025-12-28T15:40:20.846Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/9f/1609267dd3e749f57fdd66ca6752567d1c13b58a20a809dc409b263d0b5f/coverage-7.13.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:4de84e71173d4dada2897e5a0e1b7877e5eefbfe0d6a44edee6ce31d9b8ec09e", size = 245629, upload-time = "2025-12-28T15:40:22.397Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/f6/6815a220d5ec2466383d7cc36131b9fa6ecbe95c50ec52a631ba733f306a/coverage-7.13.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:a5a68357f686f8c4d527a2dc04f52e669c2fc1cbde38f6f7eb6a0e58cbd17cae", size = 245901, upload-time = "2025-12-28T15:40:23.836Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/58/40576554cd12e0872faf6d2c0eb3bc85f71d78427946ddd19ad65201e2c0/coverage-7.13.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:77cc258aeb29a3417062758975521eae60af6f79e930d6993555eeac6a8eac29", size = 246505, upload-time = "2025-12-28T15:40:25.421Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/77/9233a90253fba576b0eee81707b5781d0e21d97478e5377b226c5b096c0f/coverage-7.13.1-cp310-cp310-win32.whl", hash = "sha256:bb4f8c3c9a9f34423dba193f241f617b08ffc63e27f67159f60ae6baf2dcfe0f", size = 221257, upload-time = "2025-12-28T15:40:27.217Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/43/e842ff30c1a0a623ec80db89befb84a3a7aad7bfe44a6ea77d5a3e61fedd/coverage-7.13.1-cp310-cp310-win_amd64.whl", hash = "sha256:c8e2706ceb622bc63bac98ebb10ef5da80ed70fbd8a7999a5076de3afaef0fb1", size = 222191, upload-time = "2025-12-28T15:40:28.916Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/9b/77baf488516e9ced25fc215a6f75d803493fc3f6a1a1227ac35697910c2a/coverage-7.13.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a55d509a1dc5a5b708b5dad3b5334e07a16ad4c2185e27b40e4dba796ab7f88", size = 218755, upload-time = "2025-12-28T15:40:30.812Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/cd/7ab01154e6eb79ee2fab76bf4d89e94c6648116557307ee4ebbb85e5c1bf/coverage-7.13.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4d010d080c4888371033baab27e47c9df7d6fb28d0b7b7adf85a4a49be9298b3", size = 219257, upload-time = "2025-12-28T15:40:32.333Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/d5/b11ef7863ffbbdb509da0023fad1e9eda1c0eaea61a6d2ea5b17d4ac706e/coverage-7.13.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d938b4a840fb1523b9dfbbb454f652967f18e197569c32266d4d13f37244c3d9", size = 249657, upload-time = "2025-12-28T15:40:34.1Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/7c/347280982982383621d29b8c544cf497ae07ac41e44b1ca4903024131f55/coverage-7.13.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bf100a3288f9bb7f919b87eb84f87101e197535b9bd0e2c2b5b3179633324fee", size = 251581, upload-time = "2025-12-28T15:40:36.131Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/f6/ebcfed11036ade4c0d75fa4453a6282bdd225bc073862766eec184a4c643/coverage-7.13.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef6688db9bf91ba111ae734ba6ef1a063304a881749726e0d3575f5c10a9facf", size = 253691, upload-time = "2025-12-28T15:40:37.626Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/92/af8f5582787f5d1a8b130b2dcba785fa5e9a7a8e121a0bb2220a6fdbdb8a/coverage-7.13.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0b609fc9cdbd1f02e51f67f51e5aee60a841ef58a68d00d5ee2c0faf357481a3", size = 249799, upload-time = "2025-12-28T15:40:39.47Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/aa/0e39a2a3b16eebf7f193863323edbff38b6daba711abaaf807d4290cf61a/coverage-7.13.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c43257717611ff5e9a1d79dce8e47566235ebda63328718d9b65dd640bc832ef", size = 251389, upload-time = "2025-12-28T15:40:40.954Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/46/7f0c13111154dc5b978900c0ccee2e2ca239b910890e674a77f1363d483e/coverage-7.13.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e09fbecc007f7b6afdfb3b07ce5bd9f8494b6856dd4f577d26c66c391b829851", size = 249450, upload-time = "2025-12-28T15:40:42.489Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/ca/e80da6769e8b669ec3695598c58eef7ad98b0e26e66333996aee6316db23/coverage-7.13.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a03a4f3a19a189919c7055098790285cc5c5b0b3976f8d227aea39dbf9f8bfdb", size = 249170, upload-time = "2025-12-28T15:40:44.279Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/18/9e29baabdec1a8644157f572541079b4658199cfd372a578f84228e860de/coverage-7.13.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3820778ea1387c2b6a818caec01c63adc5b3750211af6447e8dcfb9b6f08dbba", size = 250081, upload-time = "2025-12-28T15:40:45.748Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/f8/c3021625a71c3b2f516464d322e41636aea381018319050a8114105872ee/coverage-7.13.1-cp311-cp311-win32.whl", hash = "sha256:ff10896fa55167371960c5908150b434b71c876dfab97b69478f22c8b445ea19", size = 221281, upload-time = "2025-12-28T15:40:47.232Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/56/c216625f453df6e0559ed666d246fcbaaa93f3aa99eaa5080cea1229aa3d/coverage-7.13.1-cp311-cp311-win_amd64.whl", hash = "sha256:a998cc0aeeea4c6d5622a3754da5a493055d2d95186bad877b0a34ea6e6dbe0a", size = 222215, upload-time = "2025-12-28T15:40:49.19Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/9a/be342e76f6e531cae6406dc46af0d350586f24d9b67fdfa6daee02df71af/coverage-7.13.1-cp311-cp311-win_arm64.whl", hash = "sha256:fea07c1a39a22614acb762e3fbbb4011f65eedafcb2948feeef641ac78b4ee5c", size = 220886, upload-time = "2025-12-28T15:40:51.067Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/8a/87af46cccdfa78f53db747b09f5f9a21d5fc38d796834adac09b30a8ce74/coverage-7.13.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6f34591000f06e62085b1865c9bc5f7858df748834662a51edadfd2c3bfe0dd3", size = 218927, upload-time = "2025-12-28T15:40:52.814Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/a8/6e22fdc67242a4a5a153f9438d05944553121c8f4ba70cb072af4c41362e/coverage-7.13.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b67e47c5595b9224599016e333f5ec25392597a89d5744658f837d204e16c63e", size = 219288, upload-time = "2025-12-28T15:40:54.262Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/0a/853a76e03b0f7c4375e2ca025df45c918beb367f3e20a0a8e91967f6e96c/coverage-7.13.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3e7b8bd70c48ffb28461ebe092c2345536fb18bbbf19d287c8913699735f505c", size = 250786, upload-time = "2025-12-28T15:40:56.059Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/b4/694159c15c52b9f7ec7adf49d50e5f8ee71d3e9ef38adb4445d13dd56c20/coverage-7.13.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c223d078112e90dc0e5c4e35b98b9584164bea9fbbd221c0b21c5241f6d51b62", size = 253543, upload-time = "2025-12-28T15:40:57.585Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/b2/7f1f0437a5c855f87e17cf5d0dc35920b6440ff2b58b1ba9788c059c26c8/coverage-7.13.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:794f7c05af0763b1bbd1b9e6eff0e52ad068be3b12cd96c87de037b01390c968", size = 254635, upload-time = "2025-12-28T15:40:59.443Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/d1/73c3fdb8d7d3bddd9473c9c6a2e0682f09fc3dfbcb9c3f36412a7368bcab/coverage-7.13.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0642eae483cc8c2902e4af7298bf886d605e80f26382124cddc3967c2a3df09e", size = 251202, upload-time = "2025-12-28T15:41:01.328Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/3c/f0edf75dcc152f145d5598329e864bbbe04ab78660fe3e8e395f9fff010f/coverage-7.13.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f5e772ed5fef25b3de9f2008fe67b92d46831bd2bc5bdc5dd6bfd06b83b316f", size = 252566, upload-time = "2025-12-28T15:41:03.319Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/b3/e64206d3c5f7dcbceafd14941345a754d3dbc78a823a6ed526e23b9cdaab/coverage-7.13.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:45980ea19277dc0a579e432aef6a504fe098ef3a9032ead15e446eb0f1191aee", size = 250711, upload-time = "2025-12-28T15:41:06.411Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/ad/28a3eb970a8ef5b479ee7f0c484a19c34e277479a5b70269dc652b730733/coverage-7.13.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f18eca6028ffa62adbd185a8f1e1dd242f2e68164dba5c2b74a5204850b4cf", size = 250278, upload-time = "2025-12-28T15:41:08.285Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/e3/c8f0f1a93133e3e1291ca76cbb63565bd4b5c5df63b141f539d747fff348/coverage-7.13.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8dca5590fec7a89ed6826fce625595279e586ead52e9e958d3237821fbc750c", size = 252154, upload-time = "2025-12-28T15:41:09.969Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/bf/9939c5d6859c380e405b19e736321f1c7d402728792f4c752ad1adcce005/coverage-7.13.1-cp312-cp312-win32.whl", hash = "sha256:ff86d4e85188bba72cfb876df3e11fa243439882c55957184af44a35bd5880b7", size = 221487, upload-time = "2025-12-28T15:41:11.468Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/dc/7282856a407c621c2aad74021680a01b23010bb8ebf427cf5eacda2e876f/coverage-7.13.1-cp312-cp312-win_amd64.whl", hash = "sha256:16cc1da46c04fb0fb128b4dc430b78fa2aba8a6c0c9f8eb391fd5103409a6ac6", size = 222299, upload-time = "2025-12-28T15:41:13.386Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/79/176a11203412c350b3e9578620013af35bcdb79b651eb976f4a4b32044fa/coverage-7.13.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d9bc218650022a768f3775dd7fdac1886437325d8d295d923ebcfef4892ad5c", size = 220941, upload-time = "2025-12-28T15:41:14.975Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/48/d9f421cb8da5afaa1a64570d9989e00fb7955e6acddc5a12979f7666ef60/coverage-7.13.1-py3-none-any.whl", hash = "sha256:2016745cb3ba554469d02819d78958b571792bb68e31302610e898f80dd3a573", size = 210722, upload-time = "2025-12-28T15:42:54.901Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
toml = [
|
||||
{ name = "tomli", marker = "python_full_version <= '3.11'" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "exceptiongroup"
|
||||
version = "1.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.16.0"
|
||||
@@ -116,6 +213,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linkify-it-py"
|
||||
version = "2.0.3"
|
||||
@@ -166,6 +272,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "25.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pbkdf2"
|
||||
version = "1.3"
|
||||
@@ -178,56 +293,46 @@ version = "12.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/cace85a1b0c9775a9f8f5d5423c8261c858760e2466c79b2dd184638b056/pillow-12.0.0.tar.gz", hash = "sha256:87d4f8125c9988bfbed67af47dd7a953e2fc7b0cc1e7800ec6d2080d490bb353", size = 47008828, upload-time = "2025-10-15T18:24:14.008Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/62/f2/de993bb2d21b33a98d031ecf6a978e4b61da207bef02f7b43093774c480d/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0869154a2d0546545cde61d1789a6524319fc1897d9ee31218eae7a60ccc5643", size = 4045493, upload-time = "2025-10-15T18:22:25.758Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/b6/bc8d0c4c9f6f111a783d045310945deb769b806d7574764234ffd50bc5ea/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a7921c5a6d31b3d756ec980f2f47c0cfdbce0fc48c22a39347a895f41f4a6ea4", size = 4120461, upload-time = "2025-10-15T18:22:27.286Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/57/d60d343709366a353dc56adb4ee1e7d8a2cc34e3fbc22905f4167cfec119/pillow-12.0.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1ee80a59f6ce048ae13cda1abf7fbd2a34ab9ee7d401c46be3ca685d1999a399", size = 3576912, upload-time = "2025-10-15T18:22:28.751Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/a4/a0a31467e3f83b94d37568294b01d22b43ae3c5d85f2811769b9c66389dd/pillow-12.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c50f36a62a22d350c96e49ad02d0da41dbd17ddc2e29750dbdba4323f85eb4a5", size = 5249132, upload-time = "2025-10-15T18:22:30.641Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/06/48eab21dd561de2914242711434c0c0eb992ed08ff3f6107a5f44527f5e9/pillow-12.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5193fde9a5f23c331ea26d0cf171fbf67e3f247585f50c08b3e205c7aeb4589b", size = 4650099, upload-time = "2025-10-15T18:22:32.73Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/bd/69ed99fd46a8dba7c1887156d3572fe4484e3f031405fcc5a92e31c04035/pillow-12.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bde737cff1a975b70652b62d626f7785e0480918dece11e8fef3c0cf057351c3", size = 6230808, upload-time = "2025-10-15T18:22:34.337Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/94/8fad659bcdbf86ed70099cb60ae40be6acca434bbc8c4c0d4ef356d7e0de/pillow-12.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a6597ff2b61d121172f5844b53f21467f7082f5fb385a9a29c01414463f93b07", size = 8037804, upload-time = "2025-10-15T18:22:36.402Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/39/c685d05c06deecfd4e2d1950e9a908aa2ca8bc4e6c3b12d93b9cafbd7837/pillow-12.0.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b817e7035ea7f6b942c13aa03bb554fc44fea70838ea21f8eb31c638326584e", size = 6345553, upload-time = "2025-10-15T18:22:38.066Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/57/755dbd06530a27a5ed74f8cb0a7a44a21722ebf318edbe67ddbd7fb28f88/pillow-12.0.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4f1231b7dec408e8670264ce63e9c71409d9583dd21d32c163e25213ee2a344", size = 7037729, upload-time = "2025-10-15T18:22:39.769Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/b6/7e94f4c41d238615674d06ed677c14883103dce1c52e4af16f000338cfd7/pillow-12.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e51b71417049ad6ab14c49608b4a24d8fb3fe605e5dfabfe523b58064dc3d27", size = 6459789, upload-time = "2025-10-15T18:22:41.437Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/14/4448bb0b5e0f22dd865290536d20ec8a23b64e2d04280b89139f09a36bb6/pillow-12.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d120c38a42c234dc9a8c5de7ceaaf899cf33561956acb4941653f8bdc657aa79", size = 7130917, upload-time = "2025-10-15T18:22:43.152Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/ca/16c6926cc1c015845745d5c16c9358e24282f1e588237a4c36d2b30f182f/pillow-12.0.0-cp313-cp313-win32.whl", hash = "sha256:4cc6b3b2efff105c6a1656cfe59da4fdde2cda9af1c5e0b58529b24525d0a098", size = 6302391, upload-time = "2025-10-15T18:22:44.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/2a/dd43dcfd6dae9b6a49ee28a8eedb98c7d5ff2de94a5d834565164667b97b/pillow-12.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:4cf7fed4b4580601c4345ceb5d4cbf5a980d030fd5ad07c4d2ec589f95f09905", size = 7007477, upload-time = "2025-10-15T18:22:46.838Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/f0/72ea067f4b5ae5ead653053212af05ce3705807906ba3f3e8f58ddf617e6/pillow-12.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:9f0b04c6b8584c2c193babcccc908b38ed29524b29dd464bc8801bf10d746a3a", size = 2435918, upload-time = "2025-10-15T18:22:48.399Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/5e/9046b423735c21f0487ea6cb5b10f89ea8f8dfbe32576fe052b5ba9d4e5b/pillow-12.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7fa22993bac7b77b78cae22bad1e2a987ddf0d9015c63358032f84a53f23cdc3", size = 5251406, upload-time = "2025-10-15T18:22:49.905Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/66/982ceebcdb13c97270ef7a56c3969635b4ee7cd45227fa707c94719229c5/pillow-12.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f135c702ac42262573fe9714dfe99c944b4ba307af5eb507abef1667e2cbbced", size = 4653218, upload-time = "2025-10-15T18:22:51.587Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/b3/81e625524688c31859450119bf12674619429cab3119eec0e30a7a1029cb/pillow-12.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c85de1136429c524e55cfa4e033b4a7940ac5c8ee4d9401cc2d1bf48154bbc7b", size = 6266564, upload-time = "2025-10-15T18:22:53.215Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/59/dfb38f2a41240d2408096e1a76c671d0a105a4a8471b1871c6902719450c/pillow-12.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:38df9b4bfd3db902c9c2bd369bcacaf9d935b2fff73709429d95cc41554f7b3d", size = 8069260, upload-time = "2025-10-15T18:22:54.933Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/3d/378dbea5cd1874b94c312425ca77b0f47776c78e0df2df751b820c8c1d6c/pillow-12.0.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d87ef5795da03d742bf49439f9ca4d027cde49c82c5371ba52464aee266699a", size = 6379248, upload-time = "2025-10-15T18:22:56.605Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/b0/d525ef47d71590f1621510327acec75ae58c721dc071b17d8d652ca494d8/pillow-12.0.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aff9e4d82d082ff9513bdd6acd4f5bd359f5b2c870907d2b0a9c5e10d40c88fe", size = 7066043, upload-time = "2025-10-15T18:22:58.53Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/2c/aced60e9cf9d0cde341d54bf7932c9ffc33ddb4a1595798b3a5150c7ec4e/pillow-12.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8d8ca2b210ada074d57fcee40c30446c9562e542fc46aedc19baf758a93532ee", size = 6490915, upload-time = "2025-10-15T18:23:00.582Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/26/69dcb9b91f4e59f8f34b2332a4a0a951b44f547c4ed39d3e4dcfcff48f89/pillow-12.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:99a7f72fb6249302aa62245680754862a44179b545ded638cf1fef59befb57ef", size = 7157998, upload-time = "2025-10-15T18:23:02.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/2b/726235842220ca95fa441ddf55dd2382b52ab5b8d9c0596fe6b3f23dafe8/pillow-12.0.0-cp313-cp313t-win32.whl", hash = "sha256:4078242472387600b2ce8d93ade8899c12bf33fa89e55ec89fe126e9d6d5d9e9", size = 6306201, upload-time = "2025-10-15T18:23:04.709Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/3d/2afaf4e840b2df71344ababf2f8edd75a705ce500e5dc1e7227808312ae1/pillow-12.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2c54c1a783d6d60595d3514f0efe9b37c8808746a66920315bfd34a938d7994b", size = 7013165, upload-time = "2025-10-15T18:23:06.46Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/75/3fa09aa5cf6ed04bee3fa575798ddf1ce0bace8edb47249c798077a81f7f/pillow-12.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:26d9f7d2b604cd23aba3e9faf795787456ac25634d82cd060556998e39c6fa47", size = 2437834, upload-time = "2025-10-15T18:23:08.194Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/2a/9a8c6ba2c2c07b71bec92cf63e03370ca5e5f5c5b119b742bcc0cde3f9c5/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:beeae3f27f62308f1ddbcfb0690bf44b10732f2ef43758f169d5e9303165d3f9", size = 4045531, upload-time = "2025-10-15T18:23:10.121Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/54/836fdbf1bfb3d66a59f0189ff0b9f5f666cee09c6188309300df04ad71fa/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d4827615da15cd59784ce39d3388275ec093ae3ee8d7f0c089b76fa87af756c2", size = 4120554, upload-time = "2025-10-15T18:23:12.14Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/cd/16aec9f0da4793e98e6b54778a5fbce4f375c6646fe662e80600b8797379/pillow-12.0.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:3e42edad50b6909089750e65c91aa09aaf1e0a71310d383f11321b27c224ed8a", size = 3576812, upload-time = "2025-10-15T18:23:13.962Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/b7/13957fda356dc46339298b351cae0d327704986337c3c69bb54628c88155/pillow-12.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e5d8efac84c9afcb40914ab49ba063d94f5dbdf5066db4482c66a992f47a3a3b", size = 5252689, upload-time = "2025-10-15T18:23:15.562Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/f5/eae31a306341d8f331f43edb2e9122c7661b975433de5e447939ae61c5da/pillow-12.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:266cd5f2b63ff316d5a1bba46268e603c9caf5606d44f38c2873c380950576ad", size = 4650186, upload-time = "2025-10-15T18:23:17.379Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/62/2a88339aa40c4c77e79108facbd307d6091e2c0eb5b8d3cf4977cfca2fe6/pillow-12.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58eea5ebe51504057dd95c5b77d21700b77615ab0243d8152793dc00eb4faf01", size = 6230308, upload-time = "2025-10-15T18:23:18.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/33/5425a8992bcb32d1cb9fa3dd39a89e613d09a22f2c8083b7bf43c455f760/pillow-12.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f13711b1a5ba512d647a0e4ba79280d3a9a045aaf7e0cc6fbe96b91d4cdf6b0c", size = 8039222, upload-time = "2025-10-15T18:23:20.909Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/61/3f5d3b35c5728f37953d3eec5b5f3e77111949523bd2dd7f31a851e50690/pillow-12.0.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6846bd2d116ff42cba6b646edf5bf61d37e5cbd256425fa089fee4ff5c07a99e", size = 6346657, upload-time = "2025-10-15T18:23:23.077Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/be/ee90a3d79271227e0f0a33c453531efd6ed14b2e708596ba5dd9be948da3/pillow-12.0.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c98fa880d695de164b4135a52fd2e9cd7b7c90a9d8ac5e9e443a24a95ef9248e", size = 7038482, upload-time = "2025-10-15T18:23:25.005Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/34/a16b6a4d1ad727de390e9bd9f19f5f669e079e5826ec0f329010ddea492f/pillow-12.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa3ed2a29a9e9d2d488b4da81dcb54720ac3104a20bf0bd273f1e4648aff5af9", size = 6461416, upload-time = "2025-10-15T18:23:27.009Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/39/1aa5850d2ade7d7ba9f54e4e4c17077244ff7a2d9e25998c38a29749eb3f/pillow-12.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d034140032870024e6b9892c692fe2968493790dd57208b2c37e3fb35f6df3ab", size = 7131584, upload-time = "2025-10-15T18:23:29.752Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/db/4fae862f8fad0167073a7733973bfa955f47e2cac3dc3e3e6257d10fab4a/pillow-12.0.0-cp314-cp314-win32.whl", hash = "sha256:1b1b133e6e16105f524a8dec491e0586d072948ce15c9b914e41cdadd209052b", size = 6400621, upload-time = "2025-10-15T18:23:32.06Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/24/b350c31543fb0107ab2599464d7e28e6f856027aadda995022e695313d94/pillow-12.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:8dc232e39d409036af549c86f24aed8273a40ffa459981146829a324e0848b4b", size = 7142916, upload-time = "2025-10-15T18:23:34.71Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/9b/0ba5a6fd9351793996ef7487c4fdbde8d3f5f75dbedc093bb598648fddf0/pillow-12.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:d52610d51e265a51518692045e372a4c363056130d922a7351429ac9f27e70b0", size = 2523836, upload-time = "2025-10-15T18:23:36.967Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/7a/ceee0840aebc579af529b523d530840338ecf63992395842e54edc805987/pillow-12.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1979f4566bb96c1e50a62d9831e2ea2d1211761e5662afc545fa766f996632f6", size = 5255092, upload-time = "2025-10-15T18:23:38.573Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/76/20776057b4bfd1aef4eeca992ebde0f53a4dce874f3ae693d0ec90a4f79b/pillow-12.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b2e4b27a6e15b04832fe9bf292b94b5ca156016bbc1ea9c2c20098a0320d6cf6", size = 4653158, upload-time = "2025-10-15T18:23:40.238Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/3f/d9ff92ace07be8836b4e7e87e6a4c7a8318d47c2f1463ffcf121fc57d9cb/pillow-12.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fb3096c30df99fd01c7bf8e544f392103d0795b9f98ba71a8054bcbf56b255f1", size = 6267882, upload-time = "2025-10-15T18:23:42.434Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/7a/4f7ff87f00d3ad33ba21af78bfcd2f032107710baf8280e3722ceec28cda/pillow-12.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7438839e9e053ef79f7112c881cef684013855016f928b168b81ed5835f3e75e", size = 8071001, upload-time = "2025-10-15T18:23:44.29Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/87/fcea108944a52dad8cca0715ae6247e271eb80459364a98518f1e4f480c1/pillow-12.0.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d5c411a8eaa2299322b647cd932586b1427367fd3184ffbb8f7a219ea2041ca", size = 6380146, upload-time = "2025-10-15T18:23:46.065Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/52/0d31b5e571ef5fd111d2978b84603fce26aba1b6092f28e941cb46570745/pillow-12.0.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7e091d464ac59d2c7ad8e7e08105eaf9dafbc3883fd7265ffccc2baad6ac925", size = 7067344, upload-time = "2025-10-15T18:23:47.898Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/f4/2dd3d721f875f928d48e83bb30a434dee75a2531bca839bb996bb0aa5a91/pillow-12.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:792a2c0be4dcc18af9d4a2dfd8a11a17d5e25274a1062b0ec1c2d79c76f3e7f8", size = 6491864, upload-time = "2025-10-15T18:23:49.607Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/4b/667dfcf3d61fc309ba5a15b141845cece5915e39b99c1ceab0f34bf1d124/pillow-12.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:afbefa430092f71a9593a99ab6a4e7538bc9eabbf7bf94f91510d3503943edc4", size = 7158911, upload-time = "2025-10-15T18:23:51.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/2f/16cabcc6426c32218ace36bf0d55955e813f2958afddbf1d391849fee9d1/pillow-12.0.0-cp314-cp314t-win32.whl", hash = "sha256:3830c769decf88f1289680a59d4f4c46c72573446352e2befec9a8512104fa52", size = 6408045, upload-time = "2025-10-15T18:23:53.177Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/73/e29aa0c9c666cf787628d3f0dcf379f4791fba79f4936d02f8b37165bdf8/pillow-12.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:905b0365b210c73afb0ebe9101a32572152dfd1c144c7e28968a331b9217b94a", size = 7148282, upload-time = "2025-10-15T18:23:55.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/70/6b41bdcddf541b437bbb9f47f94d2db5d9ddef6c37ccab8c9107743748a4/pillow-12.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:99353a06902c2e43b43e8ff74ee65a7d90307d82370604746738a1e0661ccca7", size = 2525630, upload-time = "2025-10-15T18:23:57.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/08/26e68b6b5da219c2a2cb7b563af008b53bb8e6b6fcb3fa40715fcdb2523a/pillow-12.0.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:3adfb466bbc544b926d50fe8f4a4e6abd8c6bffd28a26177594e6e9b2b76572b", size = 5289809, upload-time = "2025-10-15T18:21:27.791Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/e9/4e58fb097fb74c7b4758a680aacd558810a417d1edaa7000142976ef9d2f/pillow-12.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1ac11e8ea4f611c3c0147424eae514028b5e9077dd99ab91e1bd7bc33ff145e1", size = 4650606, upload-time = "2025-10-15T18:21:29.823Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/e0/1fa492aa9f77b3bc6d471c468e62bfea1823056bf7e5e4f1914d7ab2565e/pillow-12.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d49e2314c373f4c2b39446fb1a45ed333c850e09d0c59ac79b72eb3b95397363", size = 6221023, upload-time = "2025-10-15T18:21:31.415Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/09/4de7cd03e33734ccd0c876f0251401f1314e819cbfd89a0fcb6e77927cc6/pillow-12.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c7b2a63fd6d5246349f3d3f37b14430d73ee7e8173154461785e43036ffa96ca", size = 8024937, upload-time = "2025-10-15T18:21:33.453Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/69/0688e7c1390666592876d9d474f5e135abb4acb39dcb583c4dc5490f1aff/pillow-12.0.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d64317d2587c70324b79861babb9c09f71fbb780bad212018874b2c013d8600e", size = 6334139, upload-time = "2025-10-15T18:21:35.395Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/1c/880921e98f525b9b44ce747ad1ea8f73fd7e992bafe3ca5e5644bf433dea/pillow-12.0.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d77153e14b709fd8b8af6f66a3afbb9ed6e9fc5ccf0b6b7e1ced7b036a228782", size = 7026074, upload-time = "2025-10-15T18:21:37.219Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/03/96f718331b19b355610ef4ebdbbde3557c726513030665071fd025745671/pillow-12.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:32ed80ea8a90ee3e6fa08c21e2e091bba6eda8eccc83dbc34c95169507a91f10", size = 6448852, upload-time = "2025-10-15T18:21:39.168Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/a0/6a193b3f0cc9437b122978d2c5cbce59510ccf9a5b48825096ed7472da2f/pillow-12.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c828a1ae702fc712978bda0320ba1b9893d99be0badf2647f693cc01cf0f04fa", size = 7117058, upload-time = "2025-10-15T18:21:40.997Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/c4/043192375eaa4463254e8e61f0e2ec9a846b983929a8d0a7122e0a6d6fff/pillow-12.0.0-cp310-cp310-win32.whl", hash = "sha256:bd87e140e45399c818fac4247880b9ce719e4783d767e030a883a970be632275", size = 6295431, upload-time = "2025-10-15T18:21:42.518Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/c6/c2f2fc7e56301c21827e689bb8b0b465f1b52878b57471a070678c0c33cd/pillow-12.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:455247ac8a4cfb7b9bc45b7e432d10421aea9fc2e74d285ba4072688a74c2e9d", size = 7000412, upload-time = "2025-10-15T18:21:44.404Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/d2/5f675067ba82da7a1c238a73b32e3fd78d67f9d9f80fbadd33a40b9c0481/pillow-12.0.0-cp310-cp310-win_arm64.whl", hash = "sha256:6ace95230bfb7cd79ef66caa064bbe2f2a1e63d93471c3a2e1f1348d9f22d6b7", size = 2435903, upload-time = "2025-10-15T18:21:46.29Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/5a/a2f6773b64edb921a756eb0729068acad9fc5208a53f4a349396e9436721/pillow-12.0.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:0fd00cac9c03256c8b2ff58f162ebcd2587ad3e1f2e397eab718c47e24d231cc", size = 5289798, upload-time = "2025-10-15T18:21:47.763Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/05/069b1f8a2e4b5a37493da6c5868531c3f77b85e716ad7a590ef87d58730d/pillow-12.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3475b96f5908b3b16c47533daaa87380c491357d197564e0ba34ae75c0f3257", size = 4650589, upload-time = "2025-10-15T18:21:49.515Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/e3/2c820d6e9a36432503ead175ae294f96861b07600a7156154a086ba7111a/pillow-12.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:110486b79f2d112cf6add83b28b627e369219388f64ef2f960fef9ebaf54c642", size = 6230472, upload-time = "2025-10-15T18:21:51.052Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/89/63427f51c64209c5e23d4d52071c8d0f21024d3a8a487737caaf614a5795/pillow-12.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5269cc1caeedb67e6f7269a42014f381f45e2e7cd42d834ede3c703a1d915fe3", size = 8033887, upload-time = "2025-10-15T18:21:52.604Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/1b/c9711318d4901093c15840f268ad649459cd81984c9ec9887756cca049a5/pillow-12.0.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa5129de4e174daccbc59d0a3b6d20eaf24417d59851c07ebb37aeb02947987c", size = 6343964, upload-time = "2025-10-15T18:21:54.619Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/1e/db9470f2d030b4995083044cd8738cdd1bf773106819f6d8ba12597d5352/pillow-12.0.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bee2a6db3a7242ea309aa7ee8e2780726fed67ff4e5b40169f2c940e7eb09227", size = 7034756, upload-time = "2025-10-15T18:21:56.151Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/b0/6177a8bdd5ee4ed87cba2de5a3cc1db55ffbbec6176784ce5bb75aa96798/pillow-12.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:90387104ee8400a7b4598253b4c406f8958f59fcf983a6cea2b50d59f7d63d0b", size = 6458075, upload-time = "2025-10-15T18:21:57.759Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/5e/61537aa6fa977922c6a03253a0e727e6e4a72381a80d63ad8eec350684f2/pillow-12.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bc91a56697869546d1b8f0a3ff35224557ae7f881050e99f615e0119bf934b4e", size = 7125955, upload-time = "2025-10-15T18:21:59.372Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/3d/d5033539344ee3cbd9a4d69e12e63ca3a44a739eb2d4c8da350a3d38edd7/pillow-12.0.0-cp311-cp311-win32.whl", hash = "sha256:27f95b12453d165099c84f8a8bfdfd46b9e4bda9e0e4b65f0635430027f55739", size = 6298440, upload-time = "2025-10-15T18:22:00.982Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/42/aaca386de5cc8bd8a0254516957c1f265e3521c91515b16e286c662854c4/pillow-12.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:b583dc9070312190192631373c6c8ed277254aa6e6084b74bdd0a6d3b221608e", size = 6999256, upload-time = "2025-10-15T18:22:02.617Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/f1/9197c9c2d5708b785f631a6dfbfa8eb3fb9672837cb92ae9af812c13b4ed/pillow-12.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:759de84a33be3b178a64c8ba28ad5c135900359e85fb662bc6e403ad4407791d", size = 2436025, upload-time = "2025-10-15T18:22:04.598Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/90/4fcce2c22caf044e660a198d740e7fbc14395619e3cb1abad12192c0826c/pillow-12.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:53561a4ddc36facb432fae7a9d8afbfaf94795414f5cdc5fc52f28c1dca90371", size = 5249377, upload-time = "2025-10-15T18:22:05.993Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/e0/ed960067543d080691d47d6938ebccbf3976a931c9567ab2fbfab983a5dd/pillow-12.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71db6b4c1653045dacc1585c1b0d184004f0d7e694c7b34ac165ca70c0838082", size = 4650343, upload-time = "2025-10-15T18:22:07.718Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/a1/f81fdeddcb99c044bf7d6faa47e12850f13cee0849537a7d27eeab5534d4/pillow-12.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2fa5f0b6716fc88f11380b88b31fe591a06c6315e955c096c35715788b339e3f", size = 6232981, upload-time = "2025-10-15T18:22:09.287Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/e1/9098d3ce341a8750b55b0e00c03f1630d6178f38ac191c81c97a3b047b44/pillow-12.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:82240051c6ca513c616f7f9da06e871f61bfd7805f566275841af15015b8f98d", size = 8041399, upload-time = "2025-10-15T18:22:10.872Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/62/a22e8d3b602ae8cc01446d0c57a54e982737f44b6f2e1e019a925143771d/pillow-12.0.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55f818bd74fe2f11d4d7cbc65880a843c4075e0ac7226bc1a23261dbea531953", size = 6347740, upload-time = "2025-10-15T18:22:12.769Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/87/424511bdcd02c8d7acf9f65caa09f291a519b16bd83c3fb3374b3d4ae951/pillow-12.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b87843e225e74576437fd5b6a4c2205d422754f84a06942cfaf1dc32243e45a8", size = 7040201, upload-time = "2025-10-15T18:22:14.813Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/4d/435c8ac688c54d11755aedfdd9f29c9eeddf68d150fe42d1d3dbd2365149/pillow-12.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c607c90ba67533e1b2355b821fef6764d1dd2cbe26b8c1005ae84f7aea25ff79", size = 6462334, upload-time = "2025-10-15T18:22:16.375Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/f2/ad34167a8059a59b8ad10bc5c72d4d9b35acc6b7c0877af8ac885b5f2044/pillow-12.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:21f241bdd5080a15bc86d3466a9f6074a9c2c2b314100dd896ac81ee6db2f1ba", size = 7134162, upload-time = "2025-10-15T18:22:17.996Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/b1/a7391df6adacf0a5c2cf6ac1cf1fcc1369e7d439d28f637a847f8803beb3/pillow-12.0.0-cp312-cp312-win32.whl", hash = "sha256:dd333073e0cacdc3089525c7df7d39b211bcdf31fc2824e49d01c6b6187b07d0", size = 6298769, upload-time = "2025-10-15T18:22:19.923Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/0b/d87733741526541c909bbf159e338dcace4f982daac6e5a8d6be225ca32d/pillow-12.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe611163f6303d1619bbcb653540a4d60f9e55e622d60a3108be0d5b441017a", size = 7001107, upload-time = "2025-10-15T18:22:21.644Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/96/aaa61ce33cc98421fb6088af2a03be4157b1e7e0e87087c888e2370a7f45/pillow-12.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:7dfb439562f234f7d57b1ac6bc8fe7f838a4bd49c79230e0f6a1da93e82f1fad", size = 2436012, upload-time = "2025-10-15T18:22:23.621Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/b3/582327e6c9f86d037b63beebe981425d6811104cb443e8193824ef1a2f27/pillow-12.0.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b22bd8c974942477156be55a768f7aa37c46904c175be4e158b6a86e3a6b7ca8", size = 5215068, upload-time = "2025-10-15T18:23:59.594Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/d6/67748211d119f3b6540baf90f92fae73ae51d5217b171b0e8b5f7e5d558f/pillow-12.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:805ebf596939e48dbb2e4922a1d3852cfc25c38160751ce02da93058b48d252a", size = 4614994, upload-time = "2025-10-15T18:24:01.669Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/e1/f8281e5d844c41872b273b9f2c34a4bf64ca08905668c8ae730eedc7c9fa/pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cae81479f77420d217def5f54b5b9d279804d17e982e0f2fa19b1d1e14ab5197", size = 5246639, upload-time = "2025-10-15T18:24:03.403Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/5a/0d8ab8ffe8a102ff5df60d0de5af309015163bf710c7bb3e8311dd3b3ad0/pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeaefa96c768fc66818730b952a862235d68825c178f1b3ffd4efd7ad2edcb7c", size = 6986839, upload-time = "2025-10-15T18:24:05.344Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/2e/3434380e8110b76cd9eb00a363c484b050f949b4bbe84ba770bb8508a02c/pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09f2d0abef9e4e2f349305a4f8cc784a8a6c2f58a8c4892eea13b10a943bd26e", size = 5313505, upload-time = "2025-10-15T18:24:07.137Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/ca/5a9d38900d9d74785141d6580950fe705de68af735ff6e727cb911b64740/pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bdee52571a343d721fb2eb3b090a82d959ff37fc631e3f70422e0c2e029f3e76", size = 5963654, upload-time = "2025-10-15T18:24:09.579Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/7e/f896623c3c635a90537ac093c6a618ebe1a90d87206e42309cb5d98a1b9e/pillow-12.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b290fd8aa38422444d4b50d579de197557f182ef1068b75f5aa8558638b8d0a5", size = 6997850, upload-time = "2025-10-15T18:24:11.495Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -239,6 +344,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyaes"
|
||||
version = "1.6.1"
|
||||
@@ -263,6 +377,38 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-asyncio"
|
||||
version = "1.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" },
|
||||
{ name = "pytest" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rich"
|
||||
version = "14.2.0"
|
||||
@@ -314,6 +460,31 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/7a/7f3ea5e6f26d546ee4bd107df8fc9eef9f149dab0f6f15e1fc9f9413231f/textual-6.7.1-py3-none-any.whl", hash = "sha256:b92977ac5941dd37b6b7dc0ac021850ce8d9bf2e123c5bab7ff2016f215272e0", size = 713993, upload-time = "2025-12-01T20:57:23.698Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tomli"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/52/ed/3f73f72945444548f33eba9a87fc7a6e969915e7b1acc8260b30e1f76a2f/tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549", size = 17392, upload-time = "2025-10-08T22:01:47.119Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/2e/299f62b401438d5fe1624119c723f5d877acc86a4c2492da405626665f12/tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45", size = 153236, upload-time = "2025-10-08T22:01:00.137Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/7f/d8fffe6a7aefdb61bced88fcb5e280cfd71e08939da5894161bd71bea022/tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba", size = 148084, upload-time = "2025-10-08T22:01:01.63Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/5c/24935fb6a2ee63e86d80e4d3b58b222dafaf438c416752c8b58537c8b89a/tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf", size = 234832, upload-time = "2025-10-08T22:01:02.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/da/75dfd804fc11e6612846758a23f13271b76d577e299592b4371a4ca4cd09/tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441", size = 242052, upload-time = "2025-10-08T22:01:03.836Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/8c/f48ac899f7b3ca7eb13af73bacbc93aec37f9c954df3c08ad96991c8c373/tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845", size = 239555, upload-time = "2025-10-08T22:01:04.834Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/28/72f8afd73f1d0e7829bfc093f4cb98ce0a40ffc0cc997009ee1ed94ba705/tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c", size = 245128, upload-time = "2025-10-08T22:01:05.84Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/eb/a7679c8ac85208706d27436e8d421dfa39d4c914dcf5fa8083a9305f58d9/tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456", size = 96445, upload-time = "2025-10-08T22:01:06.896Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/fe/3d3420c4cb1ad9cb462fb52967080575f15898da97e21cb6f1361d505383/tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be", size = 107165, upload-time = "2025-10-08T22:01:08.107Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/b7/40f36368fcabc518bb11c8f06379a0fd631985046c038aca08c6d6a43c6e/tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac", size = 154891, upload-time = "2025-10-08T22:01:09.082Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/3f/d9dd692199e3b3aab2e4e4dd948abd0f790d9ded8cd10cbaae276a898434/tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22", size = 148796, upload-time = "2025-10-08T22:01:10.266Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/83/59bff4996c2cf9f9387a0f5a3394629c7efa5ef16142076a23a90f1955fa/tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f", size = 242121, upload-time = "2025-10-08T22:01:11.332Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/e5/7c5119ff39de8693d6baab6c0b6dcb556d192c165596e9fc231ea1052041/tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52", size = 250070, upload-time = "2025-10-08T22:01:12.498Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/12/ad5126d3a278f27e6701abde51d342aa78d06e27ce2bb596a01f7709a5a2/tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8", size = 245859, upload-time = "2025-10-08T22:01:13.551Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/a1/4d6865da6a71c603cfe6ad0e6556c73c76548557a8d658f9e3b142df245f/tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6", size = 250296, upload-time = "2025-10-08T22:01:14.614Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/b7/a7a7042715d55c9ba6e8b196d65d2cb662578b4d8cd17d882d45322b0d78/tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876", size = 97124, upload-time = "2025-10-08T22:01:15.629Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/1e/f22f100db15a68b520664eb3328fb0ae4e90530887928558112c8d1f4515/tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878", size = 107698, upload-time = "2025-10-08T22:01:16.51Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.15.0"
|
||||
|
||||
Reference in New Issue
Block a user