Compare commits

...

20 Commits

Author SHA1 Message Date
Alex Shnitman 72e8f5031f Merge PR #1028: first-class SponsorBlock toggle
Adds a "Remove sponsor segments" switch to the shared options panel,
building the same SponsorBlock + ModifyChapters pair the CLI's
--sponsorblock-remove sponsor does, and carries the flag through
subscriptions so the panel's control applies to both forms.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:47:02 +02:00
Alex Shnitman f3c464fad5 fix: let the download reach the PO token provider (closes #1064)
The image ships yt-dlp's bgutil PO token provider and starts it on
loopback, where the plugin dials it at http://127.0.0.1:4416. Since
482381d scoped the connect-time allowance to the configured proxy, the
download subprocess could no longer resolve it:

    Refusing to connect to non-global address for host '127.0.0.1'

which surfaces as the plugin's "Error reaching GET .../ping". Metadata
extraction runs in the main process and installs no guard, so titles kept
resolving while the download itself ran without a token — and YouTube
increasingly answers those with 403.

The allowance already had the right shape for this; it was just named for
its only user. Endpoints the operator or the image configured are now
allowed as a class: install_socket_guard takes service_urls alongside
proxy_urls, and ytdl derives them from the bundled default plus any
base_url set through the youtubepot-bgutilhttp (or the deprecated youtube
getpot_bgutil_baseurl) extractor argument. The bundled server runs either
way, so it stays allowed when a base URL is configured.

Matching stays exact host:port on the configured string, so nothing else
on loopback opens up: a hostile media URL naming the endpoint reaches a
token server with two endpoints and nothing worth reading.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:45:06 +02:00
tjelite1986 b10bb6103a feat: carry the SponsorBlock toggle into subscriptions
Subscriptions download unattended, which is where skipping sponsor reads
is most useful, so the flag now travels the same path the other download
options take: stored on SubscriptionInfo, persisted in the record, and
passed to add_entry for every entry a check queues.

Like the clip bounds, it is set when the subscription is created; the
update endpoint's field list is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 10:39:11 +02:00
tjelite1986 8c2990e68a feat: first-class SponsorBlock toggle
A "Remove sponsor segments" switch next to "Split by chapters" queues
the download with the same postprocessor pair the CLI's
--sponsorblock-remove sponsor builds (SponsorBlock + ModifyChapters).
The flag persists as a cookie like the other form options, survives in
the queue records, and is carried into retries.

The pair is registered above the chapter-splitting block: yt-dlp runs
same-stage postprocessors in list order, so ModifyChapters has to
rewrite the chapter list before FFmpegSplitChapters cuts the file up,
matching what the CLI builds for --sponsorblock-remove sponsor
--split-chapters. With both toggles on the other way around the chapter
files keep the sponsor segments and the removal desyncs the remaining
chapter timings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 10:39:10 +02:00
Alex Shnitman ac46fff6d9 Merge PR #1058: DEFAULT_FOLDER pre-selects a download folder 2026-08-17 09:36:30 +02:00
Alex Shnitman 05c21326b3 Merge PR #1059: show the queued format in the Downloading table 2026-08-17 09:36:24 +02:00
Alex Shnitman 99da62dcbb Merge PR #1057: shift-click to select a range of rows 2026-08-17 09:36:19 +02:00
Alex Shnitman d0ad36baad Merge PR #1060: keep generated filenames within the filesystem limit 2026-08-17 09:36:01 +02:00
Alex Shnitman d2095caea2 Merge PR #1056: surface yt-dlp warning context on failed downloads 2026-08-17 09:35:56 +02:00
Alex Shnitman e15aff3339 fix: detect channels addressed without a tab (closes #1024)
__is_channel_extraction keyed on id == channel_id. That holds for a channel
tab - /channel/UC..., and the videos, streams, shorts and playlists tabs of a
/@handle URL - but not for a channel addressed on its own: yt-dlp reports the
id in the form the channel was asked for, so a bare handle URL yields '@handle'
and a legacy /c/ URL yields the vanity name.

Neither matched, so both fell through to OUTPUT_TEMPLATE_PLAYLIST and the
folder came out as the feed's title. That is why e2c7778 fixed the reporter's
tab URLs while a bare channel URL - what you get copying the address bar - went
on ignoring OUTPUT_TEMPLATE.

Both forms match uploader_id, which is the handle either way, so compare
against that as well, without case: a legacy vanity name and the handle it
became need not agree on it. A real playlist carries its owner's channel_id and
uploader_id but keeps an id of its own, so it still reads as a playlist; no
playlist id can collide with a handle, since those are 'PL...', 'OLAK...' and
the like.

Verified against the live extractor for all six channel URL forms and a real
playlist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 09:31:56 +02:00
Cursor Agent fccd207799 fix: carry yt-dlp warning context on the DownloadError path (closes #1047)
MeTube never sets ignoreerrors, so yt-dlp's trouble() raises DownloadError
instead of returning nonzero for failures like "No video formats found!".
That is the path issue #1047 reports, and it dropped the warnings that
explain the failure because only the nonzero-return branch attached them.

The exception branch now reports the retained warnings with the exception
text as the final line, so the actual error stays prominent under the
context. yt-dlp labels errors "ERROR:" but hands warnings to the logger
unlabelled, so a last warning that repeats the error text is skipped.

Retention is bounded to the last five distinct warnings: fragmented and
live downloads emit a warning per fragment, and the joined message is
persisted with the completed queue and broadcast to every client.

Co-authored-by: Matt Van Horn <mvanhorn@users.noreply.github.com>
2026-08-16 22:13:02 +00:00
tjelite1986 6461924bf8 fix: keep generated filenames within the filesystem limit (closes #1034)
Sites that put a description in the title produce output names past what
the filesystem accepts, and the download fails outright with
"[Errno 36] File name too long". The name is now shortened to fit inside
prepare_filename, which every output path already passes through, so the
main file, its chapter files, thumbnails and subtitles stay consistent.

The limit is read from the filesystem (PC_NAME_MAX, falling back to 255)
and counted in bytes, not characters: a title of accented or CJK
characters reaches it in a third of the characters. The extension is
kept, a cut landing inside a multi-byte character does not leave a broken
sequence, and a reserve is held back for the suffixes yt-dlp appends
afterwards -- '.part', '.ytdl', '.f<format_id>', '-Frag<n>' -- since it
is those that pushed the reported name over the limit.

Names that already fit are untouched, so nothing that downloads today
changes name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 14:19:36 +02:00
tjelite1986 c68fcaddd1 feat: show the queued format in the Downloading table (closes #551)
With the format picked per download rather than left on one setting, the
Downloading list gave no way to tell which item was queued as what until
it landed in Completed. The new column labels the format exactly as the
form does, reusing the same option lists, and falls back to the raw value
uppercased for a record queued before an option existed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 12:56:29 +02:00
tjelite1986 a4454ac460 feat: DEFAULT_FOLDER pre-selects a download folder (closes #875)
Most downloads from a given install land in the same custom directory,
which today means picking it by hand every time. DEFAULT_FOLDER seeds the
folder field once the configuration arrives; the field stays editable, so
per-download folders still work, and a folder already typed this session
is not overwritten.

The value is trimmed of surrounding slashes, and dropped with a warning
when CUSTOM_DIRS is off, since the UI hides the field in that mode and
the download path check rejects a folder anyway.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 12:53:07 +02:00
tjelite1986 75fe1f0c11 feat: shift-click to select a range of rows (closes #525)
Clicking a row's checkbox with Shift held takes every row between it and
the last one toggled to the state the click produced, so a stretch of a
long queue can be cancelled without ticking each entry.

The range follows the order the rows are rendered in, which for the
Completed list is the sort the user picked rather than the order the map
holds; the master checkbox takes that order as an input. An anchor whose
row has since left the list (a download finishing moves it to Completed)
falls back to a plain toggle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 12:49:39 +02:00
Alex Shnitman aac9c63a36 feat: let a subscription carry clip bounds (closes #1049)
A subscription already carries every other download option and applies it to
each video it queues. Clip bounds were the one exception: 4f83174 added them
for one-off downloads and carved subscriptions out, rejecting the fields in the
subscribe route and stripping them in the UI before the request was built. So
the fields sat visible in the shared advanced-options panel while quietly doing
nothing for a subscription.

Carry them like the rest: two fields on SubscriptionInfo, threaded through the
check flow to add_entry. Stored records that predate the fields take the
defaults, since _from_stored filters by field name.

One thing does not carry over. parse_download_options reads a YouTube t=
timestamp from the URL and turns it into a clip start, which is right when you
paste a link to a moment in a video you want. A subscription URL is a channel
or a playlist, so a timestamp left on it says nothing about the videos that
feed will yield, and honouring it would silently truncate every future
download. The subscribe route therefore takes clip bounds only when the caller
sent the fields explicitly; the t= param is still stripped from the stored URL.

Worth knowing when using this: the range is a fixed offset applied to every
video, so it suits feeds with a consistent shape - a standing intro, a fixed
sponsor read - and will cut in the wrong place on a feed without one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 09:25:03 +02:00
Alex Shnitman 3444b1605b feat: allow a subscription's download folder to be changed (closes #1052)
The folder was already persisted on the subscription and already applied to
every download it queued, but it was missing from the small tuple of fields the
update route accepts, so it could be set when the subscription was created and
never afterwards. That is the same gap the subscription name had in #1044.

Add it to the accepted fields and validate it on the way in, following the
validate_* helpers already in this module. The check is deliberately narrow —
it rejects absolute paths and any '..' component, values that could never be
valid — because the authoritative resolution stays where it already lives, in
DownloadQueue at download time, along with the CUSTOM_DIRS / CREATE_CUSTOM_DIRS
rules and the directory creation. Doing it this way reports a bad edit while
the user is looking at the field instead of failing every check from then on,
without a second copy of the path logic drifting out of step with the first.
subscriptions.py cannot import ytdl.py in any case: ytdl imports _entry_id from
it.

An empty folder stays valid and means the base download directory. A change
applies to future downloads only; files already downloaded are not moved.

This covers the API side of the request. The subscriptions table does not show
the folder at all today, so exposing it in the UI is a separate change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 08:51:42 +02:00
Alex Shnitman 59cf84ae1a build(deps): upgrade frontend dependencies to Angular 22.1
Routine `pnpm upgrade`. Angular 22.0.6 -> 22.1.2 across the runtime packages
and 22.0.7 -> 22.1.4 for the build toolchain, ng-select 23.2 -> 23.11.

This also clears every open npm Dependabot alert. All fifteen were build
toolchain transitives that never reach the runtime image, since the Dockerfile
builds the UI in a separate stage and copies only dist: hono 4.13.2, esbuild
0.28.2, postcss 8.5.26, js-yaml 4.3.1, fast-uri 3.1.5, ip-address 10.5.0, with
undici and @hono/node-server no longer in the tree at all.

Build, lint and both suites pass (44 frontend, 372 backend).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 08:44:09 +02:00
Alex Shnitman 99b6452c8e build(deps): upgrade Python dependencies
Routine `uv lock --upgrade`. Notably yt-dlp 2026.6.17 -> 2026.7.22, plus
patch-level moves across the transitive set.

Full backend suite passes (372 tests).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 08:44:01 +02:00
Matt Van Horn 5826d0dc2b fix: surface yt-dlp warning context on failed downloads
Fixes #1047
2026-08-15 18:31:45 -07:00
27 changed files with 3743 additions and 2320 deletions
+3
View File
@@ -62,6 +62,7 @@ Certain values can be set via environment variables, using the `-e` parameter on
* __CUSTOM_DIRS__: Whether to enable downloading videos into custom directories within the __DOWNLOAD_DIR__ (or __AUDIO_DOWNLOAD_DIR__). When enabled, a dropdown appears next to the Add button to specify the download directory. Defaults to `true`. * __CUSTOM_DIRS__: Whether to enable downloading videos into custom directories within the __DOWNLOAD_DIR__ (or __AUDIO_DOWNLOAD_DIR__). When enabled, a dropdown appears next to the Add button to specify the download directory. Defaults to `true`.
* __CREATE_CUSTOM_DIRS__: Whether to support automatically creating directories within the __DOWNLOAD_DIR__ (or __AUDIO_DOWNLOAD_DIR__) if they do not exist. When enabled, the download directory selector supports free-text input, and the specified directory will be created recursively. Defaults to `true`. * __CREATE_CUSTOM_DIRS__: Whether to support automatically creating directories within the __DOWNLOAD_DIR__ (or __AUDIO_DOWNLOAD_DIR__) if they do not exist. When enabled, the download directory selector supports free-text input, and the specified directory will be created recursively. Defaults to `true`.
* __CUSTOM_DIRS_EXCLUDE_REGEX__: Regular expression to exclude some custom directories from the dropdown. Empty regex disables exclusion. Defaults to `(^|/)[.@].*$`, which means directories starting with `.` or `@`. * __CUSTOM_DIRS_EXCLUDE_REGEX__: Regular expression to exclude some custom directories from the dropdown. Empty regex disables exclusion. Defaults to `(^|/)[.@].*$`, which means directories starting with `.` or `@`.
* __DEFAULT_FOLDER__: Custom directory to pre-select in the download folder field, relative to __DOWNLOAD_DIR__ (or __AUDIO_DOWNLOAD_DIR__), for when most downloads go to the same place. It is only a starting value — the field stays editable, so any other folder can still be picked per download. Requires __CUSTOM_DIRS__; ignored with a warning otherwise. Defaults to empty, i.e. the base download directory.
* __DOWNLOAD_DIRS_INDEXABLE__: If `true`, the download directories (__DOWNLOAD_DIR__ and __AUDIO_DOWNLOAD_DIR__) are indexable on the web server. Defaults to `false`. * __DOWNLOAD_DIRS_INDEXABLE__: If `true`, the download directories (__DOWNLOAD_DIR__ and __AUDIO_DOWNLOAD_DIR__) are indexable on the web server. Defaults to `false`.
* __STATE_DIR__: Path to where MeTube will store its persistent state files (`queue.json`, `pending.json`, `completed.json`, `subscriptions.json`). Defaults to `/downloads/.metube` in the Docker image, and `.` otherwise. * __STATE_DIR__: Path to where MeTube will store its persistent state files (`queue.json`, `pending.json`, `completed.json`, `subscriptions.json`). Defaults to `/downloads/.metube` in the Docker image, and `.` otherwise.
* __TEMP_DIR__: Path where intermediary download files will be saved. Defaults to `/downloads` in the Docker image, and `.` otherwise. * __TEMP_DIR__: Path where intermediary download files will be saved. Defaults to `/downloads` in the Docker image, and `.` otherwise.
@@ -83,6 +84,8 @@ Certain values can be set via environment variables, using the `-e` parameter on
* __ALLOW_PRIVATE_ADDRESSES__: Whether to allow downloads from private, loopback, link-local and other non-global addresses. Defaults to `false`, which protects against SSRF by refusing URLs that resolve to internal hosts. Set to `true` only in trusted environments — for example when routing traffic through a proxy/VPN client in Fake-IP mode (sing-box, Clash, Mihomo), which resolves hosts to the `198.18.0.0/15` range. Enabling this disables the SSRF protection entirely, so only use it when you control the network. You do **not** need this to use a proxy on an internal address: a proxy configured through the `proxy` option in `YTDL_OPTIONS` (or the `*_proxy` environment variables) is always reachable at its own host and port, wherever it lives. * __ALLOW_PRIVATE_ADDRESSES__: Whether to allow downloads from private, loopback, link-local and other non-global addresses. Defaults to `false`, which protects against SSRF by refusing URLs that resolve to internal hosts. Set to `true` only in trusted environments — for example when routing traffic through a proxy/VPN client in Fake-IP mode (sing-box, Clash, Mihomo), which resolves hosts to the `198.18.0.0/15` range. Enabling this disables the SSRF protection entirely, so only use it when you control the network. You do **not** need this to use a proxy on an internal address: a proxy configured through the `proxy` option in `YTDL_OPTIONS` (or the `*_proxy` environment variables) is always reachable at its own host and port, wherever it lives.
* __YTDL_NIGHTLY_UPDATE_TIME__: If set, will cause MeTube to use [nightly yt-dlp builds](https://github.com/yt-dlp/yt-dlp-nightly-builds) instead of the stable releases. Set to the time (`HH:MM`, 24-hour) when you want the daily upgrades and MeTube restart to happen. Defaults to empty (disabled). * __YTDL_NIGHTLY_UPDATE_TIME__: If set, will cause MeTube to use [nightly yt-dlp builds](https://github.com/yt-dlp/yt-dlp-nightly-builds) instead of the stable releases. Set to the time (`HH:MM`, 24-hour) when you want the daily upgrades and MeTube restart to happen. Defaults to empty (disabled).
A filename that would exceed the limit the filesystem accepts is shortened to fit, keeping its extension, with room left for the suffixes yt-dlp adds while downloading. Sites that put a long description in the title would otherwise fail the download outright with `File name too long`. Use `trim_file_name` in `YTDL_OPTIONS` if you want names shorter than the filesystem's own limit, or `restrictfilenames` to strip non-ASCII characters.
Enabling `writeinfojson` or `writethumbnail` in `YTDL_OPTIONS` also writes a feed-level `.info.json` and thumbnail when you add a playlist or channel. These reuse the template of the items they belong to — `OUTPUT_TEMPLATE_CHANNEL` or `OUTPUT_TEMPLATE_PLAYLIST` — evaluated against the feed itself, so with the defaults they land in the same folder as the videos, named after the feed. Set `allow_playlist_files` to `false` in `YTDL_OPTIONS` to skip them. Enabling `writeinfojson` or `writethumbnail` in `YTDL_OPTIONS` also writes a feed-level `.info.json` and thumbnail when you add a playlist or channel. These reuse the template of the items they belong to — `OUTPUT_TEMPLATE_CHANNEL` or `OUTPUT_TEMPLATE_PLAYLIST` — evaluated against the feed itself, so with the defaults they land in the same folder as the videos, named after the feed. Set `allow_playlist_files` to `false` in `YTDL_OPTIONS` to skip them.
### 🌐 Web Server & URLs ### 🌐 Web Server & URLs
+34 -4
View File
@@ -62,6 +62,7 @@ class Config:
'CUSTOM_DIRS': 'true', 'CUSTOM_DIRS': 'true',
'CREATE_CUSTOM_DIRS': 'true', 'CREATE_CUSTOM_DIRS': 'true',
'CUSTOM_DIRS_EXCLUDE_REGEX': r'(^|/)[.@].*$', 'CUSTOM_DIRS_EXCLUDE_REGEX': r'(^|/)[.@].*$',
'DEFAULT_FOLDER': '',
'DELETE_FILE_ON_TRASHCAN': 'false', 'DELETE_FILE_ON_TRASHCAN': 'false',
'STATE_DIR': '.', 'STATE_DIR': '.',
'URL_PREFIX': '', 'URL_PREFIX': '',
@@ -127,6 +128,18 @@ class Config:
if val and not val.endswith('/'): if val and not val.endswith('/'):
setattr(self, attr, val + '/') setattr(self, attr, val + '/')
# DEFAULT_FOLDER only pre-fills the form's folder field, which the UI
# does not even show without CUSTOM_DIRS. Sending one anyway would fail
# every download on the server's own folder check, so drop it and say so
# rather than leaving the user with a form that cannot submit.
self.DEFAULT_FOLDER = self.DEFAULT_FOLDER.strip().strip('/')
if self.DEFAULT_FOLDER and not self.CUSTOM_DIRS:
log.warning(
'Ignoring DEFAULT_FOLDER "%s" because CUSTOM_DIRS is not enabled',
self.DEFAULT_FOLDER,
)
self.DEFAULT_FOLDER = ''
# Convert relative addresses to absolute addresses to prevent the failure of file address comparison # Convert relative addresses to absolute addresses to prevent the failure of file address comparison
if self.YTDL_OPTIONS_FILE and self.YTDL_OPTIONS_FILE.startswith('.'): if self.YTDL_OPTIONS_FILE and self.YTDL_OPTIONS_FILE.startswith('.'):
self.YTDL_OPTIONS_FILE = str(Path(self.YTDL_OPTIONS_FILE).resolve()) self.YTDL_OPTIONS_FILE = str(Path(self.YTDL_OPTIONS_FILE).resolve())
@@ -187,6 +200,7 @@ class Config:
_FRONTEND_KEYS = ( _FRONTEND_KEYS = (
'CUSTOM_DIRS', 'CUSTOM_DIRS',
'CREATE_CUSTOM_DIRS', 'CREATE_CUSTOM_DIRS',
'DEFAULT_FOLDER',
'OUTPUT_TEMPLATE_CHAPTER', 'OUTPUT_TEMPLATE_CHAPTER',
'PUBLIC_HOST_URL', 'PUBLIC_HOST_URL',
'PUBLIC_HOST_AUDIO_URL', 'PUBLIC_HOST_AUDIO_URL',
@@ -711,6 +725,7 @@ def parse_download_options(post: dict) -> dict:
playlist_item_limit = post.get('playlist_item_limit') playlist_item_limit = post.get('playlist_item_limit')
auto_start = post.get('auto_start') auto_start = post.get('auto_start')
split_by_chapters = post.get('split_by_chapters') split_by_chapters = post.get('split_by_chapters')
sponsorblock = bool(post.get('sponsorblock'))
chapter_template = post.get('chapter_template') chapter_template = post.get('chapter_template')
subtitle_language = post.get('subtitle_language') subtitle_language = post.get('subtitle_language')
subtitle_mode = post.get('subtitle_mode') subtitle_mode = post.get('subtitle_mode')
@@ -831,6 +846,7 @@ def parse_download_options(post: dict) -> dict:
'playlist_item_limit': playlist_item_limit, 'playlist_item_limit': playlist_item_limit,
'auto_start': auto_start, 'auto_start': auto_start,
'split_by_chapters': split_by_chapters, 'split_by_chapters': split_by_chapters,
'sponsorblock': sponsorblock,
'chapter_template': chapter_template, 'chapter_template': chapter_template,
'subtitle_language': subtitle_language, 'subtitle_language': subtitle_language,
'subtitle_mode': subtitle_mode, 'subtitle_mode': subtitle_mode,
@@ -876,6 +892,7 @@ async def add(request):
o['ytdl_options_overrides'], o['ytdl_options_overrides'],
o['clip_start'], o['clip_start'],
o['clip_end'], o['clip_end'],
sponsorblock=o['sponsorblock'],
) )
return web.Response(text=serializer.encode(status)) return web.Response(text=serializer.encode(status))
@@ -917,9 +934,6 @@ async def subscribe(request):
raise web.HTTPBadRequest(reason='check_interval_minutes must be an integer') from exc raise web.HTTPBadRequest(reason='check_interval_minutes must be an integer') from exc
if cic < 1: if cic < 1:
raise web.HTTPBadRequest(reason='check_interval_minutes must be at least 1') raise web.HTTPBadRequest(reason='check_interval_minutes must be at least 1')
if o.get('clip_start') is not None or o.get('clip_end') is not None:
raise web.HTTPBadRequest(reason='clip options are not supported for subscriptions')
try: try:
skip_subscriber_only = coerce_optional_bool( skip_subscriber_only = coerce_optional_bool(
post.get('skip_subscriber_only'), post.get('skip_subscriber_only'),
@@ -929,6 +943,19 @@ async def subscribe(request):
except ValueError as exc: except ValueError as exc:
raise web.HTTPBadRequest(reason=str(exc)) from exc raise web.HTTPBadRequest(reason=str(exc)) from exc
# A t= timestamp in the URL means "start playing here" and parse_download_options
# turns it into a clip start, which is right for a one-off download of that video.
# A subscription URL is a channel or playlist, so a timestamp left on it says
# nothing about the videos it will yield — honour clip fields only when the
# caller supplied them explicitly, rather than silently clipping every future
# download. The t= param is still stripped from the stored URL.
clip_given = (
_clip_field_provided_in_post(post.get('clip_start'))
or _clip_field_provided_in_post(post.get('clip_end'))
)
sub_clip_start = o['clip_start'] if clip_given else None
sub_clip_end = o['clip_end'] if clip_given else None
result = await submgr.add_subscription( result = await submgr.add_subscription(
o['url'], o['url'],
check_interval_minutes=cic, check_interval_minutes=cic,
@@ -946,8 +973,11 @@ async def subscribe(request):
subtitle_mode=o['subtitle_mode'], subtitle_mode=o['subtitle_mode'],
ytdl_options_presets=o['ytdl_options_presets'], ytdl_options_presets=o['ytdl_options_presets'],
ytdl_options_overrides=o['ytdl_options_overrides'], ytdl_options_overrides=o['ytdl_options_overrides'],
sponsorblock=o['sponsorblock'],
title_regex=post.get('title_regex'), title_regex=post.get('title_regex'),
skip_subscriber_only=skip_subscriber_only, skip_subscriber_only=skip_subscriber_only,
clip_start=sub_clip_start,
clip_end=sub_clip_end,
) )
return web.Response(text=serializer.encode(result)) return web.Response(text=serializer.encode(result))
@@ -967,7 +997,7 @@ async def subscriptions_update(request):
k: v k: v
for k, v in post.items() for k, v in post.items()
if k != 'id' if k != 'id'
and k in ('enabled', 'check_interval_minutes', 'name', 'title_regex', 'skip_subscriber_only') and k in ('enabled', 'check_interval_minutes', 'name', 'folder', 'title_regex', 'skip_subscriber_only')
} }
if not changes: if not changes:
raise web.HTTPBadRequest(reason='no valid fields to update') raise web.HTTPBadRequest(reason='no valid fields to update')
+69
View File
@@ -182,6 +182,7 @@ class SubscriptionInfo:
auto_start: bool = True auto_start: bool = True
playlist_item_limit: int = 0 playlist_item_limit: int = 0
split_by_chapters: bool = False split_by_chapters: bool = False
sponsorblock: bool = False
chapter_template: str = "" chapter_template: str = ""
subtitle_language: str = "en" subtitle_language: str = "en"
subtitle_mode: str = "prefer_manual" subtitle_mode: str = "prefer_manual"
@@ -189,6 +190,13 @@ class SubscriptionInfo:
ytdl_options_overrides: dict[str, Any] = field(default_factory=dict) ytdl_options_overrides: dict[str, Any] = field(default_factory=dict)
title_regex: str = "" title_regex: str = ""
skip_subscriber_only: bool = False skip_subscriber_only: bool = False
# A fixed range applied to every video the subscription downloads. Only
# sensible for channels with a consistent format (a standing intro, a fixed
# sponsor read); left unset, videos download whole. Old stored records
# predate these fields and take the defaults — _from_stored filters by
# field name, so nothing needs migrating.
clip_start: Optional[float] = None
clip_end: Optional[float] = None
last_checked: Optional[float] = None last_checked: Optional[float] = None
seen_ids: list[str] = field(default_factory=list) seen_ids: list[str] = field(default_factory=list)
error: Optional[str] = None error: Optional[str] = None
@@ -211,6 +219,8 @@ class SubscriptionInfo:
"folder": self.folder, "folder": self.folder,
"title_regex": self.title_regex, "title_regex": self.title_regex,
"skip_subscriber_only": self.skip_subscriber_only, "skip_subscriber_only": self.skip_subscriber_only,
"clip_start": self.clip_start,
"clip_end": self.clip_end,
"last_checked": self.last_checked, "last_checked": self.last_checked,
"seen_count": len(self.seen_ids), "seen_count": len(self.seen_ids),
"error": self.error, "error": self.error,
@@ -233,6 +243,7 @@ def _subscription_to_record(sub: SubscriptionInfo) -> dict[str, Any]:
"auto_start": sub.auto_start, "auto_start": sub.auto_start,
"playlist_item_limit": sub.playlist_item_limit, "playlist_item_limit": sub.playlist_item_limit,
"split_by_chapters": sub.split_by_chapters, "split_by_chapters": sub.split_by_chapters,
"sponsorblock": sub.sponsorblock,
"chapter_template": sub.chapter_template, "chapter_template": sub.chapter_template,
"subtitle_language": sub.subtitle_language, "subtitle_language": sub.subtitle_language,
"subtitle_mode": sub.subtitle_mode, "subtitle_mode": sub.subtitle_mode,
@@ -240,6 +251,8 @@ def _subscription_to_record(sub: SubscriptionInfo) -> dict[str, Any]:
"ytdl_options_overrides": sub.ytdl_options_overrides, "ytdl_options_overrides": sub.ytdl_options_overrides,
"title_regex": sub.title_regex, "title_regex": sub.title_regex,
"skip_subscriber_only": sub.skip_subscriber_only, "skip_subscriber_only": sub.skip_subscriber_only,
"clip_start": sub.clip_start,
"clip_end": sub.clip_end,
"last_checked": sub.last_checked, "last_checked": sub.last_checked,
"seen_ids": list(sub.seen_ids), "seen_ids": list(sub.seen_ids),
"error": sub.error, "error": sub.error,
@@ -311,6 +324,33 @@ def validate_subscription_name(value: Any) -> str:
return name return name
def validate_subscription_folder(value: Any) -> str:
"""Return a stored subscription folder, or raise ValueError if unusable.
The folder is relative to the configured download directory, and the
authoritative check still happens at download time in ``DownloadQueue`` —
that is where ``CUSTOM_DIRS``, ``CREATE_CUSTOM_DIRS`` and the
resolves-inside-the-base-directory rule live, and where the directory is
created. This rejects only values that could never be valid, so an edit is
refused while the user is looking at it rather than silently failing every
check from then on. An empty folder is valid and means the base directory.
"""
if value is None:
return ""
if not isinstance(value, str):
raise ValueError("folder must be a string")
folder = value.strip()
if not folder:
return ""
if os.path.isabs(folder):
raise ValueError("folder must be relative to the download directory")
# Check both separators: the value is stored as typed, and a Windows-style
# path would otherwise carry an unexamined '..' past this point.
if any(part == ".." for part in folder.replace("\\", "/").split("/")):
raise ValueError('folder must not contain ".."')
return folder
def _coerce_bool(value: Any) -> bool: def _coerce_bool(value: Any) -> bool:
"""Accept JSON booleans and common string forms used by API clients.""" """Accept JSON booleans and common string forms used by API clients."""
if isinstance(value, bool): if isinstance(value, bool):
@@ -447,6 +487,9 @@ class SubscriptionManager:
subtitle_mode: str, subtitle_mode: str,
ytdl_options_presets: Optional[list[str]] = None, ytdl_options_presets: Optional[list[str]] = None,
ytdl_options_overrides: Optional[dict[str, Any]] = None, ytdl_options_overrides: Optional[dict[str, Any]] = None,
clip_start: Optional[float] = None,
clip_end: Optional[float] = None,
sponsorblock: bool = False,
) -> tuple[list[str], list[str]]: ) -> tuple[list[str], list[str]]:
queued_ids: list[str] = [] queued_ids: list[str] = []
queue_errors: list[str] = [] queue_errors: list[str] = []
@@ -477,6 +520,9 @@ class SubscriptionManager:
subtitle_mode, subtitle_mode,
presets, presets,
ytdl_options_overrides, ytdl_options_overrides,
clip_start,
clip_end,
sponsorblock=sponsorblock,
) )
if isinstance(result, dict) and result.get("status") == "error": if isinstance(result, dict) and result.get("status") == "error":
msg = str(result.get("msg") or f"Queueing failed for {vurl}") msg = str(result.get("msg") or f"Queueing failed for {vurl}")
@@ -564,8 +610,11 @@ class SubscriptionManager:
subtitle_mode: str, subtitle_mode: str,
ytdl_options_presets: Optional[list[str]] = None, ytdl_options_presets: Optional[list[str]] = None,
ytdl_options_overrides: Optional[dict[str, Any]] = None, ytdl_options_overrides: Optional[dict[str, Any]] = None,
sponsorblock: bool = False,
title_regex: Any = None, title_regex: Any = None,
skip_subscriber_only: Any = None, skip_subscriber_only: Any = None,
clip_start: Optional[float] = None,
clip_end: Optional[float] = None,
) -> dict: ) -> dict:
url = self._normalize_url(url) url = self._normalize_url(url)
if not url: if not url:
@@ -645,6 +694,7 @@ class SubscriptionManager:
auto_start=bool(auto_start), auto_start=bool(auto_start),
playlist_item_limit=int(playlist_item_limit), playlist_item_limit=int(playlist_item_limit),
split_by_chapters=bool(split_by_chapters), split_by_chapters=bool(split_by_chapters),
sponsorblock=bool(sponsorblock),
chapter_template=chapter_template or "", chapter_template=chapter_template or "",
subtitle_language=subtitle_language, subtitle_language=subtitle_language,
subtitle_mode=subtitle_mode, subtitle_mode=subtitle_mode,
@@ -652,6 +702,8 @@ class SubscriptionManager:
ytdl_options_overrides=dict(ytdl_options_overrides or {}), ytdl_options_overrides=dict(ytdl_options_overrides or {}),
title_regex=title_regex_stored, title_regex=title_regex_stored,
skip_subscriber_only=skip_so, skip_subscriber_only=skip_so,
clip_start=clip_start,
clip_end=clip_end,
last_checked=time.time(), last_checked=time.time(),
seen_ids=list(dict.fromkeys(all_ids)), seen_ids=list(dict.fromkeys(all_ids)),
error=None, error=None,
@@ -705,6 +757,13 @@ class SubscriptionManager:
except ValueError as exc: except ValueError as exc:
return {"status": "error", "msg": str(exc)} return {"status": "error", "msg": str(exc)}
validated_folder: Optional[str] = None
if "folder" in changes:
try:
validated_folder = validate_subscription_folder(changes["folder"])
except ValueError as exc:
return {"status": "error", "msg": str(exc)}
validated_tr: Optional[str] = None validated_tr: Optional[str] = None
if "title_regex" in changes: if "title_regex" in changes:
try: try:
@@ -755,6 +814,10 @@ class SubscriptionManager:
sub.check_interval_minutes = validated_interval sub.check_interval_minutes = validated_interval
if validated_name is not None: if validated_name is not None:
sub.name = validated_name sub.name = validated_name
if validated_folder is not None:
# Applies to future downloads only; files already downloaded
# stay where they are.
sub.folder = validated_folder
if validated_tr is not None: if validated_tr is not None:
sub.title_regex = validated_tr sub.title_regex = validated_tr
if skip_so_set: if skip_so_set:
@@ -885,6 +948,7 @@ class SubscriptionManager:
dl_plimit = cur.playlist_item_limit dl_plimit = cur.playlist_item_limit
dl_autostart = cur.auto_start dl_autostart = cur.auto_start
dl_split = cur.split_by_chapters dl_split = cur.split_by_chapters
dl_sponsorblock = cur.sponsorblock
dl_chapter = cur.chapter_template dl_chapter = cur.chapter_template
dl_sublang = cur.subtitle_language dl_sublang = cur.subtitle_language
dl_submode = cur.subtitle_mode dl_submode = cur.subtitle_mode
@@ -892,6 +956,8 @@ class SubscriptionManager:
dl_ytdl_overrides = dict(cur.ytdl_options_overrides) dl_ytdl_overrides = dict(cur.ytdl_options_overrides)
dl_title_regex = cur.title_regex or "" dl_title_regex = cur.title_regex or ""
dl_skip_subscriber_only = bool(cur.skip_subscriber_only) dl_skip_subscriber_only = bool(cur.skip_subscriber_only)
dl_clip_start = cur.clip_start
dl_clip_end = cur.clip_end
new_entries: list[dict] = [] new_entries: list[dict] = []
for ent in entries: for ent in entries:
@@ -951,11 +1017,14 @@ class SubscriptionManager:
playlist_item_limit=dl_plimit, playlist_item_limit=dl_plimit,
auto_start=dl_autostart, auto_start=dl_autostart,
split_by_chapters=dl_split, split_by_chapters=dl_split,
sponsorblock=dl_sponsorblock,
chapter_template=dl_chapter or "", chapter_template=dl_chapter or "",
subtitle_language=dl_sublang, subtitle_language=dl_sublang,
subtitle_mode=dl_submode, subtitle_mode=dl_submode,
ytdl_options_presets=dl_ytdl_presets, ytdl_options_presets=dl_ytdl_presets,
ytdl_options_overrides=dl_ytdl_overrides, ytdl_options_overrides=dl_ytdl_overrides,
clip_start=dl_clip_start,
clip_end=dl_clip_end,
) )
log.info( log.info(
"Subscription check finished for %s: %d new, %d filtered, %d subscriber_skipped, %d queued, %d failed", "Subscription check finished for %s: %d new, %d filtered, %d subscriber_skipped, %d queued, %d failed",
+103 -4
View File
@@ -349,17 +349,89 @@ async def test_add_passes_clip_bounds_to_queue(mock_dqueue):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_subscribe_rejects_clip_options(mock_dqueue, monkeypatch): async def test_subscribe_passes_clip_bounds(mock_dqueue, monkeypatch):
monkeypatch.setattr(main.submgr, "add_subscription", AsyncMock()) """Issue #1049: a subscription's options apply to every future download, and
clip bounds were the one option carved out of that."""
monkeypatch.setattr(main.submgr, "add_subscription", AsyncMock(return_value={"status": "ok"}))
req = _json_request( req = _json_request(
{ {
**_valid_video_add_body(clip_start="10"), **_valid_video_add_body(clip_start="2:26", clip_end="3:24"),
"check_interval_minutes": 60, "check_interval_minutes": 60,
} }
) )
resp = await main.subscribe(req)
assert resp.status == 200
kwargs = main.submgr.add_subscription.await_args.kwargs
assert kwargs["clip_start"] == pytest.approx(146.0)
assert kwargs["clip_end"] == pytest.approx(204.0)
@pytest.mark.asyncio
async def test_subscribe_passes_sponsorblock(mock_dqueue, monkeypatch):
monkeypatch.setattr(main.submgr, "add_subscription", AsyncMock(return_value={"status": "ok"}))
req = _json_request(
{**_valid_video_add_body(), "check_interval_minutes": 60, "sponsorblock": True}
)
resp = await main.subscribe(req)
assert resp.status == 200
assert main.submgr.add_subscription.await_args.kwargs["sponsorblock"] is True
@pytest.mark.asyncio
async def test_subscribe_defaults_sponsorblock_off(mock_dqueue, monkeypatch):
monkeypatch.setattr(main.submgr, "add_subscription", AsyncMock(return_value={"status": "ok"}))
req = _json_request({**_valid_video_add_body(), "check_interval_minutes": 60})
await main.subscribe(req)
assert main.submgr.add_subscription.await_args.kwargs["sponsorblock"] is False
@pytest.mark.asyncio
async def test_subscribe_without_clip_fields_stores_none(mock_dqueue, monkeypatch):
monkeypatch.setattr(main.submgr, "add_subscription", AsyncMock(return_value={"status": "ok"}))
req = _json_request({**_valid_video_add_body(), "check_interval_minutes": 60})
await main.subscribe(req)
kwargs = main.submgr.add_subscription.await_args.kwargs
assert kwargs["clip_start"] is None
assert kwargs["clip_end"] is None
@pytest.mark.asyncio
async def test_subscribe_ignores_t_param_in_url(mock_dqueue, monkeypatch):
"""A t= timestamp means "start here" for a one-off download of that video.
On a channel or playlist URL it says nothing about the videos it yields, so
it must not silently clip every future download."""
monkeypatch.setattr(main.submgr, "add_subscription", AsyncMock(return_value={"status": "ok"}))
body = _valid_video_add_body()
# t= is only honoured on YouTube hosts, so this must be one to exercise it.
body["url"] = "https://www.youtube.com/@somechannel?t=90"
req = _json_request({**body, "check_interval_minutes": 60})
await main.subscribe(req)
kwargs = main.submgr.add_subscription.await_args.kwargs
assert kwargs["clip_start"] is None
assert kwargs["clip_end"] is None
# The timestamp is still stripped from the URL that gets stored.
assert "t=90" not in main.submgr.add_subscription.await_args.args[0]
@pytest.mark.asyncio
async def test_subscribe_explicit_clip_wins_over_t_param(mock_dqueue, monkeypatch):
monkeypatch.setattr(main.submgr, "add_subscription", AsyncMock(return_value={"status": "ok"}))
body = _valid_video_add_body(clip_start="30")
body["url"] = "https://www.youtube.com/@somechannel?t=90"
req = _json_request({**body, "check_interval_minutes": 60})
await main.subscribe(req)
kwargs = main.submgr.add_subscription.await_args.kwargs
assert kwargs["clip_start"] == pytest.approx(30.0)
@pytest.mark.asyncio
async def test_subscribe_still_rejects_clips_for_non_media(mock_dqueue, monkeypatch):
monkeypatch.setattr(main.submgr, "add_subscription", AsyncMock(return_value={"status": "ok"}))
body = _valid_video_add_body(clip_start="10")
body["download_type"] = "thumbnail"
req = _json_request({**body, "check_interval_minutes": 60})
with pytest.raises(web.HTTPBadRequest): with pytest.raises(web.HTTPBadRequest):
await main.subscribe(req) await main.subscribe(req)
main.submgr.add_subscription.assert_not_awaited()
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -380,6 +452,33 @@ async def test_subscriptions_update_invalid_interval_returns_error_not_500(mock_
assert body["status"] == "error" assert body["status"] == "error"
@pytest.mark.asyncio
async def test_subscriptions_update_accepts_folder(monkeypatch, mock_dqueue):
"""Issue #1052: folder was absent from the route's accepted fields, so a
folder-only update was rejected outright as having nothing to update."""
submgr = MagicMock()
submgr.update_subscription = AsyncMock(return_value={"status": "ok"})
monkeypatch.setattr(main, "submgr", submgr)
req = _json_request({"id": "abc", "folder": "channels/jane"})
resp = await main.subscriptions_update(req)
assert resp.status == 200
submgr.update_subscription.assert_awaited_once_with("abc", {"folder": "channels/jane"})
@pytest.mark.asyncio
async def test_subscriptions_update_still_drops_unknown_fields(monkeypatch, mock_dqueue):
submgr = MagicMock()
submgr.update_subscription = AsyncMock(return_value={"status": "ok"})
monkeypatch.setattr(main, "submgr", submgr)
req = _json_request({"id": "abc", "seen_ids": ["x"], "url": "https://evil.example"})
with pytest.raises(web.HTTPBadRequest):
await main.subscriptions_update(req)
submgr.update_subscription.assert_not_awaited()
def test_is_within_state_dir_blocks_state_subtree(): def test_is_within_state_dir_blocks_state_subtree():
state_dir = main._STATE_DIR_REAL state_dir = main._STATE_DIR_REAL
assert main._is_within_state_dir(state_dir) assert main._is_within_state_dir(state_dir)
+22
View File
@@ -115,6 +115,28 @@ class ConfigTests(unittest.TestCase):
self.assertNotIn("HOST", safe) self.assertNotIn("HOST", safe)
self.assertEqual(safe["ALLOW_YTDL_OPTIONS_OVERRIDES"], False) self.assertEqual(safe["ALLOW_YTDL_OPTIONS_OVERRIDES"], False)
def test_default_folder_empty_by_default(self):
with patch.dict(os.environ, _base_env(), clear=False):
c = Config()
self.assertEqual(c.DEFAULT_FOLDER, "")
def test_default_folder_is_trimmed_and_reaches_the_frontend(self):
with patch.dict(os.environ, _base_env(DEFAULT_FOLDER=" /youtube/ "), clear=False):
c = Config()
self.assertEqual(c.DEFAULT_FOLDER, "youtube")
self.assertEqual(c.frontend_safe()["DEFAULT_FOLDER"], "youtube")
def test_default_folder_ignored_without_custom_dirs(self):
# The folder field is not shown at all without CUSTOM_DIRS, and sending
# a folder anyway is rejected by the download path check.
with patch.dict(
os.environ,
_base_env(DEFAULT_FOLDER="youtube", CUSTOM_DIRS="false"),
clear=False,
):
c = Config()
self.assertEqual(c.DEFAULT_FOLDER, "")
def test_allow_ytdl_options_overrides_boolean_loaded(self): def test_allow_ytdl_options_overrides_boolean_loaded(self):
with patch.dict(os.environ, _base_env(ALLOW_YTDL_OPTIONS_OVERRIDES="true"), clear=False): with patch.dict(os.environ, _base_env(ALLOW_YTDL_OPTIONS_OVERRIDES="true"), clear=False):
c = Config() c = Config()
+102
View File
@@ -476,6 +476,32 @@ async def test_retry_keeps_overrides_while_still_allowed(dq_env):
assert dq.queue.get(url).info.ytdl_options_overrides == {"writesubtitles": True} assert dq.queue.get(url).info.ytdl_options_overrides == {"writesubtitles": True}
@pytest.mark.asyncio
async def test_retry_carries_the_sponsorblock_flag(dq_env):
notifier = AsyncMock()
dq = DownloadQueue(dq_env, notifier)
url = "https://example.com/watch?v=1"
dq.done.put(
Download(None, None, None, None, "best", "any", {}, _failed_playlist_item(url, sponsorblock=True))
)
def fake_extract(self, extracted_url, *_args, **_kwargs):
return {
"_type": "video",
"id": "vid1",
"title": "Test Video",
"url": extracted_url,
"webpage_url": extracted_url,
}
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", fake_extract), \
patch.object(DownloadQueue, "_DownloadQueue__start_download", new=AsyncMock()):
result = await dq.retry(url)
assert result["status"] == "ok"
assert dq.queue.get(url).info.sponsorblock is True
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_add_entry_duplicate_while_pending_is_skipped_not_clobbered(dq_env): async def test_add_entry_duplicate_while_pending_is_skipped_not_clobbered(dq_env):
notifier = AsyncMock() notifier = AsyncMock()
@@ -625,6 +651,82 @@ async def test_playlist_download_not_treated_as_channel(dq_env):
assert download.output_template.startswith("My Playlist/") assert download.output_template.startswith("My Playlist/")
def _channel_extraction(entry_id, **extra):
"""A channel yt-dlp reported as a playlist, addressed by *entry_id*."""
return {
"_type": "playlist",
"id": entry_id,
"channel_id": "UCabcd123",
"channel": "Odin",
"title": "Odin",
**extra,
"entries": [
{
"id": "vid1",
"title": "Salvia Plath - Pondering",
"url": "https://example.com/watch?v=1",
"webpage_url": "https://example.com/watch?v=1",
"channel": "Odin",
"upload_date": "20130804",
},
],
}
async def _add_and_get_template(dq_env, extraction, url):
dq_env.OUTPUT_TEMPLATE = "%(channel)s [YT]/%(title)s.%(ext)s"
dq_env.OUTPUT_TEMPLATE_CHANNEL = ""
dq_env.OUTPUT_TEMPLATE_PLAYLIST = "%(playlist_title)s/%(title)s.%(ext)s"
def fake_extract(self, _url, *_args, **_kwargs):
return extraction
dq = DownloadQueue(dq_env, AsyncMock())
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", fake_extract):
result = await dq.add(url, "video", "auto", "any", "best", "", "", 0, auto_start=False)
assert result["status"] == "ok"
return dq.pending.get("https://example.com/watch?v=1").output_template
@pytest.mark.asyncio
async def test_bare_handle_channel_url_is_treated_as_a_channel(dq_env):
"""A channel addressed as /@handle reports its id as the handle, not the
channel id, and was falling through to OUTPUT_TEMPLATE_PLAYLIST."""
template = await _add_and_get_template(
dq_env,
_channel_extraction("@odin", uploader_id="@odin"),
"https://www.youtube.com/@odin",
)
assert template.startswith("Odin [YT]/")
@pytest.mark.asyncio
async def test_legacy_vanity_channel_url_is_treated_as_a_channel(dq_env):
"""A legacy /c/Name URL reports the vanity name as its id, while
uploader_id is still the handle."""
template = await _add_and_get_template(
dq_env,
_channel_extraction("Odin", uploader_id="@odin"),
"https://www.youtube.com/c/Odin",
)
assert template.startswith("Odin [YT]/")
@pytest.mark.asyncio
async def test_playlist_with_owner_uploader_id_is_still_a_playlist(dq_env):
"""A real playlist carries its owner's channel_id and uploader_id, but its
own id matches neither, so it must keep the playlist template."""
template = await _add_and_get_template(
dq_env,
_channel_extraction("PLxyz789", uploader_id="@odin", title="My Playlist"),
"https://www.youtube.com/playlist?list=PLxyz789",
)
assert template.startswith("My Playlist/")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_add_merges_global_preset_and_override_options(dq_env): async def test_add_merges_global_preset_and_override_options(dq_env):
notifier = AsyncMock() notifier = AsyncMock()
+202
View File
@@ -409,6 +409,139 @@ class SubscriptionPersistenceTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(sub.seen_ids[:2], ["v2", "v1"]) self.assertEqual(sub.seen_ids[:2], ["v2", "v1"])
self.assertEqual([entry["webpage_url"] for entry, _, _ in queue.entries], ["https://example.com/v2"]) self.assertEqual([entry["webpage_url"] for entry, _, _ in queue.entries], ["https://example.com/v2"])
async def test_check_now_applies_subscription_clip_bounds(self):
"""Issue #1049: clip bounds were the one download option a subscription
could not carry, so they must reach every entry it queues."""
with tempfile.TemporaryDirectory() as tmp:
queue = _Queue()
mgr = SubscriptionManager(_Config(tmp), queue, _Notifier())
with patch(
"subscriptions.extract_flat_playlist",
side_effect=[
(
{"_type": "channel", "title": "Channel"},
[{"id": "v1", "title": "One", "webpage_url": "https://example.com/v1"}],
),
(
{"_type": "channel", "title": "Channel"},
[
{"id": "v2", "title": "Two", "webpage_url": "https://example.com/v2"},
{"id": "v1", "title": "One", "webpage_url": "https://example.com/v1"},
],
),
],
):
result = await mgr.add_subscription(
"https://example.com/channel",
check_interval_minutes=60,
download_type="video",
codec="auto",
format="any",
quality="best",
folder="",
custom_name_prefix="",
auto_start=True,
playlist_item_limit=0,
split_by_chapters=False,
chapter_template="",
subtitle_language="en",
subtitle_mode="prefer_manual",
clip_start=30.0,
clip_end=90.0,
)
sub_id = result["subscription"]["id"]
self.assertEqual(mgr.get(sub_id).clip_start, 30.0)
self.assertEqual(mgr.get(sub_id).clip_end, 90.0)
await mgr.check_now([sub_id])
self.assertEqual(len(queue.entries), 1)
_entry, args, _kwargs = queue.entries[0]
# add_entry(entry, download_type, ..., ytdl_options_overrides, clip_start, clip_end)
self.assertEqual(args[-2], 30.0)
self.assertEqual(args[-1], 90.0)
async def test_clip_bounds_survive_reload_and_default_to_none(self):
with tempfile.TemporaryDirectory() as tmp:
cfg = _Config(tmp)
queue = _Queue()
mgr = SubscriptionManager(cfg, queue, _Notifier())
sub_id = await self._add_one_subscription(mgr)
# Records written before these fields existed simply take the defaults.
self.assertIsNone(mgr.get(sub_id).clip_start)
self.assertIsNone(mgr.get(sub_id).clip_end)
mgr.get(sub_id).clip_start = 12.5
async with mgr._lock:
mgr._save_locked()
reloaded = SubscriptionManager(cfg, _Queue(), _Notifier())
self.assertEqual(reloaded.get(sub_id).clip_start, 12.5)
self.assertIsNone(reloaded.get(sub_id).clip_end)
async def test_check_now_applies_subscription_sponsorblock(self):
"""Subscriptions download unattended, so the sponsor-segment removal has
to reach every entry the subscription queues, not just manual adds."""
with tempfile.TemporaryDirectory() as tmp:
queue = _Queue()
mgr = SubscriptionManager(_Config(tmp), queue, _Notifier())
with patch(
"subscriptions.extract_flat_playlist",
side_effect=[
(
{"_type": "channel", "title": "Channel"},
[{"id": "v1", "title": "One", "webpage_url": "https://example.com/v1"}],
),
(
{"_type": "channel", "title": "Channel"},
[
{"id": "v2", "title": "Two", "webpage_url": "https://example.com/v2"},
{"id": "v1", "title": "One", "webpage_url": "https://example.com/v1"},
],
),
],
):
result = await mgr.add_subscription(
"https://example.com/channel",
check_interval_minutes=60,
download_type="video",
codec="auto",
format="any",
quality="best",
folder="",
custom_name_prefix="",
auto_start=True,
playlist_item_limit=0,
split_by_chapters=False,
chapter_template="",
subtitle_language="en",
subtitle_mode="prefer_manual",
sponsorblock=True,
)
sub_id = result["subscription"]["id"]
self.assertTrue(mgr.get(sub_id).sponsorblock)
await mgr.check_now([sub_id])
self.assertEqual(len(queue.entries), 1)
_entry, _args, kwargs = queue.entries[0]
self.assertIs(kwargs["sponsorblock"], True)
async def test_sponsorblock_survives_reload_and_defaults_to_false(self):
with tempfile.TemporaryDirectory() as tmp:
cfg = _Config(tmp)
mgr = SubscriptionManager(cfg, _Queue(), _Notifier())
sub_id = await self._add_one_subscription(mgr)
# Records written before the field existed simply take the default.
self.assertFalse(mgr.get(sub_id).sponsorblock)
mgr.get(sub_id).sponsorblock = True
async with mgr._lock:
mgr._save_locked()
reloaded = SubscriptionManager(cfg, _Queue(), _Notifier())
self.assertTrue(reloaded.get(sub_id).sponsorblock)
async def test_check_now_queues_subscriber_only_when_skip_disabled(self): async def test_check_now_queues_subscriber_only_when_skip_disabled(self):
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
queue = _Queue() queue = _Queue()
@@ -891,6 +1024,75 @@ class SubscriptionPersistenceTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(upd["status"], "ok") self.assertEqual(upd["status"], "ok")
self.assertEqual(mgr.list_all()[0].name, "x" * 200) self.assertEqual(mgr.list_all()[0].name, "x" * 200)
async def test_update_subscription_changes_folder(self):
"""Issue #1052: the folder was settable at creation and then frozen,
because it was never added to the fields the update route accepts."""
with tempfile.TemporaryDirectory() as tmp:
mgr = SubscriptionManager(_Config(tmp), _Queue(), _Notifier())
sub_id = await self._add_one_subscription(mgr)
self.assertEqual(mgr.list_all()[0].folder, "")
upd = await mgr.update_subscription(sub_id, {"folder": " channels/jane "})
self.assertEqual(upd["status"], "ok")
self.assertEqual(upd["subscription"]["folder"], "channels/jane")
self.assertEqual(mgr.list_all()[0].folder, "channels/jane")
async def test_update_subscription_folder_survives_reload(self):
with tempfile.TemporaryDirectory() as tmp:
cfg = _Config(tmp)
mgr = SubscriptionManager(cfg, _Queue(), _Notifier())
sub_id = await self._add_one_subscription(mgr)
await mgr.update_subscription(sub_id, {"folder": "archive"})
reloaded = SubscriptionManager(cfg, _Queue(), _Notifier())
self.assertEqual(reloaded.get(sub_id).folder, "archive")
async def test_update_subscription_clears_folder(self):
# An empty folder is valid and means the base download directory.
with tempfile.TemporaryDirectory() as tmp:
mgr = SubscriptionManager(_Config(tmp), _Queue(), _Notifier())
sub_id = await self._add_one_subscription(mgr)
await mgr.update_subscription(sub_id, {"folder": "archive"})
upd = await mgr.update_subscription(sub_id, {"folder": " "})
self.assertEqual(upd["status"], "ok")
self.assertEqual(mgr.list_all()[0].folder, "")
async def test_update_subscription_rejects_unusable_folder(self):
with tempfile.TemporaryDirectory() as tmp:
mgr = SubscriptionManager(_Config(tmp), _Queue(), _Notifier())
sub_id = await self._add_one_subscription(mgr)
await mgr.update_subscription(sub_id, {"folder": "keep"})
bad_values = (
"/etc",
"/absolute/path",
"../escape",
"nested/../../escape",
"windows\\..\\escape",
42,
["a"],
)
for bad in bad_values:
upd = await mgr.update_subscription(sub_id, {"folder": bad})
self.assertEqual(upd["status"], "error", f"expected {bad!r} to be rejected")
self.assertEqual(mgr.list_all()[0].folder, "keep")
async def test_update_subscription_folder_leaves_other_fields_alone(self):
with tempfile.TemporaryDirectory() as tmp:
mgr = SubscriptionManager(_Config(tmp), _Queue(), _Notifier())
sub_id = await self._add_one_subscription(mgr)
before = mgr.get(sub_id)
name, interval, enabled = before.name, before.check_interval_minutes, before.enabled
await mgr.update_subscription(sub_id, {"folder": "only/this"})
after = mgr.get(sub_id)
self.assertEqual(after.folder, "only/this")
self.assertEqual(after.name, name)
self.assertEqual(after.check_interval_minutes, interval)
self.assertEqual(after.enabled, enabled)
async def test_update_subscription_skip_subscriber_only(self): async def test_update_subscription_skip_subscriber_only(self):
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
queue = _Queue() queue = _Queue()
+71 -36
View File
@@ -12,7 +12,7 @@ from url_guard import (
_address_allowed_at_connect, _address_allowed_at_connect,
_address_is_global, _address_is_global,
_guarded_getaddrinfo, _guarded_getaddrinfo,
_proxy_endpoint, _url_endpoint,
install_socket_guard, install_socket_guard,
) )
@@ -121,19 +121,19 @@ class ConnectAddressPolicyTests(unittest.TestCase):
self.assertFalse(_address_allowed_at_connect("::1")) self.assertFalse(_address_allowed_at_connect("::1"))
def test_loopback_allowed_only_when_opted_in(self): def test_loopback_allowed_only_when_opted_in(self):
self.assertTrue(_address_allowed_at_connect("127.0.0.1", is_proxy_endpoint=True)) self.assertTrue(_address_allowed_at_connect("127.0.0.1", is_allowed_endpoint=True))
self.assertTrue(_address_allowed_at_connect("::1", is_proxy_endpoint=True)) self.assertTrue(_address_allowed_at_connect("::1", is_allowed_endpoint=True))
def test_proxy_opt_in_covers_any_internal_range(self): def test_proxy_opt_in_covers_any_internal_range(self):
# A proxy is just as legitimately on the LAN or a VPN range as on # A proxy is just as legitimately on the LAN or a VPN range as on
# loopback (#1055): the allowance follows the operator's configured # loopback (#1055): the allowance follows the operator's configured
# endpoint, not a particular address family. # endpoint, not a particular address family.
self.assertTrue(_address_allowed_at_connect("10.1.20.30", is_proxy_endpoint=True)) self.assertTrue(_address_allowed_at_connect("10.1.20.30", is_allowed_endpoint=True))
self.assertTrue(_address_allowed_at_connect("192.168.1.10", is_proxy_endpoint=True)) self.assertTrue(_address_allowed_at_connect("192.168.1.10", is_allowed_endpoint=True))
self.assertTrue(_address_allowed_at_connect("fd00::1", is_proxy_endpoint=True)) self.assertTrue(_address_allowed_at_connect("fd00::1", is_allowed_endpoint=True))
def test_opt_in_still_rejects_non_addresses(self): def test_opt_in_still_rejects_non_addresses(self):
self.assertFalse(_address_allowed_at_connect("not-an-ip", is_proxy_endpoint=True)) self.assertFalse(_address_allowed_at_connect("not-an-ip", is_allowed_endpoint=True))
def test_link_local_metadata_blocked(self): def test_link_local_metadata_blocked(self):
self.assertFalse(_address_allowed_at_connect("169.254.169.254")) self.assertFalse(_address_allowed_at_connect("169.254.169.254"))
@@ -187,36 +187,36 @@ class TunnelledIPv4Tests(unittest.TestCase):
self.assertIsNotNone(validate_url("http://nat64.example/x")) self.assertIsNotNone(validate_url("http://nat64.example/x"))
class ProxyEndpointParsingTests(unittest.TestCase): class EndpointParsingTests(unittest.TestCase):
def test_explicit_port(self): def test_explicit_port(self):
self.assertEqual(_proxy_endpoint("http://127.0.0.1:9050"), ("127.0.0.1", 9050)) self.assertEqual(_url_endpoint("http://127.0.0.1:9050"), ("127.0.0.1", 9050))
def test_default_port_per_scheme(self): def test_default_port_per_scheme(self):
self.assertEqual(_proxy_endpoint("socks5://127.0.0.1"), ("127.0.0.1", 1080)) self.assertEqual(_url_endpoint("socks5://127.0.0.1"), ("127.0.0.1", 1080))
self.assertEqual(_proxy_endpoint("http://127.0.0.1"), ("127.0.0.1", 80)) self.assertEqual(_url_endpoint("http://127.0.0.1"), ("127.0.0.1", 80))
def test_bare_host_port(self): def test_bare_host_port(self):
self.assertEqual(_proxy_endpoint("127.0.0.1:8080"), ("127.0.0.1", 8080)) self.assertEqual(_url_endpoint("127.0.0.1:8080"), ("127.0.0.1", 8080))
def test_hostname_lowercased(self): def test_hostname_lowercased(self):
self.assertEqual(_proxy_endpoint("http://LocalHost.:9050"), ("localhost", 9050)) self.assertEqual(_url_endpoint("http://LocalHost.:9050"), ("localhost", 9050))
def test_ipv6_literal(self): def test_ipv6_literal(self):
self.assertEqual(_proxy_endpoint("http://[::1]:9050"), ("::1", 9050)) self.assertEqual(_url_endpoint("http://[::1]:9050"), ("::1", 9050))
def test_empty_and_invalid(self): def test_empty_and_invalid(self):
self.assertIsNone(_proxy_endpoint("")) self.assertIsNone(_url_endpoint(""))
self.assertIsNone(_proxy_endpoint(" ")) self.assertIsNone(_url_endpoint(" "))
self.assertIsNone(_proxy_endpoint(None)) self.assertIsNone(_url_endpoint(None))
self.assertIsNone(_proxy_endpoint("http://")) self.assertIsNone(_url_endpoint("http://"))
class GuardedGetaddrinfoTests(unittest.TestCase): class GuardedGetaddrinfoTests(unittest.TestCase):
def setUp(self): def setUp(self):
# Default state: no proxy configured, so no loopback destination allowed. # Default state: no proxy configured, so no loopback destination allowed.
saved = set(url_guard._allowed_proxy_endpoints) saved = set(url_guard._allowed_endpoints)
url_guard._allowed_proxy_endpoints = set() url_guard._allowed_endpoints = set()
self.addCleanup(lambda: setattr(url_guard, "_allowed_proxy_endpoints", saved)) self.addCleanup(lambda: setattr(url_guard, "_allowed_endpoints", saved))
def test_internal_only_raises(self): def test_internal_only_raises(self):
with mock.patch("url_guard._real_getaddrinfo", return_value=_addrinfo("169.254.169.254")): with mock.patch("url_guard._real_getaddrinfo", return_value=_addrinfo("169.254.169.254")):
@@ -235,27 +235,27 @@ class GuardedGetaddrinfoTests(unittest.TestCase):
with self.assertRaises(socket.gaierror): with self.assertRaises(socket.gaierror):
_guarded_getaddrinfo("127.0.0.1", 9999) _guarded_getaddrinfo("127.0.0.1", 9999)
def test_loopback_allowed_at_configured_proxy_endpoint(self): def test_loopback_allowed_at_configured_url_endpoint(self):
url_guard._allowed_proxy_endpoints = {("127.0.0.1", 9050)} url_guard._allowed_endpoints = {("127.0.0.1", 9050)}
with mock.patch("url_guard._real_getaddrinfo", return_value=_addrinfo("127.0.0.1")): with mock.patch("url_guard._real_getaddrinfo", return_value=_addrinfo("127.0.0.1")):
results = _guarded_getaddrinfo("127.0.0.1", 9050) results = _guarded_getaddrinfo("127.0.0.1", 9050)
self.assertEqual([r[4][0] for r in results], ["127.0.0.1"]) self.assertEqual([r[4][0] for r in results], ["127.0.0.1"])
def test_loopback_blocked_at_other_port_on_proxy_host(self): def test_loopback_blocked_at_other_port_on_proxy_host(self):
# Same host as the proxy, different port: still off limits. # Same host as the proxy, different port: still off limits.
url_guard._allowed_proxy_endpoints = {("127.0.0.1", 9050)} url_guard._allowed_endpoints = {("127.0.0.1", 9050)}
with mock.patch("url_guard._real_getaddrinfo", return_value=_addrinfo("127.0.0.1")): with mock.patch("url_guard._real_getaddrinfo", return_value=_addrinfo("127.0.0.1")):
with self.assertRaises(socket.gaierror): with self.assertRaises(socket.gaierror):
_guarded_getaddrinfo("127.0.0.1", 9999) _guarded_getaddrinfo("127.0.0.1", 9999)
def test_proxy_reachable_by_hostname(self): def test_proxy_reachable_by_hostname(self):
url_guard._allowed_proxy_endpoints = {("localhost", 9050)} url_guard._allowed_endpoints = {("localhost", 9050)}
with mock.patch("url_guard._real_getaddrinfo", return_value=_addrinfo("127.0.0.1")): with mock.patch("url_guard._real_getaddrinfo", return_value=_addrinfo("127.0.0.1")):
results = _guarded_getaddrinfo("localhost", 9050) results = _guarded_getaddrinfo("localhost", 9050)
self.assertEqual([r[4][0] for r in results], ["127.0.0.1"]) self.assertEqual([r[4][0] for r in results], ["127.0.0.1"])
def test_string_port_is_normalised(self): def test_string_port_is_normalised(self):
url_guard._allowed_proxy_endpoints = {("127.0.0.1", 9050)} url_guard._allowed_endpoints = {("127.0.0.1", 9050)}
with mock.patch("url_guard._real_getaddrinfo", return_value=_addrinfo("127.0.0.1")): with mock.patch("url_guard._real_getaddrinfo", return_value=_addrinfo("127.0.0.1")):
results = _guarded_getaddrinfo("127.0.0.1", "9050") results = _guarded_getaddrinfo("127.0.0.1", "9050")
self.assertEqual([r[4][0] for r in results], ["127.0.0.1"]) self.assertEqual([r[4][0] for r in results], ["127.0.0.1"])
@@ -263,22 +263,38 @@ class GuardedGetaddrinfoTests(unittest.TestCase):
def test_lan_proxy_reachable(self): def test_lan_proxy_reachable(self):
# #1055: a socks5 proxy on the LAN, refused while the allowance was # #1055: a socks5 proxy on the LAN, refused while the allowance was
# loopback-only, which pushed operators to ALLOW_PRIVATE_ADDRESSES. # loopback-only, which pushed operators to ALLOW_PRIVATE_ADDRESSES.
url_guard._allowed_proxy_endpoints = {("10.1.20.30", 1080)} url_guard._allowed_endpoints = {("10.1.20.30", 1080)}
with mock.patch("url_guard._real_getaddrinfo", return_value=_addrinfo("10.1.20.30")): with mock.patch("url_guard._real_getaddrinfo", return_value=_addrinfo("10.1.20.30")):
results = _guarded_getaddrinfo("10.1.20.30", 1080) results = _guarded_getaddrinfo("10.1.20.30", 1080)
self.assertEqual([r[4][0] for r in results], ["10.1.20.30"]) self.assertEqual([r[4][0] for r in results], ["10.1.20.30"])
def test_other_lan_host_still_blocked(self): def test_other_lan_host_still_blocked(self):
# The allowance is the proxy's endpoint, not its subnet. # The allowance is the proxy's endpoint, not its subnet.
url_guard._allowed_proxy_endpoints = {("10.1.20.30", 1080)} url_guard._allowed_endpoints = {("10.1.20.30", 1080)}
with mock.patch("url_guard._real_getaddrinfo", return_value=_addrinfo("10.1.20.31")): with mock.patch("url_guard._real_getaddrinfo", return_value=_addrinfo("10.1.20.31")):
with self.assertRaises(socket.gaierror): with self.assertRaises(socket.gaierror):
_guarded_getaddrinfo("10.1.20.31", 1080) _guarded_getaddrinfo("10.1.20.31", 1080)
def test_pot_provider_reachable_on_loopback(self):
# #1064: the bundled PO token provider listens on loopback, and blocking
# it left every default install downloading YouTube without a token.
url_guard._allowed_endpoints = {("127.0.0.1", 4416)}
with mock.patch("url_guard._real_getaddrinfo", return_value=_addrinfo("127.0.0.1")):
results = _guarded_getaddrinfo("127.0.0.1", 4416)
self.assertEqual([r[4][0] for r in results], ["127.0.0.1"])
def test_other_loopback_service_still_blocked(self):
# MeTube's own port is one hop away on the same interface: allowing the
# token provider must not allow the rest of loopback.
url_guard._allowed_endpoints = {("127.0.0.1", 4416)}
with mock.patch("url_guard._real_getaddrinfo", return_value=_addrinfo("127.0.0.1")):
with self.assertRaises(socket.gaierror):
_guarded_getaddrinfo("127.0.0.1", 8081)
def test_proxy_address_not_borrowable_by_another_host(self): def test_proxy_address_not_borrowable_by_another_host(self):
# Matching is on the configured host string: a manifest URL that resolves # Matching is on the configured host string: a manifest URL that resolves
# to the proxy's address under its own name gets no allowance. # to the proxy's address under its own name gets no allowance.
url_guard._allowed_proxy_endpoints = {("10.1.20.30", 1080)} url_guard._allowed_endpoints = {("10.1.20.30", 1080)}
with mock.patch("url_guard._real_getaddrinfo", return_value=_addrinfo("10.1.20.30")): with mock.patch("url_guard._real_getaddrinfo", return_value=_addrinfo("10.1.20.30")):
with self.assertRaises(socket.gaierror): with self.assertRaises(socket.gaierror):
_guarded_getaddrinfo("evil.example", 1080) _guarded_getaddrinfo("evil.example", 1080)
@@ -312,9 +328,9 @@ class AllowPrivateBypassTests(unittest.TestCase):
class InstallSocketGuardTests(unittest.TestCase): class InstallSocketGuardTests(unittest.TestCase):
def setUp(self): def setUp(self):
original, saved = socket.getaddrinfo, set(url_guard._allowed_proxy_endpoints) original, saved = socket.getaddrinfo, set(url_guard._allowed_endpoints)
self.addCleanup(lambda: setattr(socket, "getaddrinfo", original)) self.addCleanup(lambda: setattr(socket, "getaddrinfo", original))
self.addCleanup(lambda: setattr(url_guard, "_allowed_proxy_endpoints", saved)) self.addCleanup(lambda: setattr(url_guard, "_allowed_endpoints", saved))
# Keep the host's own environment out of the assertions below. # Keep the host's own environment out of the assertions below.
patcher = mock.patch("url_guard.urllib.request.getproxies", return_value={}) patcher = mock.patch("url_guard.urllib.request.getproxies", return_value={})
self.getproxies = patcher.start() self.getproxies = patcher.start()
@@ -329,26 +345,45 @@ class InstallSocketGuardTests(unittest.TestCase):
def test_no_proxy_means_no_loopback_allowance(self): def test_no_proxy_means_no_loopback_allowance(self):
install_socket_guard() install_socket_guard()
self.assertEqual(url_guard._allowed_proxy_endpoints, set()) self.assertEqual(url_guard._allowed_endpoints, set())
def test_explicit_proxy_is_registered(self): def test_explicit_proxy_is_registered(self):
install_socket_guard(proxy_urls=("socks5://127.0.0.1:9050",)) install_socket_guard(proxy_urls=("socks5://127.0.0.1:9050",))
self.assertEqual(url_guard._allowed_proxy_endpoints, {("127.0.0.1", 9050)}) self.assertEqual(url_guard._allowed_endpoints, {("127.0.0.1", 9050)})
def test_unset_proxy_option_is_ignored(self): def test_unset_proxy_option_is_ignored(self):
# ytdl_opts.get('proxy') is None when the operator configured no proxy. # ytdl_opts.get('proxy') is None when the operator configured no proxy.
install_socket_guard(proxy_urls=(None,)) install_socket_guard(proxy_urls=(None,))
self.assertEqual(url_guard._allowed_proxy_endpoints, set()) self.assertEqual(url_guard._allowed_endpoints, set())
def test_environment_proxies_are_registered(self): def test_environment_proxies_are_registered(self):
self.getproxies.return_value = {"http": "http://127.0.0.1:8080"} self.getproxies.return_value = {"http": "http://127.0.0.1:8080"}
install_socket_guard() install_socket_guard()
self.assertEqual(url_guard._allowed_proxy_endpoints, {("127.0.0.1", 8080)}) self.assertEqual(url_guard._allowed_endpoints, {("127.0.0.1", 8080)})
def test_service_url_is_registered(self):
install_socket_guard(service_urls=("http://127.0.0.1:4416",))
self.assertEqual(url_guard._allowed_endpoints, {("127.0.0.1", 4416)})
def test_service_and_proxy_endpoints_coexist(self):
install_socket_guard(
proxy_urls=("socks5://10.1.20.30:1080",),
service_urls=("http://127.0.0.1:4416",),
)
self.assertEqual(
url_guard._allowed_endpoints,
{("10.1.20.30", 1080), ("127.0.0.1", 4416)},
)
def test_service_urls_reset_between_installs(self):
install_socket_guard(service_urls=("http://127.0.0.1:4416",))
install_socket_guard()
self.assertEqual(url_guard._allowed_endpoints, set())
def test_endpoints_reset_between_installs(self): def test_endpoints_reset_between_installs(self):
install_socket_guard(proxy_urls=("http://127.0.0.1:8080",)) install_socket_guard(proxy_urls=("http://127.0.0.1:8080",))
install_socket_guard(proxy_urls=(None,)) install_socket_guard(proxy_urls=(None,))
self.assertEqual(url_guard._allowed_proxy_endpoints, set()) self.assertEqual(url_guard._allowed_endpoints, set())
if __name__ == "__main__": if __name__ == "__main__":
+347
View File
@@ -50,6 +50,7 @@ class _YoutubeDL:
fake_utils.DownloadError = type("DownloadError", (Exception,), {}) fake_utils.DownloadError = type("DownloadError", (Exception,), {})
fake_utils.YoutubeDLError = fake_utils.DownloadError
fake_yt_dlp.YoutubeDL = _YoutubeDL fake_yt_dlp.YoutubeDL = _YoutubeDL
fake_impersonate.ImpersonateTarget = _ImpersonateTarget fake_impersonate.ImpersonateTarget = _ImpersonateTarget
fake_networking.impersonate = fake_impersonate fake_networking.impersonate = fake_impersonate
@@ -76,6 +77,7 @@ from ytdl import (
MusicMetadataPreProcessor, MusicMetadataPreProcessor,
_compact_persisted_entry, _compact_persisted_entry,
_convert_srt_to_txt_file, _convert_srt_to_txt_file,
_pot_provider_urls,
_AlbumArtistPostProcessor, _AlbumArtistPostProcessor,
_resolve_outtmpl_fields, _resolve_outtmpl_fields,
_sanitize_entry_for_pickle, _sanitize_entry_for_pickle,
@@ -376,6 +378,49 @@ class ConfinedYoutubeDLTests(unittest.TestCase):
self.assertEqual(self._prepared_path(""), "") self.assertEqual(self._prepared_path(""), "")
self.assertEqual(self._prepared_path("-"), "-") self.assertEqual(self._prepared_path("-"), "-")
def test_overlong_name_is_trimmed_to_fit_the_filesystem(self):
# A title long enough to blow the filename limit is what made these
# downloads fail outright with [Errno 36] File name too long.
long_path = os.path.join(self.base, "a" * 400 + ".mp4")
result = self._prepared_path(long_path)
name = os.path.basename(result)
self.assertTrue(name.endswith(".mp4"))
self.assertLessEqual(len(name.encode("utf-8")), 255 - 32)
self.assertEqual(os.path.dirname(result), self.base)
# The file must still be writable once yt-dlp adds its own suffixes.
self.assertLessEqual(len(f"{name}.f1229065279304024v.part".encode("utf-8")), 255)
def test_name_within_the_limit_is_left_alone(self):
ok = os.path.join(self.base, "Ordinary Title.mp4")
self.assertEqual(self._prepared_path(ok), ok)
def test_limit_counts_bytes_not_characters(self):
# 200 CJK characters are 600 bytes: a character count would pass this.
long_path = os.path.join(self.base, "" * 200 + ".mp4")
name = os.path.basename(self._prepared_path(long_path))
self.assertLessEqual(len(name.encode("utf-8")), 255 - 32)
# A trim landing mid-character must not leave a broken byte sequence.
self.assertEqual(name, name.encode("utf-8").decode("utf-8"))
self.assertTrue(name.endswith(".mp4"))
def test_a_long_tail_is_not_mistaken_for_an_extension(self):
# os.path.splitext on a title containing a dot late in the string would
# otherwise "preserve" a 100-character extension and trim nothing.
long_path = os.path.join(self.base, "b" * 300 + "." + "c" * 100)
name = os.path.basename(self._prepared_path(long_path))
self.assertLessEqual(len(name.encode("utf-8")), 255 - 32)
def test_trimming_still_cannot_escape_the_download_directory(self):
escaping = os.path.join(self.base, "..", "..", "d" * 400 + ".mp4")
with self.assertRaises(ytdl.yt_dlp.utils.DownloadError):
self._prepared_path(escaping)
class SanitizeEntryForPickleTests(unittest.TestCase): class SanitizeEntryForPickleTests(unittest.TestCase):
def test_nested(self): def test_nested(self):
@@ -434,6 +479,269 @@ def _make_test_download() -> Download:
return Download("/tmp", "/tmp", "%(title)s.%(ext)s", "%(title)s.%(ext)s", "best", "any", {}, info) return Download("/tmp", "/tmp", "%(title)s.%(ext)s", "%(title)s.%(ext)s", "best", "any", {}, info)
class DownloadLoggerTests(unittest.TestCase):
def test_routes_messages_and_retains_only_non_empty_warnings(self):
logger = ytdl._DownloadYtdlLogger()
with self.assertLogs('ytdl', level='DEBUG') as logs:
logger.debug('debug detail')
logger.warning(' useful warning ')
logger.warning(' ')
logger.error('error detail')
self.assertEqual(logger.warnings, ['useful warning'])
self.assertIn('DEBUG:ytdl:debug detail', logs.output)
self.assertIn('WARNING:ytdl: useful warning ', logs.output)
self.assertIn('ERROR:ytdl:error detail', logs.output)
def test_retains_only_the_last_distinct_warnings(self):
logger = ytdl._DownloadYtdlLogger()
cap = ytdl._MAX_RETAINED_WARNINGS
with self.assertLogs('ytdl', level='WARNING') as logs:
for index in range(cap + 3):
logger.warning(f'fragment {index} not found')
self.assertEqual(
logger.warnings,
[f'fragment {index} not found' for index in range(3, cap + 3)],
)
# Every warning still reaches the log; only the retained list is bounded.
self.assertEqual(len(logs.output), cap + 3)
def test_repeated_warning_is_retained_once(self):
logger = ytdl._DownloadYtdlLogger()
with self.assertLogs('ytdl', level='WARNING'):
logger.warning('Requested format is not available')
logger.warning('Only images are available for download')
logger.warning('Requested format is not available')
self.assertEqual(
logger.warnings,
['Requested format is not available', 'Only images are available for download'],
)
def test_failure_message_puts_the_error_last(self):
logger = ytdl._DownloadYtdlLogger()
with self.assertLogs('ytdl', level='WARNING'):
logger.warning('Only images are available for download')
self.assertEqual(
logger.failure_message('ERROR: [youtube] u2HSc2Ym1Vk: No video formats found!'),
'Only images are available for download\n'
'ERROR: [youtube] u2HSc2Ym1Vk: No video formats found!',
)
def test_failure_message_skips_a_last_warning_that_repeats_the_error(self):
logger = ytdl._DownloadYtdlLogger()
with self.assertLogs('ytdl', level='WARNING'):
logger.warning('Video unavailable')
# yt-dlp labels errors but hands warnings to the logger unlabelled,
# so the same text can arrive through both routes.
logger.warning('Requested format is not available')
self.assertEqual(
logger.failure_message('ERROR: Requested format is not available'),
'Video unavailable\nERROR: Requested format is not available',
)
def test_failure_message_without_warnings_is_the_error_alone(self):
logger = ytdl._DownloadYtdlLogger()
self.assertEqual(logger.failure_message('ERROR: boom'), 'ERROR: boom')
class DownloadResultTests(unittest.TestCase):
def _run_download(self, result=0, warnings=(), error=None):
download = _make_test_download()
statuses = []
download.status_queue = types.SimpleNamespace(put=statuses.append)
captured_params = {}
class FakeYoutubeDL:
def download(self, urls):
self.urls = urls
for warning in warnings:
captured_params['logger'].warning(warning)
if error is not None:
raise error
return result
def make_youtube_dl(params):
captured_params.update(params)
return FakeYoutubeDL()
with patch.object(download, '_make_youtube_dl', side_effect=make_youtube_dl), \
patch('ytdl.install_socket_guard'), \
patch('ytdl.os.setpgrp'):
download._download()
return statuses, captured_params
def test_nonzero_result_includes_warning_context_and_forwards_logs(self):
warnings = [
'The uploader has blocked this video in your country',
'No video formats found',
]
with self.assertLogs('ytdl', level='WARNING') as logs:
statuses, params = self._run_download(result=1, warnings=warnings)
self.assertEqual(
statuses[-1],
{'status': 'error', 'msg': '\n'.join(warnings)},
)
self.assertIs(params['logger'].__class__, ytdl._DownloadYtdlLogger)
for warning in warnings:
self.assertTrue(any(warning in entry for entry in logs.output))
def test_nonzero_result_without_warning_uses_fallback_message(self):
statuses, _ = self._run_download(result=2)
self.assertEqual(
statuses[-1],
{'status': 'error', 'msg': 'yt-dlp failed with exit code 2'},
)
def test_warning_does_not_change_success_status(self):
statuses, _ = self._run_download(result=0, warnings=['A recoverable warning'])
self.assertEqual(statuses[-1], {'status': 'finished'})
def test_youtube_dl_error_carries_the_warnings_that_explain_it(self):
# The sequence from issue #1047: yt-dlp raises DownloadError, so the
# warnings naming the real cause only reach the user if the exception
# branch carries them too.
statuses, _ = self._run_download(
warnings=[
'[youtube] Video unavailable. This video contains content from bryhuangpub,'
' who has blocked it from display on this website or application',
'Only images are available for download. use --list-formats to see them',
'Requested format is not available',
],
error=ytdl.yt_dlp.utils.YoutubeDLError(
'ERROR: [youtube] u2HSc2Ym1Vk: No video formats found!'
),
)
self.assertEqual(
statuses[-1],
{
'status': 'error',
'msg': '[youtube] Video unavailable. This video contains content from bryhuangpub,'
' who has blocked it from display on this website or application\n'
'Only images are available for download. use --list-formats to see them\n'
'Requested format is not available\n'
'ERROR: [youtube] u2HSc2Ym1Vk: No video formats found!',
},
)
def test_youtube_dl_error_drops_a_last_warning_that_repeats_it(self):
statuses, _ = self._run_download(
warnings=['Earlier warning', 'Requested format is not available'],
error=ytdl.yt_dlp.utils.YoutubeDLError('ERROR: Requested format is not available'),
)
self.assertEqual(
statuses[-1],
{
'status': 'error',
'msg': 'Earlier warning\nERROR: Requested format is not available',
},
)
def test_youtube_dl_error_message_is_bounded(self):
cap = ytdl._MAX_RETAINED_WARNINGS
statuses, _ = self._run_download(
warnings=[f'fragment {index} not found' for index in range(cap + 4)],
error=ytdl.yt_dlp.utils.YoutubeDLError('ERROR: giving up'),
)
msg = statuses[-1]['msg']
self.assertEqual(
msg.split('\n'),
[f'fragment {index} not found' for index in range(4, cap + 4)] + ['ERROR: giving up'],
)
def test_nonzero_result_message_is_bounded(self):
cap = ytdl._MAX_RETAINED_WARNINGS
statuses, _ = self._run_download(
result=1,
warnings=[f'fragment {index} not found' for index in range(cap + 4)],
)
self.assertEqual(
statuses[-1]['msg'].split('\n'),
[f'fragment {index} not found' for index in range(4, cap + 4)],
)
def _capture_ytdl_params(download: Download) -> dict:
"""Run ``_download`` far enough to capture the params it builds."""
fake_ydl = MagicMock()
fake_ydl.download.return_value = 0
download.status_queue = types.SimpleNamespace(put=lambda _: None)
with patch('ytdl.install_socket_guard'), \
patch.object(Download, '_make_youtube_dl', return_value=fake_ydl) as make:
download._download()
params, = make.call_args.args
return params
class SponsorBlockPostprocessorTests(unittest.TestCase):
def test_no_sponsorblock_postprocessors_when_disabled(self):
download = _make_test_download()
params = _capture_ytdl_params(download)
keys = [pp['key'] for pp in params.get('postprocessors', [])]
self.assertNotIn('SponsorBlock', keys)
self.assertNotIn('ModifyChapters', keys)
def test_sponsorblock_pair_matches_the_cli(self):
download = _make_test_download()
download.info.sponsorblock = True
params = _capture_ytdl_params(download)
self.assertEqual(
params['postprocessors'],
[
{
'key': 'SponsorBlock',
'categories': ['sponsor'],
'when': 'after_filter',
},
{
'key': 'ModifyChapters',
'remove_sponsor_segments': ['sponsor'],
'force_keyframes': False,
},
],
)
def test_segment_removal_runs_before_the_chapter_split(self):
# yt-dlp runs same-stage postprocessors in list order, so ModifyChapters
# has to rewrite the chapter list before FFmpegSplitChapters cuts the
# file up -- the order the CLI builds for
# --sponsorblock-remove sponsor --split-chapters.
download = _make_test_download()
download.info.sponsorblock = True
download.info.split_by_chapters = True
download.info.chapter_template = '%(section_number)s.%(ext)s'
params = _capture_ytdl_params(download)
keys = [pp['key'] for pp in params['postprocessors']]
self.assertEqual(keys, ['SponsorBlock', 'ModifyChapters', 'FFmpegSplitChapters'])
self.assertEqual(params['outtmpl']['chapter'], '%(section_number)s.%(ext)s')
class ProgressThrottleTests(unittest.TestCase): class ProgressThrottleTests(unittest.TestCase):
def test_downloading_ticks_are_throttled(self): def test_downloading_ticks_are_throttled(self):
dl = _make_test_download() dl = _make_test_download()
@@ -827,5 +1135,44 @@ class ShortTitleForFailedUrlTests(unittest.TestCase):
self.assertEqual(_short_title_for_failed_url(malformed), malformed) self.assertEqual(_short_title_for_failed_url(malformed), malformed)
class PotProviderUrlsTests(unittest.TestCase):
"""#1064: the connect-time guard must let the download reach the PO token
provider, so it has to know every endpoint yt-dlp might dial for one."""
def test_bundled_provider_by_default(self):
self.assertEqual(_pot_provider_urls({}), ("http://127.0.0.1:4416",))
def test_configured_base_url_is_added(self):
urls = _pot_provider_urls({
"extractor_args": {"youtubepot-bgutilhttp": {"base_url": ["http://pot:4416"]}},
})
# The bundled server runs regardless, so both stay reachable.
self.assertEqual(urls, ("http://127.0.0.1:4416", "http://pot:4416"))
def test_deprecated_base_url_arg_is_honoured(self):
urls = _pot_provider_urls({
"extractor_args": {"youtube": {"getpot_bgutil_baseurl": ["http://pot:4416"]}},
})
self.assertEqual(urls, ("http://127.0.0.1:4416", "http://pot:4416"))
def test_unrelated_extractor_args_are_ignored(self):
urls = _pot_provider_urls({
"extractor_args": {"youtube": {"player_client": ["web"]}},
})
self.assertEqual(urls, ("http://127.0.0.1:4416",))
def test_malformed_extractor_args_do_not_raise(self):
# YTDL_OPTIONS is operator-supplied JSON and reaches here unvalidated.
for opts in (
{"extractor_args": None},
{"extractor_args": "youtube:player_client=web"},
{"extractor_args": {"youtubepot-bgutilhttp": "http://pot:4416"}},
{"extractor_args": {"youtubepot-bgutilhttp": {"base_url": []}}},
):
with self.subTest(opts=opts):
self.assertEqual(_pot_provider_urls(opts), ("http://127.0.0.1:4416",))
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+52 -38
View File
@@ -37,8 +37,8 @@ log = logging.getLogger('url_guard')
_ALLOWED_SCHEMES = ('http', 'https') _ALLOWED_SCHEMES = ('http', 'https')
# Ports to assume when a configured proxy URL omits one, per proxy scheme. # Ports to assume when a configured endpoint URL omits one, per scheme.
_PROXY_DEFAULT_PORTS = { _SCHEME_DEFAULT_PORTS = {
'http': 80, 'http': 80,
'https': 443, 'https': 443,
'socks4': 1080, 'socks4': 1080,
@@ -129,31 +129,32 @@ def _address_is_global(addr: str) -> bool:
return bool(ips) and all(ip.is_global for ip in ips) return bool(ips) and all(ip.is_global for ip in ips)
def _address_allowed_at_connect(addr: str, is_proxy_endpoint: bool = False) -> bool: def _address_allowed_at_connect(addr: str, is_allowed_endpoint: bool = False) -> bool:
"""True if *addr* may be connected to at download time. """True if *addr* may be connected to at download time.
Permits global addresses, and anything at all when the destination is an Permits global addresses, and anything at all when the destination is an
operator-configured proxy (see ``_is_proxy_endpoint``). Internal addresses endpoint the operator or the image configured a proxy, or the PO token
are otherwise refused with no blanket exception: media URLs that yt-dlp provider (see ``_is_allowed_endpoint``). Internal addresses are otherwise
derives from a remote manifest are attacker-controlled and reach this policy refused with no blanket exception: media URLs that yt-dlp derives from a
without passing ``validate_url``, so any range opened here is a range a remote manifest are attacker-controlled and reach this policy without passing
hostile playlist can read from the server's own network. Blocks link-local ``validate_url``, so any range opened here is a range a hostile playlist can
read from the server's own network. Blocks link-local
(cloud metadata at 169.254.169.254), private (RFC1918), loopback, (cloud metadata at 169.254.169.254), private (RFC1918), loopback,
unique-local and every other non-global range. unique-local and every other non-global range.
""" """
ips = _ips_to_judge(addr) ips = _ips_to_judge(addr)
if not ips: if not ips:
return False return False
return is_proxy_endpoint or all(ip.is_global for ip in ips) return is_allowed_endpoint or all(ip.is_global for ip in ips)
def _proxy_endpoint(proxy_url: str): def _url_endpoint(url: str):
"""Parse a proxy URL into a ``(hostname, port)`` pair, or ``None`` if it has """Parse a configured URL into a ``(hostname, port)`` pair, or ``None`` if it
no usable host. Used to scope the internal-address allowance to that endpoint has no usable host. Used to scope the internal-address allowance to that
alone.""" endpoint alone."""
if not isinstance(proxy_url, str) or not proxy_url.strip(): if not isinstance(url, str) or not url.strip():
return None return None
candidate = proxy_url.strip() candidate = url.strip()
if '://' not in candidate: if '://' not in candidate:
# Bare host:port, as accepted by the *_proxy environment variables. # Bare host:port, as accepted by the *_proxy environment variables.
candidate = '//' + candidate candidate = '//' + candidate
@@ -165,23 +166,28 @@ def _proxy_endpoint(proxy_url: str):
if not hostname: if not hostname:
return None return None
if port is None: if port is None:
port = _PROXY_DEFAULT_PORTS.get(parts.scheme.lower()) port = _SCHEME_DEFAULT_PORTS.get(parts.scheme.lower())
return (hostname.rstrip('.').lower(), port) return (hostname.rstrip('.').lower(), port)
def _endpoints(urls) -> set:
"""The parseable endpoints among *urls*, dropping any that name no host."""
return {ep for ep in map(_url_endpoint, urls) if ep is not None}
def _collect_proxy_endpoints(proxy_urls) -> set: def _collect_proxy_endpoints(proxy_urls) -> set:
"""Endpoints of every proxy this download may legitimately dial: the explicit """Endpoints of every proxy this download may legitimately dial: the explicit
yt-dlp ``proxy`` option plus the ``*_proxy`` environment variables yt-dlp falls yt-dlp ``proxy`` option plus the ``*_proxy`` environment variables yt-dlp falls
back to. All are operator-configured, unlike the URLs inside fetched media.""" back to. All are operator-configured, unlike the URLs inside fetched media."""
candidates = list(proxy_urls) + list(urllib.request.getproxies().values()) candidates = list(proxy_urls) + list(urllib.request.getproxies().values())
return {ep for ep in map(_proxy_endpoint, candidates) if ep is not None} return _endpoints(candidates)
# Captured at import so re-installing the guard never wraps the wrapper. # Captured at import so re-installing the guard never wraps the wrapper.
_real_getaddrinfo = socket.getaddrinfo _real_getaddrinfo = socket.getaddrinfo
# Populated by install_socket_guard; empty means no internal destination is allowed. # Populated by install_socket_guard; empty means no internal destination is allowed.
_allowed_proxy_endpoints: set = set() _allowed_endpoints: set = set()
def _normalise_port(port): def _normalise_port(port):
@@ -196,28 +202,29 @@ def _normalise_port(port):
return port return port
def _is_proxy_endpoint(host, port) -> bool: def _is_allowed_endpoint(host, port) -> bool:
"""True when host:port is exactly an endpoint the operator configured as a """True when host:port is exactly one of the endpoints this download is
proxy. Matching is on the configured host *string*, not on the resolved configured to dial a proxy or the PO token provider. Matching is on the
address, so a hostile media URL cannot borrow the allowance by resolving to configured host *string*, not on the resolved address, so a hostile media URL
the same address under a different name.""" cannot borrow the allowance by resolving to the same address under a
if not _allowed_proxy_endpoints or host is None: different name."""
if not _allowed_endpoints or host is None:
return False return False
return (str(host).rstrip('.').lower(), _normalise_port(port)) in _allowed_proxy_endpoints return (str(host).rstrip('.').lower(), _normalise_port(port)) in _allowed_endpoints
def _guarded_getaddrinfo(host, *args, **kwargs): def _guarded_getaddrinfo(host, *args, **kwargs):
results = _real_getaddrinfo(host, *args, **kwargs) results = _real_getaddrinfo(host, *args, **kwargs)
# Mirrors getaddrinfo(host, port, ...): port is the first optional argument. # Mirrors getaddrinfo(host, port, ...): port is the first optional argument.
port = args[0] if args else kwargs.get('port') port = args[0] if args else kwargs.get('port')
is_proxy = _is_proxy_endpoint(host, port) is_configured = _is_allowed_endpoint(host, port)
allowed = [r for r in results if _address_allowed_at_connect(r[4][0], is_proxy)] allowed = [r for r in results if _address_allowed_at_connect(r[4][0], is_configured)]
if not allowed: if not allowed:
raise socket.gaierror(f'Refusing to connect to non-global address for host {host!r}') raise socket.gaierror(f'Refusing to connect to non-global address for host {host!r}')
return allowed return allowed
def install_socket_guard(allow_private: bool = False, proxy_urls=()) -> None: def install_socket_guard(allow_private: bool = False, proxy_urls=(), service_urls=()) -> None:
"""Enforce the no-internal-hosts policy at actual connection time. """Enforce the no-internal-hosts policy at actual connection time.
``validate_url`` only checks the *submitted* URL string; yt-dlp then follows ``validate_url`` only checks the *submitted* URL string; yt-dlp then follows
@@ -230,12 +237,16 @@ def install_socket_guard(allow_private: bool = False, proxy_urls=()) -> None:
isolation as the backstop. isolation as the backstop.
*proxy_urls* are the operator's configured proxies (yt-dlp's ``proxy`` option; *proxy_urls* are the operator's configured proxies (yt-dlp's ``proxy`` option;
the ``*_proxy`` environment variables are picked up automatically). A proxy is the ``*_proxy`` environment variables are picked up automatically), and
reachable at its own host:port wherever it lives loopback, the LAN, a VPN *service_urls* the helper services the download itself has to reach the PO
range and nothing else internal is. That costs proxied setups nothing and token provider this image ships and starts on loopback. Each is reachable at
gives away nothing: yt-dlp resolves the proxy itself at exactly that host:port, its own host:port wherever it lives loopback, the LAN, a VPN range and
and a media URL is either handed to the proxy unresolved or resolved on its own nothing else internal is. That costs those setups nothing and gives away
merits never inheriting the proxy's allowance. little: yt-dlp dials each at exactly that host:port, and a media URL is either
handed to the proxy unresolved or resolved on its own merits never
inheriting the allowance. A hostile media URL naming an allowed endpoint
reaches only what is listening there: a proxy that would have fetched it
anyway, or a token server with two endpoints and nothing to read.
When *allow_private* is set (``ALLOW_PRIVATE_ADDRESSES``), the guard is not When *allow_private* is set (``ALLOW_PRIVATE_ADDRESSES``), the guard is not
installed at all, so proxy/VPN setups that route through private or Fake-IP installed at all, so proxy/VPN setups that route through private or Fake-IP
@@ -243,10 +254,13 @@ def install_socket_guard(allow_private: bool = False, proxy_urls=()) -> None:
""" """
if allow_private: if allow_private:
return return
_allowed_proxy_endpoints.clear() proxy_endpoints = _collect_proxy_endpoints(proxy_urls)
_allowed_proxy_endpoints.update(_collect_proxy_endpoints(proxy_urls)) service_endpoints = _endpoints(service_urls) - proxy_endpoints
for host, port in sorted(_allowed_proxy_endpoints, key=lambda ep: (ep[0], ep[1] or 0)): _allowed_endpoints.clear()
log.info(f'Allowing connections to configured proxy {host}:{port}') _allowed_endpoints.update(proxy_endpoints | service_endpoints)
for label, endpoints in (('proxy', proxy_endpoints), ('service', service_endpoints)):
for host, port in sorted(endpoints, key=lambda ep: (ep[0], ep[1] or 0)):
log.info(f'Allowing connections to configured {label} {host}:{port}')
socket.getaddrinfo = _guarded_getaddrinfo socket.getaddrinfo = _guarded_getaddrinfo
+216 -11
View File
@@ -32,6 +32,55 @@ from urllib.parse import urlsplit
log = logging.getLogger('ytdl') log = logging.getLogger('ytdl')
# Fragmented and live downloads can emit a warning per fragment, and the joined
# text is persisted with the completed queue and broadcast to every client, so
# only the last few distinct warnings are kept.
_MAX_RETAINED_WARNINGS = 5
_REPORT_LABEL_RE = re.compile(r'^(?:ERROR|WARNING):\s*')
def _report_body(message):
"""yt-dlp labels errors with an ``ERROR:`` prefix but hands warnings to the
logger unlabelled, so compare the two with any such label removed."""
return _REPORT_LABEL_RE.sub('', message).strip()
class _DownloadYtdlLogger:
"""Forward yt-dlp output while retaining warnings for failed downloads."""
def __init__(self):
self._warnings = collections.deque(maxlen=_MAX_RETAINED_WARNINGS)
@property
def warnings(self):
return list(self._warnings)
def debug(self, msg):
log.debug('%s', msg)
def warning(self, msg):
log.warning('%s', msg)
if msg is not None and (warning := str(msg).strip()) and warning not in self._warnings:
self._warnings.append(warning)
def error(self, msg):
log.error('%s', msg)
def failure_message(self, error_text):
"""Retained warnings followed by *error_text*, kept last so the actual
error stays prominent under the context that explains it."""
lines = self.warnings
error_text = (error_text or '').strip()
if not error_text:
return '\n'.join(lines)
if lines and _report_body(lines[-1]) == _report_body(error_text):
lines.pop()
lines.append(error_text)
return '\n'.join(lines)
# Python 3.14 switches the default multiprocessing start method on Linux # Python 3.14 switches the default multiprocessing start method on Linux
# (this app's only supported deployment target, per the Dockerfile) from fork # (this app's only supported deployment target, per the Dockerfile) from fork
# to forkserver. Download._download relies on inheriting process state the # to forkserver. Download._download relies on inheriting process state the
@@ -43,6 +92,36 @@ log = logging.getLogger('ytdl')
# vanish in the child can deadlock it silently before it does any work. This # vanish in the child can deadlock it silently before it does any work. This
# app creates background threads (executors, notifier callbacks) well before # app creates background threads (executors, notifier callbacks) well before
# any download starts, so forcing fork there reproduces exactly that hazard. # any download starts, so forcing fork there reproduces exactly that hazard.
# The image ships yt-dlp's bgutil PO token provider and starts it on loopback
# (docker-entrypoint.sh); the plugin dials this URL unless pointed elsewhere.
# Without a token YouTube serves 403s, so the connect-time guard has to let the
# download subprocess reach it.
_POT_PROVIDER_DEFAULT_URL = 'http://127.0.0.1:4416'
# extractor-arg keys the bgutil HTTP provider reads its base URL from: the
# current one first, then the deprecated form it still honours.
_POT_PROVIDER_BASE_URL_ARGS = (
('youtubepot-bgutilhttp', 'base_url'),
('youtube', 'getpot_bgutil_baseurl'),
)
def _pot_provider_urls(ytdl_opts: dict) -> tuple:
"""Every PO token provider endpoint this download may dial: the bundled one,
plus any the operator pointed yt-dlp at through ``extractor_args``. The
bundled server runs either way, so it stays allowed even when a base URL is
configured."""
urls = [_POT_PROVIDER_DEFAULT_URL]
extractor_args = ytdl_opts.get('extractor_args')
if isinstance(extractor_args, dict):
for ie_key, arg in _POT_PROVIDER_BASE_URL_ARGS:
section = extractor_args.get(ie_key)
values = section.get(arg) if isinstance(section, dict) else None
if values:
urls.append(values[0])
return tuple(urls)
_MP_CTX = ( _MP_CTX = (
multiprocessing.get_context("fork") multiprocessing.get_context("fork")
if sys.platform.startswith("linux") and "fork" in multiprocessing.get_all_start_methods() if sys.platform.startswith("linux") and "fork" in multiprocessing.get_all_start_methods()
@@ -147,6 +226,56 @@ def _sanitize_path_component(value: Any) -> Any:
return value.lstrip('.').strip() or '_' return value.lstrip('.').strip() or '_'
# Room left for the suffixes yt-dlp appends after prepare_filename has run:
# '.part' and '.ytdl' while the download is in flight, '.f<format_id>' for a
# stream fetched on its own before merging, '-Frag<n>' for fragmented
# downloads. A name trimmed to exactly the limit would still fail the moment
# one of those is added, which is what the '.part' in the reported errors is.
_NAME_SUFFIX_RESERVE_BYTES = 32
# POSIX guarantees at least this much, and it is what ext4/xfs/btrfs allow.
_FALLBACK_NAME_MAX_BYTES = 255
# Keep a recognisable stem even on a filesystem with a very short limit.
_MIN_STEM_BYTES = 16
# Longer than this is not really an extension (a title ending in '.something'),
# so the whole name is treated as the stem rather than preserving it.
_MAX_EXT_BYTES = 16
def _name_max_bytes(directory: str) -> int:
"""The filesystem's filename limit, in bytes, for *directory*."""
try:
return int(os.pathconf(directory or '.', 'PC_NAME_MAX'))
except (OSError, ValueError, AttributeError):
# The directory may not exist yet (CREATE_CUSTOM_DIRS makes it during
# the download), and pathconf is not available on every platform.
return _FALLBACK_NAME_MAX_BYTES
def _trim_to_name_max(path: str) -> str:
"""Shorten the final component of *path* to what the filesystem accepts.
The limit is a byte count, not a character count: a title of accented or
CJK characters hits it in half as many characters, or fewer. The extension
is preserved, since it is what decides how the file is handled afterwards.
"""
directory, name = os.path.split(path)
if not name:
return path
encoded = name.encode('utf-8', 'surrogatepass')
limit = _name_max_bytes(directory) - _NAME_SUFFIX_RESERVE_BYTES
if len(encoded) <= limit:
return path
stem, ext = os.path.splitext(name)
ext_bytes = ext.encode('utf-8', 'surrogatepass')
if len(ext_bytes) > _MAX_EXT_BYTES:
stem, ext, ext_bytes = name, '', b''
stem_limit = max(limit - len(ext_bytes), _MIN_STEM_BYTES)
# 'ignore' drops a multi-byte character the cut landed inside of.
trimmed = stem.encode('utf-8', 'surrogatepass')[:stem_limit].decode('utf-8', 'ignore').rstrip()
return os.path.join(directory, (trimmed or '_') + ext)
class _ConfinedYoutubeDL(yt_dlp.YoutubeDL): class _ConfinedYoutubeDL(yt_dlp.YoutubeDL):
"""A ``YoutubeDL`` that refuses to emit any output path outside the allowed roots. """A ``YoutubeDL`` that refuses to emit any output path outside the allowed roots.
@@ -170,6 +299,12 @@ class _ConfinedYoutubeDL(yt_dlp.YoutubeDL):
def prepare_filename(self, *args, **kwargs): def prepare_filename(self, *args, **kwargs):
filename = super().prepare_filename(*args, **kwargs) filename = super().prepare_filename(*args, **kwargs)
# Titles long enough to exceed the filesystem's filename limit are
# common on some sites, and the download fails outright when they do.
# Every output path comes through here, so trimming once keeps the
# main file, its chapter files, thumbnails and subtitles consistent.
if filename and filename != '-':
filename = _trim_to_name_max(filename)
if filename and filename != '-' and self._allowed_roots: if filename and filename != '-' and self._allowed_roots:
resolved = os.path.realpath(filename) resolved = os.path.realpath(filename)
if not any(_is_within_directory(root, resolved) for root in self._allowed_roots): if not any(_is_within_directory(root, resolved) for root in self._allowed_roots):
@@ -348,6 +483,7 @@ class DownloadInfo:
clip_end=None, clip_end=None,
live_status=None, live_status=None,
live_release_timestamp=None, live_release_timestamp=None,
sponsorblock=False,
): ):
self.id = id if len(custom_name_prefix) == 0 else f'{custom_name_prefix}.{id}' self.id = id if len(custom_name_prefix) == 0 else f'{custom_name_prefix}.{id}'
self.title = title if len(custom_name_prefix) == 0 else f'{custom_name_prefix}.{title}' self.title = title if len(custom_name_prefix) == 0 else f'{custom_name_prefix}.{title}'
@@ -367,6 +503,7 @@ class DownloadInfo:
self.entry = _sanitize_entry_for_pickle(entry) if entry is not None else None self.entry = _sanitize_entry_for_pickle(entry) if entry is not None else None
self.playlist_item_limit = playlist_item_limit self.playlist_item_limit = playlist_item_limit
self.split_by_chapters = split_by_chapters self.split_by_chapters = split_by_chapters
self.sponsorblock = sponsorblock
self.chapter_template = chapter_template self.chapter_template = chapter_template
self.subtitle_language = subtitle_language self.subtitle_language = subtitle_language
self.subtitle_mode = subtitle_mode self.subtitle_mode = subtitle_mode
@@ -436,6 +573,8 @@ class DownloadInfo:
self.playlist_item_limit = 0 self.playlist_item_limit = 0
if not hasattr(self, "split_by_chapters"): if not hasattr(self, "split_by_chapters"):
self.split_by_chapters = False self.split_by_chapters = False
if not hasattr(self, "sponsorblock"):
self.sponsorblock = False
if not hasattr(self, "chapter_template"): if not hasattr(self, "chapter_template"):
self.chapter_template = "" self.chapter_template = ""
if not hasattr(self, "subtitle_language"): if not hasattr(self, "subtitle_language"):
@@ -480,6 +619,7 @@ _PERSISTED_DOWNLOAD_FIELDS = (
"custom_name_prefix", "custom_name_prefix",
"playlist_item_limit", "playlist_item_limit",
"split_by_chapters", "split_by_chapters",
"sponsorblock",
"chapter_template", "chapter_template",
"subtitle_language", "subtitle_language",
"subtitle_mode", "subtitle_mode",
@@ -659,11 +799,19 @@ class Download:
# Re-validate every outbound connection at fetch time. validate_url only # Re-validate every outbound connection at fetch time. validate_url only
# saw the submitted URL string; this catches redirects, DNS rebinding and # saw the submitted URL string; this catches redirects, DNS rebinding and
# attacker-controlled media URLs pulled from a remote manifest, none of # attacker-controlled media URLs pulled from a remote manifest, none of
# which it can see. The configured proxy is passed so that a proxy on an # which it can see. The configured proxy and the PO token provider are
# internal address stays reachable at its own host:port without opening up # passed so that each stays reachable at its own host:port without opening
# anything else. Skipped when ALLOW_PRIVATE_ADDRESSES trusts the environment. # up anything else. Skipped when ALLOW_PRIVATE_ADDRESSES trusts the
install_socket_guard(self.allow_private, proxy_urls=(self.ytdl_opts.get('proxy'),)) # environment.
install_socket_guard(
self.allow_private,
proxy_urls=(self.ytdl_opts.get('proxy'),),
service_urls=_pot_provider_urls(self.ytdl_opts),
)
log.info(f"Starting download for: {self.info.title} ({self.info.url})") log.info(f"Starting download for: {self.info.title} ({self.info.url})")
# Bound outside the try so the except branch can read what was captured
# before the error was raised.
ytdl_logger = _DownloadYtdlLogger()
try: try:
debug_logging = logging.getLogger().isEnabledFor(logging.DEBUG) debug_logging = logging.getLogger().isEnabledFor(logging.DEBUG)
put_status = self._make_progress_hook() put_status = self._make_progress_hook()
@@ -710,6 +858,30 @@ class Download:
'postprocessor_hooks': [put_status_postprocessor], 'postprocessor_hooks': [put_status_postprocessor],
**self.ytdl_opts, **self.ytdl_opts,
} }
# Set after the ytdl_opts merge: the failure messages below depend on
# this logger, so a user-supplied one must not replace it.
ytdl_params['logger'] = ytdl_logger
# SponsorBlock: mark sponsor segments and cut them out, the same
# postprocessor pair the CLI's --sponsorblock-remove sponsor builds.
# This has to stay above the chapter-splitting block: yt-dlp runs
# same-stage postprocessors in list order, and ModifyChapters must
# rewrite the chapter list before FFmpegSplitChapters cuts the file
# up, or the chapter files keep the sponsor segments and the
# removal desyncs the remaining chapter timings.
if getattr(self.info, 'sponsorblock', False):
if 'postprocessors' not in ytdl_params:
ytdl_params['postprocessors'] = []
ytdl_params['postprocessors'].append({
'key': 'SponsorBlock',
'categories': ['sponsor'],
'when': 'after_filter',
})
ytdl_params['postprocessors'].append({
'key': 'ModifyChapters',
'remove_sponsor_segments': ['sponsor'],
'force_keyframes': False,
})
# Add chapter splitting options if enabled # Add chapter splitting options if enabled
if self.info.split_by_chapters: if self.info.split_by_chapters:
@@ -732,11 +904,15 @@ class Download:
) )
ret = self._make_youtube_dl(ytdl_params).download([self.info.url]) ret = self._make_youtube_dl(ytdl_params).download([self.info.url])
self.status_queue.put({'status': 'finished' if ret == 0 else 'error'}) if ret == 0:
self.status_queue.put({'status': 'finished'})
else:
msg = '\n'.join(ytdl_logger.warnings) or f'yt-dlp failed with exit code {ret}'
self.status_queue.put({'status': 'error', 'msg': msg})
log.info(f"Finished download for: {self.info.title}") log.info(f"Finished download for: {self.info.title}")
except yt_dlp.utils.YoutubeDLError as exc: except yt_dlp.utils.YoutubeDLError as exc:
log.error(f"Download error for {self.info.title}: {str(exc)}") log.error(f"Download error for {self.info.title}: {str(exc)}")
self.status_queue.put({'status': 'error', 'msg': str(exc)}) self.status_queue.put({'status': 'error', 'msg': ytdl_logger.failure_message(str(exc))})
async def start(self, notifier, executor=None): async def start(self, notifier, executor=None):
log.info(f"Preparing download for: {self.info.title}") log.info(f"Preparing download for: {self.info.title}")
@@ -1063,13 +1239,33 @@ class DownloadQueue:
@staticmethod @staticmethod
def __is_channel_extraction(entry): def __is_channel_extraction(entry):
"""Return True when yt-dlp reported a channel tab as a playlist. """Return True when yt-dlp reported a channel as a playlist.
YouTube channel tabs are extracted with ``_type: 'playlist'`` but set A channel *tab* -- ``/channel/UC...``, ``/@handle/videos``, and the
``id`` equal to ``channel_id``; real playlists keep a distinct id. streams, shorts and playlists tabs -- is extracted with ``id`` equal to
``channel_id``. A channel addressed without a tab keeps the form it was
asked for instead: ``@handle`` for a handle URL and the vanity name for
a legacy ``/c/`` URL. Both of those match ``uploader_id``, which is the
handle either way, so compare against it as well.
A real playlist has an id of its own and matches neither, even though
it also carries its owner's ``channel_id``.
""" """
channel_id = entry.get('channel_id') channel_id = entry.get('channel_id')
return bool(channel_id) and entry.get('id') == channel_id entry_id = entry.get('id')
if not channel_id or not entry_id:
return False
if entry_id == channel_id:
return True
uploader_id = entry.get('uploader_id')
if not uploader_id:
return False
# Compared without case because a legacy vanity name and the handle it
# became need not agree on it. No playlist id can collide here: those
# are 'PL...', 'OLAK...' and the like, never a handle.
handle = uploader_id.casefold()
entry_id = entry_id.casefold()
return handle in (entry_id, f'@{entry_id}')
async def __import_queue(self): async def __import_queue(self):
for k, v in self.queue.saved_items(): for k, v in self.queue.saved_items():
@@ -1484,6 +1680,7 @@ class DownloadQueue:
already, already,
_add_gen=None, _add_gen=None,
retry_entry=None, retry_entry=None,
sponsorblock=False,
): ):
if not entry: if not entry:
return {'status': 'error', 'msg': "Invalid/empty data was given."} return {'status': 'error', 'msg': "Invalid/empty data was given."}
@@ -1527,6 +1724,7 @@ class DownloadQueue:
already, already,
_add_gen, _add_gen,
retry_entry, retry_entry,
sponsorblock=sponsorblock,
) )
elif etype == 'playlist' or etype == 'channel': elif etype == 'playlist' or etype == 'channel':
if etype == 'playlist' and self.__is_channel_extraction(entry): if etype == 'playlist' and self.__is_channel_extraction(entry):
@@ -1594,6 +1792,7 @@ class DownloadQueue:
clip_end, clip_end,
already, already,
_add_gen, _add_gen,
sponsorblock=sponsorblock,
) )
) )
if any(res['status'] == 'error' for res in results): if any(res['status'] == 'error' for res in results):
@@ -1634,6 +1833,7 @@ class DownloadQueue:
clip_end=clip_end, clip_end=clip_end,
live_status=entry.get('live_status'), live_status=entry.get('live_status'),
live_release_timestamp=entry.get('release_timestamp'), live_release_timestamp=entry.get('release_timestamp'),
sponsorblock=sponsorblock,
) )
await self.__add_download(dl, auto_start) await self.__add_download(dl, auto_start)
return {'status': 'ok'} return {'status': 'ok'}
@@ -1714,13 +1914,14 @@ class DownloadQueue:
already=None, already=None,
_add_gen=None, _add_gen=None,
retry_entry=None, retry_entry=None,
sponsorblock=False,
): ):
if ytdl_options_presets is None: if ytdl_options_presets is None:
ytdl_options_presets = [] ytdl_options_presets = []
log.info( log.info(
f'adding {url}: {download_type=} {codec=} {format=} {quality=} {already=} {folder=} {custom_name_prefix=} ' f'adding {url}: {download_type=} {codec=} {format=} {quality=} {already=} {folder=} {custom_name_prefix=} '
f'{playlist_item_limit=} {auto_start=} {split_by_chapters=} {chapter_template=} ' f'{playlist_item_limit=} {auto_start=} {split_by_chapters=} {chapter_template=} '
f'{subtitle_language=} {subtitle_mode=} {ytdl_options_presets=} {clip_start=} {clip_end=}' f'{subtitle_language=} {subtitle_mode=} {ytdl_options_presets=} {clip_start=} {clip_end=} {sponsorblock=}'
) )
if already is None: if already is None:
_add_gen = self._add_generation _add_gen = self._add_generation
@@ -1783,6 +1984,7 @@ class DownloadQueue:
already, already,
_add_gen, _add_gen,
retry_entry, retry_entry,
sponsorblock=sponsorblock,
) )
async def retry(self, id): async def retry(self, id):
@@ -1819,6 +2021,7 @@ class DownloadQueue:
info.clip_start, info.clip_start,
info.clip_end, info.clip_end,
retry_entry=info.entry, retry_entry=info.entry,
sponsorblock=info.sponsorblock,
) )
async def add_entry( async def add_entry(
@@ -1840,6 +2043,7 @@ class DownloadQueue:
ytdl_options_overrides=None, ytdl_options_overrides=None,
clip_start=None, clip_start=None,
clip_end=None, clip_end=None,
sponsorblock=False,
): ):
if ytdl_options_presets is None: if ytdl_options_presets is None:
ytdl_options_presets = [] ytdl_options_presets = []
@@ -1865,6 +2069,7 @@ class DownloadQueue:
clip_end, clip_end,
already, already,
None, None,
sponsorblock=sponsorblock,
) )
async def start_pending(self, ids): async def start_pending(self, ids):
+13 -13
View File
@@ -24,21 +24,21 @@
"packageManager": "pnpm@11.5.2", "packageManager": "pnpm@11.5.2",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@angular/animations": "^22.0.6", "@angular/animations": "^22.1.2",
"@angular/common": "^22.0.6", "@angular/common": "^22.1.2",
"@angular/compiler": "^22.0.6", "@angular/compiler": "^22.1.2",
"@angular/core": "^22.0.6", "@angular/core": "^22.1.2",
"@angular/forms": "^22.0.6", "@angular/forms": "^22.1.2",
"@angular/platform-browser": "^22.0.6", "@angular/platform-browser": "^22.1.2",
"@angular/platform-browser-dynamic": "^22.0.6", "@angular/platform-browser-dynamic": "^22.1.2",
"@angular/service-worker": "^22.0.6", "@angular/service-worker": "^22.1.2",
"@fortawesome/angular-fontawesome": "~4.0.0", "@fortawesome/angular-fontawesome": "~4.0.0",
"@fortawesome/fontawesome-svg-core": "^7.3.1", "@fortawesome/fontawesome-svg-core": "^7.3.1",
"@fortawesome/free-brands-svg-icons": "^7.3.1", "@fortawesome/free-brands-svg-icons": "^7.3.1",
"@fortawesome/free-regular-svg-icons": "^7.3.1", "@fortawesome/free-regular-svg-icons": "^7.3.1",
"@fortawesome/free-solid-svg-icons": "^7.3.1", "@fortawesome/free-solid-svg-icons": "^7.3.1",
"@ng-bootstrap/ng-bootstrap": "^21.0.0", "@ng-bootstrap/ng-bootstrap": "^21.0.0",
"@ng-select/ng-select": "^23.2.0", "@ng-select/ng-select": "^23.11.0",
"@popperjs/core": "^2.11.8", "@popperjs/core": "^2.11.8",
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"ngx-cookie-service": "^22.0.0", "ngx-cookie-service": "^22.0.0",
@@ -49,10 +49,10 @@
}, },
"devDependencies": { "devDependencies": {
"@angular-eslint/builder": "22.0.0", "@angular-eslint/builder": "22.0.0",
"@angular/build": "^22.0.7", "@angular/build": "^22.1.4",
"@angular/cli": "^22.0.7", "@angular/cli": "^22.1.4",
"@angular/compiler-cli": "^22.0.6", "@angular/compiler-cli": "^22.1.2",
"@angular/localize": "^22.0.6", "@angular/localize": "^22.1.2",
"@eslint/js": "^9.39.5", "@eslint/js": "^9.39.5",
"angular-eslint": "22.0.0", "angular-eslint": "22.0.0",
"eslint": "^9.39.5", "eslint": "^9.39.5",
+1766 -1938
View File
File diff suppressed because it is too large Load Diff
+13 -1
View File
@@ -399,6 +399,16 @@
</div> </div>
<div class="col-12"> <div class="col-12">
<div class="row g-2 align-items-center"> <div class="row g-2 align-items-center">
<div class="col-auto">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="checkbox-sponsorblock"
name="sponsorblock" [(ngModel)]="sponsorblock" (change)="sponsorblockChanged()"
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
<label class="form-check-label" for="checkbox-sponsorblock"
ngbPopover="Cut out sponsor segments using SponsorBlock's crowd-sourced markers (YouTube only)."
triggers="hover" container="body">Remove sponsor segments</label>
</div>
</div>
<div class="col-auto"> <div class="col-auto">
<div class="form-check form-switch"> <div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="checkbox-split-chapters" <input class="form-check-input" type="checkbox" role="switch" id="checkbox-split-chapters"
@@ -693,6 +703,7 @@
<app-select-all-checkbox #queueMasterCheckboxRef [id]="'queue'" [list]="downloads.queue" (changed)="queueSelectionChanged($event)" /> <app-select-all-checkbox #queueMasterCheckboxRef [id]="'queue'" [list]="downloads.queue" (changed)="queueSelectionChanged($event)" />
</th> </th>
<th scope="col">Video</th> <th scope="col">Video</th>
<th scope="col" style="width: 7rem;">Format</th>
<th scope="col" style="width: 8rem;">Speed</th> <th scope="col" style="width: 8rem;">Speed</th>
<th scope="col" style="width: 7rem;">ETA</th> <th scope="col" style="width: 7rem;">ETA</th>
<th scope="col" style="width: 6rem;"></th> <th scope="col" style="width: 6rem;"></th>
@@ -726,6 +737,7 @@
} }
</div> </div>
</td> </td>
<td class="text-nowrap">{{ formatLabel(download.value) }}</td>
<td>{{ download.value.speed | speed }}</td> <td>{{ download.value.speed | speed }}</td>
<td>{{ download.value.eta | eta }}</td> <td>{{ download.value.eta | eta }}</td>
<td> <td>
@@ -757,7 +769,7 @@
<thead> <thead>
<tr> <tr>
<th scope="col" style="width: 1rem;"> <th scope="col" style="width: 1rem;">
<app-select-all-checkbox #doneMasterCheckboxRef [id]="'done'" [list]="downloads.done" (changed)="doneSelectionChanged($event)" /> <app-select-all-checkbox #doneMasterCheckboxRef [id]="'done'" [list]="downloads.done" [orderedIds]="cachedSortedDoneIds" (changed)="doneSelectionChanged($event)" />
</th> </th>
<th scope="col">Video</th> <th scope="col">Video</th>
<th scope="col">Type</th> <th scope="col">Type</th>
+65 -3
View File
@@ -6,6 +6,7 @@ import { DownloadsService } from './services/downloads.service';
import { SubscriptionsService } from './services/subscriptions.service'; import { SubscriptionsService } from './services/subscriptions.service';
import { ToastService } from './services/toast.service'; import { ToastService } from './services/toast.service';
import { CookieService } from 'ngx-cookie-service'; import { CookieService } from 'ngx-cookie-service';
import { Download } from './interfaces';
class DownloadsServiceStub { class DownloadsServiceStub {
loading = false; loading = false;
@@ -148,6 +149,25 @@ describe('App', () => {
expect(app).toBeTruthy(); expect(app).toBeTruthy();
}); });
it('pre-fills the download folder from DEFAULT_FOLDER', () => {
const fixture = TestBed.createComponent(App);
fixture.detectChanges();
downloads.configurationChanged.next({ DEFAULT_FOLDER: 'youtube' });
expect(fixture.componentInstance.folder).toBe('youtube');
});
it('does not overwrite a folder the user already typed', () => {
const fixture = TestBed.createComponent(App);
fixture.detectChanges();
fixture.componentInstance.folder = 'music';
downloads.configurationChanged.next({ DEFAULT_FOLDER: 'youtube' });
expect(fixture.componentInstance.folder).toBe('music');
});
it('asIsOrder returns a stable comparator value (insertion order preserved)', () => { it('asIsOrder returns a stable comparator value (insertion order preserved)', () => {
const fixture = TestBed.createComponent(App); const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance; const app = fixture.componentInstance;
@@ -229,6 +249,46 @@ describe('App', () => {
expect(root.textContent).toContain('starts in'); expect(root.textContent).toContain('starts in');
}); });
it('shows the queued format in the Downloading table', () => {
downloads.queue.set('https://example.com/v', {
id: 'v1',
title: 'Some Video',
url: 'https://example.com/v',
download_type: 'audio',
quality: 'best',
format: 'flac',
folder: '',
custom_name_prefix: '',
playlist_item_limit: 0,
status: 'downloading',
msg: '',
percent: 10,
speed: 0,
eta: 0,
filename: '',
checked: false,
});
downloads.queueChanged.next();
const fixture = TestBed.createComponent(App);
fixture.detectChanges();
const row = (fixture.nativeElement as HTMLElement).querySelector('tbody tr');
expect(row?.textContent).toContain('FLAC');
});
it('labels formats the way the form does, and copes with an unknown one', () => {
const app = TestBed.createComponent(App).componentInstance;
const base = { format: '' } as Download;
expect(app.formatLabel({ ...base, format: 'any' })).toBe('Auto');
expect(app.formatLabel({ ...base, format: 'mp4' })).toBe('MP4');
expect(app.formatLabel({ ...base, format: 'srt' })).toBe('SRT');
// A format from a record older than the option list still reads sensibly.
expect(app.formatLabel({ ...base, format: 'mkv' })).toBe('MKV');
expect(app.formatLabel(base)).toBe('-');
});
it('includes titleRegex in subscribe payload', () => { it('includes titleRegex in subscribe payload', () => {
const fixture = TestBed.createComponent(App); const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance; const app = fixture.componentInstance;
@@ -254,7 +314,9 @@ describe('App', () => {
expect(payload.skipSubscriberOnly).toBe(true); expect(payload.skipSubscriberOnly).toBe(true);
}); });
it('omits clip fields from subscribe payload', () => { it('passes clip fields through to the subscribe payload', () => {
// #1049: a subscription's options apply to all its future downloads, and
// clip bounds used to be stripped out on the way.
const fixture = TestBed.createComponent(App); const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance; const app = fixture.componentInstance;
const subs = TestBed.inject(SubscriptionsService) as unknown as SubscriptionsServiceStub; const subs = TestBed.inject(SubscriptionsService) as unknown as SubscriptionsServiceStub;
@@ -264,8 +326,8 @@ describe('App', () => {
app.addSubscription(); app.addSubscription();
expect(subs.subscribeCalls.length).toBe(1); expect(subs.subscribeCalls.length).toBe(1);
const payload = subs.subscribeCalls[0] as Record<string, unknown>; const payload = subs.subscribeCalls[0] as Record<string, unknown>;
expect('clipStart' in payload).toBe(false); expect(payload['clipStart']).toBe('1:00');
expect('clipEnd' in payload).toBe(false); expect(payload['clipEnd']).toBe('2:00');
}); });
it('buildAddPayload includes clip times', () => { it('buildAddPayload includes clip times', () => {
+34 -5
View File
@@ -86,6 +86,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
autoStart: boolean; autoStart: boolean;
playlistItemLimit!: number; playlistItemLimit!: number;
splitByChapters: boolean; splitByChapters: boolean;
sponsorblock: boolean;
chapterTemplate: string; chapterTemplate: string;
clipStart = ''; clipStart = '';
clipEnd = ''; clipEnd = '';
@@ -137,6 +138,9 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
sortAscending = false; sortAscending = false;
expandedErrors: Set<string> = new Set<string>(); expandedErrors: Set<string> = new Set<string>();
cachedSortedDone: [string, Download][] = []; cachedSortedDone: [string, Download][] = [];
// The done ids in rendered order, so a shift-click range follows the sort
// the user is looking at rather than the map's insertion order.
cachedSortedDoneIds: string[] = [];
lastCopiedErrorId: string | null = null; lastCopiedErrorId: string | null = null;
private previousDownloadType = 'video'; private previousDownloadType = 'video';
private addRequestSub?: Subscription; private addRequestSub?: Subscription;
@@ -256,6 +260,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
this.quality = this.cookieService.get('metube_quality') || 'best'; this.quality = this.cookieService.get('metube_quality') || 'best';
this.autoStart = this.cookieService.get('metube_auto_start') !== 'false'; this.autoStart = this.cookieService.get('metube_auto_start') !== 'false';
this.splitByChapters = this.cookieService.get('metube_split_chapters') === 'true'; this.splitByChapters = this.cookieService.get('metube_split_chapters') === 'true';
this.sponsorblock = this.cookieService.get('metube_sponsorblock') === 'true';
// Will be set from backend configuration, use empty string as placeholder // Will be set from backend configuration, use empty string as placeholder
this.chapterTemplate = this.cookieService.get('metube_chapter_template') || ''; this.chapterTemplate = this.cookieService.get('metube_chapter_template') || '';
this.clipStart = this.cookieService.get('metube_clip_start') || ''; this.clipStart = this.cookieService.get('metube_clip_start') || '';
@@ -434,6 +439,12 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
if (!Number.isNaN(playlistItemLimit) && playlistItemLimit > 0) { if (!Number.isNaN(playlistItemLimit) && playlistItemLimit > 0) {
this.playlistItemLimit = playlistItemLimit; this.playlistItemLimit = playlistItemLimit;
} }
// Pre-fill the download folder, unless the user has already typed one
// this session. The server drops DEFAULT_FOLDER when CUSTOM_DIRS is
// off, so there is nothing to guard against here.
if (!this.folder) {
this.folder = String(config['DEFAULT_FOLDER'] ?? '');
}
// Set chapter template from backend config if not already set by cookie // Set chapter template from backend config if not already set by cookie
if (!this.chapterTemplate) { if (!this.chapterTemplate) {
this.chapterTemplate = config['OUTPUT_TEMPLATE_CHAPTER']; this.chapterTemplate = config['OUTPUT_TEMPLATE_CHAPTER'];
@@ -601,14 +612,10 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
if (!this.validateYtdlOptionsOverrides(payload.ytdlOptionsOverrides)) { if (!this.validateYtdlOptionsOverrides(payload.ytdlOptionsOverrides)) {
return; return;
} }
// Subscriptions do not support clip ranges (backend rejects clip fields).
const { clipStart: _clipStart, clipEnd: _clipEnd, ...subscribeBase } = payload;
void _clipStart;
void _clipEnd;
this.subscribeInProgress = true; this.subscribeInProgress = true;
this.subscriptionsSvc this.subscriptionsSvc
.subscribe({ .subscribe({
...subscribeBase, ...payload,
checkIntervalMinutes: this.checkIntervalMinutes, checkIntervalMinutes: this.checkIntervalMinutes,
titleRegex: tr, titleRegex: tr,
skipSubscriberOnly: this.skipSubscriberOnly, skipSubscriberOnly: this.skipSubscriberOnly,
@@ -850,6 +857,10 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
this.cookieService.set('metube_auto_start', this.autoStart ? 'true' : 'false', { expires: this.settingsCookieExpiryDays }); this.cookieService.set('metube_auto_start', this.autoStart ? 'true' : 'false', { expires: this.settingsCookieExpiryDays });
} }
sponsorblockChanged() {
this.cookieService.set('metube_sponsorblock', this.sponsorblock ? 'true' : 'false', { expires: this.settingsCookieExpiryDays });
}
splitByChaptersChanged() { splitByChaptersChanged() {
this.cookieService.set('metube_split_chapters', this.splitByChapters ? 'true' : 'false', { expires: this.settingsCookieExpiryDays }); this.cookieService.set('metube_split_chapters', this.splitByChapters ? 'true' : 'false', { expires: this.settingsCookieExpiryDays });
} }
@@ -913,6 +924,22 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
return type.charAt(0).toUpperCase() + type.slice(1); return type.charAt(0).toUpperCase() + type.slice(1);
} }
// The format the download was queued with, labelled the way the form labels
// it, so a queued item can be told apart while it is still downloading.
formatLabel(download: Download): string {
const format = (download.format || '').trim();
if (!format) {
return '-';
}
const options: Option[] = [
...this.videoFormats,
...this.audioFormats,
...this.captionFormats,
...this.thumbnailFormats,
];
return options.find(o => o.id === format)?.text ?? format.toUpperCase();
}
formatCodecLabel(download: Download): string { formatCodecLabel(download: Download): string {
if (download.download_type !== 'video') { if (download.download_type !== 'video') {
const format = (download.format || '').toUpperCase(); const format = (download.format || '').toUpperCase();
@@ -1090,6 +1117,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
playlistItemLimit: overrides.playlistItemLimit ?? this.playlistItemLimit, playlistItemLimit: overrides.playlistItemLimit ?? this.playlistItemLimit,
autoStart: overrides.autoStart ?? this.autoStart, autoStart: overrides.autoStart ?? this.autoStart,
splitByChapters: overrides.splitByChapters ?? this.splitByChapters, splitByChapters: overrides.splitByChapters ?? this.splitByChapters,
sponsorblock: overrides.sponsorblock ?? this.sponsorblock,
chapterTemplate: overrides.chapterTemplate ?? this.chapterTemplate, chapterTemplate: overrides.chapterTemplate ?? this.chapterTemplate,
subtitleLanguage: overrides.subtitleLanguage ?? this.subtitleLanguage, subtitleLanguage: overrides.subtitleLanguage ?? this.subtitleLanguage,
subtitleMode: overrides.subtitleMode ?? this.subtitleMode, subtitleMode: overrides.subtitleMode ?? this.subtitleMode,
@@ -1536,6 +1564,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
result.reverse(); result.reverse();
} }
this.cachedSortedDone = result; this.cachedSortedDone = result;
this.cachedSortedDoneIds = result.map(([key]) => key);
} }
toggleErrorDetail(id: string) { toggleErrorDetail(id: string) {
@@ -2,6 +2,38 @@ import { TestBed } from '@angular/core/testing';
import { SelectAllCheckboxComponent } from './master-checkbox.component'; import { SelectAllCheckboxComponent } from './master-checkbox.component';
import { Checkable } from '../interfaces'; import { Checkable } from '../interfaces';
function makeList(ids: string[]): Map<string, Checkable> {
const list = new Map<string, Checkable>();
for (const id of ids) {
list.set(id, { checked: false });
}
return list;
}
function makeMaster(list: Map<string, Checkable>, orderedIds: string[] | null = null) {
const fixture = TestBed.createComponent(SelectAllCheckboxComponent);
fixture.componentRef.setInput('id', 'queue');
fixture.componentRef.setInput('list', list);
if (orderedIds) {
fixture.componentRef.setInput('orderedIds', orderedIds);
}
fixture.detectChanges();
return fixture;
}
// Simulates what the item checkbox does: ngModel writes the new state, then
// the change handler reports the click to the master.
function clickItem(
master: SelectAllCheckboxComponent,
list: Map<string, Checkable>,
id: string,
shift = false,
) {
const item = list.get(id)!;
item.checked = !item.checked;
master.selectionChanged(id, shift);
}
describe('SelectAllCheckboxComponent', () => { describe('SelectAllCheckboxComponent', () => {
beforeEach(async () => { beforeEach(async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
@@ -20,4 +52,87 @@ describe('SelectAllCheckboxComponent', () => {
fixture.componentInstance.clicked(); fixture.componentInstance.clicked();
expect(list.get('u1')?.checked).toBe(true); expect(list.get('u1')?.checked).toBe(true);
}); });
it('shift-click checks every item between the two clicks', () => {
const list = makeList(['u1', 'u2', 'u3', 'u4', 'u5']);
const master = makeMaster(list).componentInstance;
clickItem(master, list, 'u2');
clickItem(master, list, 'u4', true);
expect([...list.values()].map((i) => i.checked)).toEqual([false, true, true, true, false]);
});
it('extends upwards as well as downwards', () => {
const list = makeList(['u1', 'u2', 'u3', 'u4']);
const master = makeMaster(list).componentInstance;
clickItem(master, list, 'u4');
clickItem(master, list, 'u2', true);
expect([...list.values()].map((i) => i.checked)).toEqual([false, true, true, true]);
});
it('shift-clicking a checked box clears the range', () => {
const list = makeList(['u1', 'u2', 'u3']);
list.forEach((item) => (item.checked = true));
const master = makeMaster(list).componentInstance;
clickItem(master, list, 'u1');
clickItem(master, list, 'u3', true);
expect([...list.values()].map((i) => i.checked)).toEqual([false, false, false]);
});
it('follows the rendered order, not the map order', () => {
// The done list renders newest-first, so its rendered order is not the
// order the entries sit in the map. u2 lies inside the range on screen
// and outside it in the map, which is what separates the two.
const list = makeList(['u1', 'u2', 'u3', 'u4']);
const master = makeMaster(list, ['u4', 'u2', 'u3', 'u1']).componentInstance;
clickItem(master, list, 'u4');
clickItem(master, list, 'u3', true);
// u1 (rendered last) stays clear; u2 is swept up with the range.
expect([...list.values()].map((i) => i.checked)).toEqual([false, true, true, true]);
});
it('a plain click after a range starts a new anchor', () => {
const list = makeList(['u1', 'u2', 'u3', 'u4']);
const master = makeMaster(list).componentInstance;
clickItem(master, list, 'u1');
clickItem(master, list, 'u2', true);
clickItem(master, list, 'u4');
expect([...list.values()].map((i) => i.checked)).toEqual([true, true, false, true]);
});
it('select-all clears the anchor so the next shift-click is a plain toggle', () => {
const list = makeList(['u1', 'u2', 'u3']);
const fixture = makeMaster(list);
const master = fixture.componentInstance;
clickItem(master, list, 'u1');
master.selected = true;
master.clicked();
master.selected = false;
master.clicked();
clickItem(master, list, 'u3', true);
expect([...list.values()].map((i) => i.checked)).toEqual([false, false, true]);
});
it('ignores a range whose anchor row is gone', () => {
const list = makeList(['u1', 'u2', 'u3']);
const master = makeMaster(list).componentInstance;
clickItem(master, list, 'u1');
// The anchor finishes downloading and leaves the queue.
list.delete('u1');
clickItem(master, list, 'u3', true);
expect([...list.values()].map((i) => i.checked)).toEqual([false, true]);
});
}); });
@@ -20,17 +20,33 @@ import { FormsModule } from "@angular/forms";
export class SelectAllCheckboxComponent { export class SelectAllCheckboxComponent {
readonly id = input.required<string>(); readonly id = input.required<string>();
readonly list = input.required<Map<string, Checkable>>(); readonly list = input.required<Map<string, Checkable>>();
// The ids in the order the rows are rendered. The done list is sorted for
// display, so its order is not the map's insertion order, and a range
// selection has to follow what the user sees. Left unset, the map order is
// the rendered order.
readonly orderedIds = input<string[] | null>(null);
readonly changed = output<number>(); readonly changed = output<number>();
readonly masterCheckbox = viewChild.required<ElementRef>('masterCheckbox'); readonly masterCheckbox = viewChild.required<ElementRef>('masterCheckbox');
selected!: boolean; selected!: boolean;
// The item a range extends from: the last one toggled on its own.
private anchorId: string | null = null;
clicked() { clicked() {
this.list().forEach(item => item.checked = this.selected); this.list().forEach(item => item.checked = this.selected);
// Select-all is not a position, so there is nothing to extend from next.
this.anchorId = null;
this.selectionChanged(); this.selectionChanged();
} }
selectionChanged() { selectionChanged(id?: string, extend = false) {
if (id !== undefined) {
if (extend && this.anchorId !== null && this.anchorId !== id) {
this.applyRange(this.anchorId, id);
}
this.anchorId = id;
}
const masterCheckbox = this.masterCheckbox(); const masterCheckbox = this.masterCheckbox();
if (!masterCheckbox) if (!masterCheckbox)
return; return;
@@ -40,4 +56,27 @@ export class SelectAllCheckboxComponent {
masterCheckbox.nativeElement.indeterminate = checked > 0 && checked < this.list().size; masterCheckbox.nativeElement.indeterminate = checked > 0 && checked < this.list().size;
this.changed.emit(checked); this.changed.emit(checked);
} }
// Everything between the anchor and the just-clicked row takes the state the
// click produced, so shift-clicking a checked box clears the range and
// shift-clicking an unchecked one fills it.
private applyRange(fromId: string, toId: string) {
const ids = this.orderedIds() ?? Array.from(this.list().keys());
const from = ids.indexOf(fromId);
const to = ids.indexOf(toId);
// A row can disappear between two clicks (a download finishing moves it
// from the queue to the done list); without both ends there is no range.
if (from < 0 || to < 0) {
return;
}
const target = this.list().get(toId)?.checked ?? false;
const start = Math.min(from, to);
const end = Math.max(from, to);
for (let i = start; i <= end; i++) {
const item = this.list().get(ids[i]);
if (item) {
item.checked = target;
}
}
}
} }
@@ -22,4 +22,33 @@ describe('ItemCheckboxComponent', () => {
itemFixture.detectChanges(); itemFixture.detectChanges();
expect(itemFixture.componentInstance).toBeTruthy(); expect(itemFixture.componentInstance).toBeTruthy();
}); });
it('reports the shift modifier from the click to the master', () => {
const masterFixture = TestBed.createComponent(SelectAllCheckboxComponent);
masterFixture.componentRef.setInput('id', 'q');
masterFixture.componentRef.setInput('list', new Map());
masterFixture.detectChanges();
const master = masterFixture.componentInstance;
const reported: [string | undefined, boolean | undefined][] = [];
master.selectionChanged = (id?: string, extend?: boolean) => {
reported.push([id, extend]);
};
const itemFixture = TestBed.createComponent(ItemCheckboxComponent);
itemFixture.componentRef.setInput('id', 'row1');
itemFixture.componentRef.setInput('master', master);
itemFixture.componentRef.setInput('checkable', { checked: false });
itemFixture.detectChanges();
const item = itemFixture.componentInstance;
item.clicked(new MouseEvent('click', { shiftKey: true }));
item.changed();
// The modifier must not stick to the next toggle.
item.changed();
expect(reported).toEqual([
['row1', true],
['row1', false],
]);
});
}); });
@@ -7,14 +7,14 @@ import { FormsModule } from '@angular/forms';
selector: 'app-item-checkbox', selector: 'app-item-checkbox',
template: ` template: `
<div class="form-check"> <div class="form-check">
<input type="checkbox" class="form-check-input" id="{{master().id()}}-{{id()}}-select" [(ngModel)]="checkable().checked" (change)="master().selectionChanged()" [attr.aria-label]="'Select item ' + id()"> <input type="checkbox" class="form-check-input" id="{{master().id()}}-{{id()}}-select" [(ngModel)]="checkable().checked" (click)="clicked($event)" (change)="changed()" [attr.aria-label]="'Select item ' + id()">
<label class="form-check-label visually-hidden" for="{{master().id()}}-{{id()}}-select">Select item</label> <label class="form-check-label visually-hidden" for="{{master().id()}}-{{id()}}-select">Select item</label>
</div> </div>
`, `,
// Shared Checkable objects are mutated in place; Eager preserves pre-v22 behavior. // Shared Checkable objects are mutated in place; Eager preserves pre-v22 behavior.
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
changeDetection: ChangeDetectionStrategy.Eager, changeDetection: ChangeDetectionStrategy.Eager,
imports: [ imports: [
FormsModule FormsModule
] ]
}) })
@@ -22,4 +22,19 @@ export class ItemCheckboxComponent {
readonly id = input.required<string>(); readonly id = input.required<string>();
readonly master = input.required<SelectAllCheckboxComponent>(); readonly master = input.required<SelectAllCheckboxComponent>();
readonly checkable = input.required<Checkable>(); readonly checkable = input.required<Checkable>();
// click fires before change, so the modifier is recorded here and read once
// ngModel has written the new state into the checkable. Keyboard activation
// fires change without a click, which is a plain toggle.
private extend = false;
clicked(event: MouseEvent) {
this.extend = event.shiftKey;
}
changed() {
const extend = this.extend;
this.extend = false;
this.master().selectionChanged(this.id(), extend);
}
} }
+1
View File
@@ -11,6 +11,7 @@ export interface Download {
custom_name_prefix: string; custom_name_prefix: string;
playlist_item_limit: number; playlist_item_limit: number;
split_by_chapters?: boolean; split_by_chapters?: boolean;
sponsorblock?: boolean;
chapter_template?: string; chapter_template?: string;
subtitle_language?: string; subtitle_language?: string;
subtitle_mode?: string; subtitle_mode?: string;
+2
View File
@@ -11,6 +11,8 @@ export interface SubscriptionRow {
folder: string; folder: string;
title_regex?: string; title_regex?: string;
skip_subscriber_only?: boolean; skip_subscriber_only?: boolean;
clip_start?: number | null;
clip_end?: number | null;
last_checked: number | null; last_checked: number | null;
seen_count: number; seen_count: number;
error: string | null; error: string | null;
@@ -36,6 +36,7 @@ function basePayload(): AddDownloadPayload {
playlistItemLimit: 0, playlistItemLimit: 0,
autoStart: true, autoStart: true,
splitByChapters: false, splitByChapters: false,
sponsorblock: false,
chapterTemplate: '', chapterTemplate: '',
subtitleLanguage: 'en', subtitleLanguage: 'en',
subtitleMode: 'prefer_manual', subtitleMode: 'prefer_manual',
+2
View File
@@ -17,6 +17,7 @@ export interface AddDownloadPayload {
playlistItemLimit: number; playlistItemLimit: number;
autoStart: boolean; autoStart: boolean;
splitByChapters: boolean; splitByChapters: boolean;
sponsorblock: boolean;
chapterTemplate: string; chapterTemplate: string;
subtitleLanguage: string; subtitleLanguage: string;
subtitleMode: string; subtitleMode: string;
@@ -148,6 +149,7 @@ export class DownloadsService {
playlist_item_limit: payload.playlistItemLimit, playlist_item_limit: payload.playlistItemLimit,
auto_start: payload.autoStart, auto_start: payload.autoStart,
split_by_chapters: payload.splitByChapters, split_by_chapters: payload.splitByChapters,
sponsorblock: payload.sponsorblock,
chapter_template: payload.chapterTemplate, chapter_template: payload.chapterTemplate,
subtitle_language: payload.subtitleLanguage, subtitle_language: payload.subtitleLanguage,
subtitle_mode: payload.subtitleMode, subtitle_mode: payload.subtitleMode,
@@ -0,0 +1,77 @@
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { Subject } from 'rxjs';
import { SubscriptionsService, SubscribePayload } from './subscriptions.service';
import { MeTubeSocket } from './metube-socket.service';
class MeTubeSocketStub {
private subjects: Record<string, Subject<string>> = {};
fromEvent(event: string) {
if (!this.subjects[event]) {
this.subjects[event] = new Subject<string>();
}
return this.subjects[event].asObservable();
}
}
function basePayload(): SubscribePayload {
return {
url: 'https://example.com/channel',
downloadType: 'video',
codec: 'auto',
quality: 'best',
format: 'any',
folder: '',
customNamePrefix: '',
playlistItemLimit: 0,
autoStart: true,
splitByChapters: false,
sponsorblock: false,
chapterTemplate: '',
subtitleLanguage: 'en',
subtitleMode: 'prefer_manual',
ytdlOptionsPresets: [],
ytdlOptionsOverrides: '',
clipStart: '',
clipEnd: '',
checkIntervalMinutes: 60,
titleRegex: '',
skipSubscriberOnly: false,
};
}
describe('SubscriptionsService', () => {
let httpMock: HttpTestingController;
let service: SubscriptionsService;
beforeEach(async () => {
await TestBed.configureTestingModule({
providers: [
SubscriptionsService,
provideHttpClient(),
provideHttpClientTesting(),
{ provide: MeTubeSocket, useValue: new MeTubeSocketStub() },
],
}).compileComponents();
service = TestBed.inject(SubscriptionsService);
httpMock = TestBed.inject(HttpTestingController);
});
it('subscribe() carries the sponsorblock flag', () => {
service.subscribe({ ...basePayload(), sponsorblock: true }).subscribe();
const req = httpMock.expectOne('subscribe');
expect(req.request.method).toBe('POST');
expect(req.request.body).toEqual(expect.objectContaining({ sponsorblock: true }));
req.flush({ status: 'ok' });
});
it('subscribe() sends the flag off by default', () => {
service.subscribe(basePayload()).subscribe();
const req = httpMock.expectOne('subscribe');
expect(req.request.body).toEqual(expect.objectContaining({ sponsorblock: false }));
req.flush({ status: 'ok' });
});
});
+29 -22
View File
@@ -81,28 +81,35 @@ export class SubscriptionsService {
} }
subscribe(payload: SubscribePayload) { subscribe(payload: SubscribePayload) {
return this.http const body: Record<string, unknown> = {
.post<Status>('subscribe', { url: payload.url,
url: payload.url, download_type: payload.downloadType,
download_type: payload.downloadType, codec: payload.codec,
codec: payload.codec, quality: payload.quality,
quality: payload.quality, format: payload.format,
format: payload.format, folder: payload.folder,
folder: payload.folder, custom_name_prefix: payload.customNamePrefix,
custom_name_prefix: payload.customNamePrefix, playlist_item_limit: payload.playlistItemLimit,
playlist_item_limit: payload.playlistItemLimit, auto_start: payload.autoStart,
auto_start: payload.autoStart, split_by_chapters: payload.splitByChapters,
split_by_chapters: payload.splitByChapters, sponsorblock: payload.sponsorblock,
chapter_template: payload.chapterTemplate, chapter_template: payload.chapterTemplate,
subtitle_language: payload.subtitleLanguage, subtitle_language: payload.subtitleLanguage,
subtitle_mode: payload.subtitleMode, subtitle_mode: payload.subtitleMode,
ytdl_options_presets: payload.ytdlOptionsPresets, ytdl_options_presets: payload.ytdlOptionsPresets,
ytdl_options_overrides: payload.ytdlOptionsOverrides, ytdl_options_overrides: payload.ytdlOptionsOverrides,
check_interval_minutes: payload.checkIntervalMinutes, check_interval_minutes: payload.checkIntervalMinutes,
title_regex: payload.titleRegex, title_regex: payload.titleRegex,
skip_subscriber_only: payload.skipSubscriberOnly, skip_subscriber_only: payload.skipSubscriberOnly,
}) };
.pipe(catchError((err) => this.handleHTTPError(err))); // Send the clip fields only when actually filled in. The backend treats an
// absent field as "not requested", which is what stops a t= timestamp on the
// subscribed URL from clipping every future download.
const cs = payload.clipStart?.trim();
const ce = payload.clipEnd?.trim();
if (cs) body['clip_start'] = cs;
if (ce) body['clip_end'] = ce;
return this.http.post<Status>('subscribe', body).pipe(catchError((err) => this.handleHTTPError(err)));
} }
delete(ids: string[]) { delete(ids: string[]) {
Generated
+318 -242
View File
@@ -194,132 +194,199 @@ wheels = [
[[package]] [[package]]
name = "certifi" name = "certifi"
version = "2026.6.17" version = "2026.7.22"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
] ]
[[package]] [[package]]
name = "cffi" name = "cffi"
version = "2.1.0" version = "2.1.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "pycparser", marker = "implementation_name != 'PyPy'" }, { name = "pycparser", marker = "implementation_name != 'PyPy'" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" } sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/96/88/a996879e2eeccb815f6e3a5967b12a308257412acec882039d386bd2aa7b/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda", size = 194331, upload-time = "2026-07-06T21:33:03.697Z" }, { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" },
{ url = "https://files.pythonhosted.org/packages/58/85/7ae00d5c8dd6266f4e944c3db630f3c5c9a98b61d469c714d848b1d8138a/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b", size = 196966, upload-time = "2026-07-06T21:33:05.353Z" }, { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" },
{ url = "https://files.pythonhosted.org/packages/8c/e9/45c3a76ad8d43ad9261f4c95436da61128d3ca545d72b9612c0ab5be0b1c/cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a", size = 184795, upload-time = "2026-07-06T21:33:06.699Z" }, { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" },
{ url = "https://files.pythonhosted.org/packages/84/4c/82f132cb4418ee6d953d982b19191e87e2a6372c8a4ce36e50b69d6ade4a/cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea", size = 184746, upload-time = "2026-07-06T21:33:08.071Z" }, { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" },
{ url = "https://files.pythonhosted.org/packages/a0/1c/4ed5a0e5bdca6cbc275556de3328dd1b76fd0c11cc13c88fe66d1d8715f2/cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db", size = 214747, upload-time = "2026-07-06T21:33:09.671Z" }, { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" },
{ url = "https://files.pythonhosted.org/packages/3a/a6/e879bb68cc23a2bc9ba8f4b7d8019f0c2694bad2ab6c4a3701d429439f58/cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f", size = 222392, upload-time = "2026-07-06T21:33:10.896Z" }, { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" },
{ url = "https://files.pythonhosted.org/packages/88/f6/01890cfd63c08f8eb96a8319b0443690197d240a8bd6346048cf7bde9190/cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d", size = 210285, upload-time = "2026-07-06T21:33:12.251Z" }, { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" },
{ url = "https://files.pythonhosted.org/packages/a6/cf/2b684132056f438567b61e19d690dd31cd0921ace051e0a458be6074369e/cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0", size = 208801, upload-time = "2026-07-06T21:33:13.617Z" }, { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" },
{ url = "https://files.pythonhosted.org/packages/6f/08/f2e7d62c460faae0926f2d6e423694aa409ced3bc1fe2927a0a6e5f05416/cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224", size = 221808, upload-time = "2026-07-06T21:33:15.466Z" }, { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" },
{ url = "https://files.pythonhosted.org/packages/38/37/04f54b8e63a02f3d908332c9effbf8c366167c6f733ed8a3d4f79b7e2a1e/cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c", size = 225241, upload-time = "2026-07-06T21:33:16.869Z" }, { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" },
{ url = "https://files.pythonhosted.org/packages/a9/d6/c72eecca433cd3e681c65ed313ab4835d9d4a379704d0f628a6a05f51c2e/cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a", size = 223588, upload-time = "2026-07-06T21:33:18.239Z" }, { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" },
{ url = "https://files.pythonhosted.org/packages/c6/4b/e706f67279140f92939da3475ad610df18bfd52d50f14953a8e5fede71d5/cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2", size = 175248, upload-time = "2026-07-06T21:33:19.799Z" }, { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" },
{ url = "https://files.pythonhosted.org/packages/5a/47/59eb7975cb0e4ef0afa764ea945b29a5bb4537a9f771cb7d6c8a5dd74c95/cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512", size = 185717, upload-time = "2026-07-06T21:33:21.47Z" }, { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" },
{ url = "https://files.pythonhosted.org/packages/5a/af/34fee85c48f8d94efc8597bc09470c9dd274c145f1c12e0fbc6ab6d38d74/cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f", size = 180114, upload-time = "2026-07-06T21:33:22.515Z" }, { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" },
{ url = "https://files.pythonhosted.org/packages/d8/f0/81478e482afa03f6d18dc8f2afb5edc45b3080853b634b5ed91961be0998/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a", size = 194142, upload-time = "2026-07-06T21:33:23.657Z" }, { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" },
{ url = "https://files.pythonhosted.org/packages/7d/95/8de304305cd9204974b0ca051b86d307cafca13aa575a0ef1b44d92c0d8c/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3", size = 196819, upload-time = "2026-07-06T21:33:25.007Z" }, { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" },
{ url = "https://files.pythonhosted.org/packages/20/71/7c8372d30e42415602ed9f268f7cfd66f1b855fed881ecd168bcb45dbc0b/cffi-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d", size = 184965, upload-time = "2026-07-06T21:33:26.605Z" }, { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" },
{ url = "https://files.pythonhosted.org/packages/d6/5c/584e626835f0375c928176c04137c96927165cb8733cdb3150ec04e5ee5e/cffi-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac", size = 184952, upload-time = "2026-07-06T21:33:27.823Z" }, { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" },
{ url = "https://files.pythonhosted.org/packages/2e/d2/065fcae1c73979fac8e054462478d0ff8a29c40cdc2ed7ea5676a061df53/cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6", size = 222353, upload-time = "2026-07-06T21:33:29.178Z" }, { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" },
{ url = "https://files.pythonhosted.org/packages/ed/a5/e8bbb1ce5b3ac2f53ad6a10bde44318a5a8d99d4f4a000d44a6e39aeb3e4/cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913", size = 210051, upload-time = "2026-07-06T21:33:30.534Z" }, { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" },
{ url = "https://files.pythonhosted.org/packages/28/ed/c127d3ac36e899c965e3361357c3befacd6578c03f40125183e41c3b219e/cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d", size = 208630, upload-time = "2026-07-06T21:33:31.753Z" }, { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" },
{ url = "https://files.pythonhosted.org/packages/cc/d7/97d3136f81db489ec8d1d67748c110d6c994268fd7528014aa9f2b085e4e/cffi-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5", size = 221593, upload-time = "2026-07-06T21:33:33.044Z" }, { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" },
{ url = "https://files.pythonhosted.org/packages/d3/27/93195977168ee63aed233a1a0993a2178798654d1f4bddcdd321d6fd3b21/cffi-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce", size = 225146, upload-time = "2026-07-06T21:33:34.224Z" }, { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" },
{ url = "https://files.pythonhosted.org/packages/b3/c1/6dbd291ee2ae5a50a034aa057207081f545923bbf15dad4511e985aafff5/cffi-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326", size = 223240, upload-time = "2026-07-06T21:33:35.57Z" }, { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" },
{ url = "https://files.pythonhosted.org/packages/0f/6f/ade5ce9863a57992a6ea3d0d10d7e29b8749fc127204b3d493d667b2815f/cffi-2.1.0-cp314-cp314-win32.whl", hash = "sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd", size = 177723, upload-time = "2026-07-06T21:33:51.626Z" }, { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" },
{ url = "https://files.pythonhosted.org/packages/41/de/92b9eeed4ae4a21d6fd9b2a2c8505cbed573299902ea73981cc13f7ff62c/cffi-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb", size = 187937, upload-time = "2026-07-06T21:33:53.403Z" }, { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" },
{ url = "https://files.pythonhosted.org/packages/2e/1a/cc6ae6c2913a03aab8898eee57963cf1035b8df5872ed8b9115fcc7e2be8/cffi-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804", size = 183001, upload-time = "2026-07-06T21:33:54.74Z" }, { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" },
{ url = "https://files.pythonhosted.org/packages/14/f0/134c00ce0779ec86dea2aa1aac69339c2741a8045072676763512363a2ea/cffi-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714", size = 188538, upload-time = "2026-07-06T21:33:36.792Z" }, { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" },
{ url = "https://files.pythonhosted.org/packages/50/d8/3b86aba791cb610d24e8a3e1b2cd529e71fa15096b04e4d4e360049d4a4c/cffi-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376", size = 188230, upload-time = "2026-07-06T21:33:38.011Z" }, { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" },
{ url = "https://files.pythonhosted.org/packages/14/d0/117dcd9209255ad8571fbc8c92ef32593a1d294dcec91ddc4e4db50606f2/cffi-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98", size = 223899, upload-time = "2026-07-06T21:33:39.514Z" }, { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" },
{ url = "https://files.pythonhosted.org/packages/b6/3d/f20f8b886b254e3ad10e15cd4186d3aed49f3e6a35ab37aab9f8f25f7c03/cffi-2.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13", size = 211652, upload-time = "2026-07-06T21:33:40.851Z" }, { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" },
{ url = "https://files.pythonhosted.org/packages/28/3b/fad54de07260b93ddeef4b96d0131d57ea900675df1d410ae1deee52d7a6/cffi-2.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d", size = 210755, upload-time = "2026-07-06T21:33:42.183Z" }, { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" },
{ url = "https://files.pythonhosted.org/packages/cc/82/3d5c705acb7abbba9bbd7d79b8e62e0f25b6120eb7ae6ac49f1b721722fe/cffi-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056", size = 223933, upload-time = "2026-07-06T21:33:43.603Z" }, { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" },
{ url = "https://files.pythonhosted.org/packages/6c/d0/47e338384ab6b1004241002fa616301020cea4fc95f283506565d252f276/cffi-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4", size = 226749, upload-time = "2026-07-06T21:33:45.046Z" }, { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" },
{ url = "https://files.pythonhosted.org/packages/70/25/65bd5b58ea4bfdfc15cde02cb5365f89ef8ab8b2adfb8fe5c4bd4233382f/cffi-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94", size = 225703, upload-time = "2026-07-06T21:33:46.374Z" }, { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" },
{ url = "https://files.pythonhosted.org/packages/dc/78/aa01ac599a8a4322533d45a1f9bc93b338276d2d59dabbe7c6d92a775c81/cffi-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76", size = 182857, upload-time = "2026-07-06T21:33:47.696Z" }, { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" },
{ url = "https://files.pythonhosted.org/packages/b9/26/d00496b22de4d4228f32dde94ad996f350c8aad676d63bcca0743c8dea4d/cffi-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5", size = 194065, upload-time = "2026-07-06T21:33:48.953Z" }, { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" },
{ url = "https://files.pythonhosted.org/packages/d5/dd/0c7dbf815a579ff005008a2d815a55d6bb047c349eef536d9dc53d3f0a8d/cffi-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8", size = 186404, upload-time = "2026-07-06T21:33:50.309Z" }, { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" },
{ url = "https://files.pythonhosted.org/packages/55/c7/8c8c50cb11c6750051daf12164098a9a6f027ac4356967fd4d800a07f242/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c", size = 194121, upload-time = "2026-07-06T21:33:56.109Z" }, { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" },
{ url = "https://files.pythonhosted.org/packages/99/e2/67680bf19a6b60d2bb7ff83baefa2a4c3d2d7dc0f3277034b802e1fc504c/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001", size = 196820, upload-time = "2026-07-06T21:33:57.288Z" }, { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" },
{ url = "https://files.pythonhosted.org/packages/ed/da/4bbe583a3b3a5c8c60892124fe17f3fa3656523faf0d3484eae90f091853/cffi-2.1.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3", size = 184936, upload-time = "2026-07-06T21:33:58.765Z" }, { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" },
{ url = "https://files.pythonhosted.org/packages/e5/4b/1f4c36ab273980d7aa75bb126ea4f8971f24a96108acad3a0a084028c57b/cffi-2.1.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc", size = 185045, upload-time = "2026-07-06T21:34:00.085Z" }, { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" },
{ url = "https://files.pythonhosted.org/packages/ef/c3/ad299dc38f3583f8d916b299f028af418a9ec98bc695fcbebeae7420691c/cffi-2.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699", size = 222342, upload-time = "2026-07-06T21:34:01.814Z" }, { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" },
{ url = "https://files.pythonhosted.org/packages/eb/d8/df4543cc087245044ed02ef3ad8e0a26619d0075ac7a77a12dc81177851b/cffi-2.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022", size = 210073, upload-time = "2026-07-06T21:34:03.255Z" }, { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" },
{ url = "https://files.pythonhosted.org/packages/2c/0e/fac738d73728c6cea2a88a2883dca54892496cbba88a1dc1f2909cb8a6f5/cffi-2.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0", size = 208551, upload-time = "2026-07-06T21:34:04.433Z" }, { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" },
{ url = "https://files.pythonhosted.org/packages/e6/3f/0b04a700dd64f465c93020253a793a82c9b4dff9961f48facd0df945d9b8/cffi-2.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1", size = 221649, upload-time = "2026-07-06T21:34:06.157Z" }, { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" },
{ url = "https://files.pythonhosted.org/packages/5d/7c/b7379a5704c79eda57ce075869ba70a0368d1c850f803b3c0d078d39dcaf/cffi-2.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28", size = 225203, upload-time = "2026-07-06T21:34:07.489Z" }, { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" },
{ url = "https://files.pythonhosted.org/packages/5a/02/d5e6c43ea85c41bda2a184a3418f195fe7cf602967a8d2b94e085b83deef/cffi-2.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629", size = 223263, upload-time = "2026-07-06T21:34:08.712Z" }, { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" },
{ url = "https://files.pythonhosted.org/packages/2c/d8/772b8259bf75749adffb1c546828978381fb516f60cf701f6c83daf60c85/cffi-2.1.0-cp315-cp315-win32.whl", hash = "sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6", size = 177696, upload-time = "2026-07-06T21:34:26.355Z" }, { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" },
{ url = "https://files.pythonhosted.org/packages/2f/dd/afa2191fc6d57fedd26e5844a2fe2fcc0bbfa00961bbaa5a41e4921e7cca/cffi-2.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853", size = 187914, upload-time = "2026-07-06T21:34:27.58Z" }, { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" },
{ url = "https://files.pythonhosted.org/packages/05/ef/6cd4f8c671517162379dc79cfae5aea9106bc38abb89628d5c16adf6a838/cffi-2.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda", size = 183004, upload-time = "2026-07-06T21:34:28.905Z" }, { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" },
{ url = "https://files.pythonhosted.org/packages/11/b6/12fc55092817a5faa26fb8c40c7f9d662e11a46ee248c137aafc42517d92/cffi-2.1.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc", size = 188378, upload-time = "2026-07-06T21:34:09.926Z" }, { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" },
{ url = "https://files.pythonhosted.org/packages/8d/2e/cdac88979f295fde5daa69622c7d2111e56e7ceb94f211357fbe452339e4/cffi-2.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca", size = 188319, upload-time = "2026-07-06T21:34:11.101Z" }, { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" },
{ url = "https://files.pythonhosted.org/packages/e0/27/1d0b408497e41a74795af122d7b603c418c5fed0171450f899afd04e594f/cffi-2.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d", size = 223904, upload-time = "2026-07-06T21:34:12.606Z" }, { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" },
{ url = "https://files.pythonhosted.org/packages/8b/31/e115c985105dd7ffb32444505f18ceb874bb42d992af05d5dced7ecf1980/cffi-2.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8", size = 211554, upload-time = "2026-07-06T21:34:13.987Z" }, { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" },
{ url = "https://files.pythonhosted.org/packages/5a/67/9e6e09409336d9e515c58367e7cfcf4f89df06ad25252675595a58eb59d5/cffi-2.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd", size = 210795, upload-time = "2026-07-06T21:34:15.972Z" }, { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" },
{ url = "https://files.pythonhosted.org/packages/19/e5/d3cc82a4a0be7902af279c04181ad038449c096734464a5ae1de3e1401bd/cffi-2.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f", size = 223843, upload-time = "2026-07-06T21:34:17.509Z" }, { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" },
{ url = "https://files.pythonhosted.org/packages/b9/65/b434abc97ce7cecc2c640fde160507c0ecc7e21544b483ba3325d2e2ea17/cffi-2.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc", size = 226773, upload-time = "2026-07-06T21:34:19.05Z" }, { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" },
{ url = "https://files.pythonhosted.org/packages/b5/9f/d4dc66ca651eb1145a133314cda721abf13cfac3d28c4a0402263ae6ad75/cffi-2.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9", size = 225719, upload-time = "2026-07-06T21:34:20.576Z" }, { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" },
{ url = "https://files.pythonhosted.org/packages/68/5a/e536c528bc8057496c360c0978559a2dc45653f89dd6151078aa7d8fca1a/cffi-2.1.0-cp315-cp315t-win32.whl", hash = "sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b", size = 182760, upload-time = "2026-07-06T21:34:22.059Z" }, { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" },
{ url = "https://files.pythonhosted.org/packages/d3/0b/0ffe8b82d3875bced5fa1e7986a7a46b748262a40ab7f60b475eb9fb1bb3/cffi-2.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5", size = 193769, upload-time = "2026-07-06T21:34:23.589Z" }, { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" },
{ url = "https://files.pythonhosted.org/packages/a0/17/1073b53b68c9b5ca6914adf5f8bf55aacc2d3be102418c90700160ea8605/cffi-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210", size = 186405, upload-time = "2026-07-06T21:34:24.857Z" }, { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" },
] ]
[[package]] [[package]]
name = "charset-normalizer" name = "charset-normalizer"
version = "3.4.9" version = "3.5.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" } sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764, upload-time = "2026-08-15T08:20:44.807Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", size = 317688, upload-time = "2026-07-07T14:33:35.408Z" }, { url = "https://files.pythonhosted.org/packages/bc/61/2cb6ad133dbbb449fa2d37ccae973232f4827e799af258d15e589a3d1e9e/charset_normalizer-3.5.1-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:4f298bdadb8f0b9e5672877f647d1be9373ef5320c9e2f049795e26cad28b6a9", size = 211584, upload-time = "2026-08-15T08:17:33.597Z" },
{ url = "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", size = 214982, upload-time = "2026-07-07T14:33:36.996Z" }, { url = "https://files.pythonhosted.org/packages/18/57/a305c968be1ca13f3dd1b32f445877e97addf55d80b65c7cb35fac82b777/charset_normalizer-3.5.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:88ca277405c2d3b71c4e1c2ee0e7966e807bcba86a69d11e19ba199d18ae4491", size = 223359, upload-time = "2026-08-15T08:17:35.022Z" },
{ url = "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", size = 236460, upload-time = "2026-07-07T14:33:38.536Z" }, { url = "https://files.pythonhosted.org/packages/09/0a/d3646670292ce8d8f8cc11ac067d44885e697a5591f57a9221128da5e7b3/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9362dd90aa7dab48c0054a21187791ccf05473f7dba5d92b8033ae62164675e7", size = 194464, upload-time = "2026-08-15T08:17:36.452Z" },
{ url = "https://files.pythonhosted.org/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", size = 232003, upload-time = "2026-07-07T14:33:40.059Z" }, { url = "https://files.pythonhosted.org/packages/de/93/d51ec556e01042fed6f993ea859311bc7917b466684182fbbceb6ca24762/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:977cdbd483a9cff38179bea4fd754289a6f2195c7abd414aba85410b3e66cc5e", size = 197676, upload-time = "2026-08-15T08:17:37.819Z" },
{ url = "https://files.pythonhosted.org/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", size = 223149, upload-time = "2026-07-07T14:33:41.631Z" }, { url = "https://files.pythonhosted.org/packages/a4/a0/562247944386f7d4ef94467e84876600cc1e0f1b93239aaa9213d2bc3cbd/charset_normalizer-3.5.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e90251c0c7bdd54a100a0dce3c07b7e637278c93af29dbf78ebb89a58c4bac7d", size = 340473, upload-time = "2026-08-15T08:17:39.303Z" },
{ url = "https://files.pythonhosted.org/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", size = 207901, upload-time = "2026-07-07T14:33:43.209Z" }, { url = "https://files.pythonhosted.org/packages/31/e7/1d994be1b93d41e9502b8b0460eaa88a1dd8df335df415db87d6c3e91ab2/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94d78ecec2605a8d0398b0f365d5f12a63248438516f5dac536a5eff7337df4a", size = 240156, upload-time = "2026-08-15T08:17:40.66Z" },
{ url = "https://files.pythonhosted.org/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", size = 219176, upload-time = "2026-07-07T14:33:44.725Z" }, { url = "https://files.pythonhosted.org/packages/09/53/27923ce5cc6cbccb832037b27dca98882d9c53e9b69e866bbbef4aae7fc8/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d59b75732e9b6f27388e10c14b0259cc5f2e48c78627d185e6a177b58ad3cffe", size = 228246, upload-time = "2026-08-15T08:17:42.003Z" },
{ url = "https://files.pythonhosted.org/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", size = 217356, upload-time = "2026-07-07T14:33:46.192Z" }, { url = "https://files.pythonhosted.org/packages/ce/48/5a97e84d63af1d55c07439cb80e56d99a8efb4295700eb4e18c0d1615d2c/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0d929fc574b4d6fd9e7c0f5c2ede8716a41911923aa7fa5fce38e0818aa4a1ac", size = 263660, upload-time = "2026-08-15T08:17:43.627Z" },
{ url = "https://files.pythonhosted.org/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", size = 209614, upload-time = "2026-07-07T14:33:47.705Z" }, { url = "https://files.pythonhosted.org/packages/7a/c2/071575791dcc88316c0a9a65ce38897a82e4cfe4a325f0f7fe1b1ac47bcf/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:394fea06235c8543390050ed5f529187074b029fb027213f6c46ac11ab5d950e", size = 260354, upload-time = "2026-08-15T08:17:45.094Z" },
{ url = "https://files.pythonhosted.org/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", size = 224991, upload-time = "2026-07-07T14:33:49.238Z" }, { url = "https://files.pythonhosted.org/packages/fb/af/63240b0c0248c075c2535a1f1bd992821d8251b9f173abc13329661d09e4/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62b55f6722735a6c472f88361cde6640608773d9443cebdbb51abf436a1fcdd3", size = 250638, upload-time = "2026-08-15T08:17:46.496Z" },
{ url = "https://files.pythonhosted.org/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", size = 150622, upload-time = "2026-07-07T14:33:50.711Z" }, { url = "https://files.pythonhosted.org/packages/4d/66/70dfad64f15be09c15ccfee81330a7e515895dbe296dd23114e9a231268a/charset_normalizer-3.5.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa48b1b63d639f9483e0633e092f5851e2348c352f1f9bb6c8182f87884ef876", size = 244583, upload-time = "2026-08-15T08:17:47.963Z" },
{ url = "https://files.pythonhosted.org/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115", size = 161947, upload-time = "2026-07-07T14:33:52.197Z" }, { url = "https://files.pythonhosted.org/packages/c0/24/ef36367d38b9ddd4bccbf72888c342e8de1f5ae506fa0b2dcf970e2732a1/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c71fb0d56c920c269cd3e2e3fe7c610e3f1fdb21a6ce60efa6430ff63676cea6", size = 242038, upload-time = "2026-08-15T08:17:49.481Z" },
{ url = "https://files.pythonhosted.org/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", size = 152594, upload-time = "2026-07-07T14:33:53.486Z" }, { url = "https://files.pythonhosted.org/packages/db/ab/55e683ba0fff2e43adafc10daa3001eac90fdaa419a97227d5a7067eedde/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:485a0d363cafefcd2538a73c7c838daa2035f09b2c9f9b5e3133f80c6aeb84c2", size = 233677, upload-time = "2026-08-15T08:17:50.845Z" },
{ url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" }, { url = "https://files.pythonhosted.org/packages/bd/67/0f40eaf8d1b6e7cf15e82382a2965efaca787fc1c2794b7021d37aaf5036/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0ea61a470e070686aa30892fed79e297d2c8d0ab46b8bcdf027d38c51da591", size = 264491, upload-time = "2026-08-15T08:17:52.61Z" },
{ url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" }, { url = "https://files.pythonhosted.org/packages/5c/64/12b4c2a11ee8df4fcc518c78b0d93e3a92bd3d5253d1617ce74ff0e8c7ef/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90b7481fb62fbe172c558bc6fd1c4c98d82004a54a7551f20e11ac9bf0b8708c", size = 245196, upload-time = "2026-08-15T08:17:54.023Z" },
{ url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" }, { url = "https://files.pythonhosted.org/packages/37/2e/651d910af6d0fba325eee1cda37ec5443462ed25360e666c144166eb6091/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:35fe081843b35aad20ffeccec3eeffbe637b15d14f3fb22cc1b59cd8ec17e93c", size = 261660, upload-time = "2026-08-15T08:17:55.491Z" },
{ url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519, upload-time = "2026-07-07T14:33:59.811Z" }, { url = "https://files.pythonhosted.org/packages/90/c6/b09e05e6db7f64338e0dc067c79577b1138da86c1e38369096851d96be88/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fd0350afdc3aabd5576f60ea109228bd5538139713c7b094c5cd27c73a98bc6f", size = 252618, upload-time = "2026-08-15T08:17:57.025Z" },
{ url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143, upload-time = "2026-07-07T14:34:01.517Z" }, { url = "https://files.pythonhosted.org/packages/76/4e/362d4f9fdcdf5556fb2aa3ce7d4a58ebce03ed1ff03aa1d9aca8d02f13f3/charset_normalizer-3.5.1-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:9d9a0dc7cbe9bec24c3f767c9122c41fe5a1bc43f47cd099d00d393e09769de4", size = 140362, upload-time = "2026-08-15T08:17:58.425Z" },
{ url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742, upload-time = "2026-07-07T14:34:03.04Z" }, { url = "https://files.pythonhosted.org/packages/b4/d4/703be739b26acce318bd29eb3b25b7209e1b1f527f9eae3d1f1f01fdde2b/charset_normalizer-3.5.1-cp313-cp313-win32.whl", hash = "sha256:d63600d620ad0064c3a748b950ac5ea38a80190e5498532efefa4b7b3f1da1f3", size = 177755, upload-time = "2026-08-15T08:18:00.037Z" },
{ url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191, upload-time = "2026-07-07T14:34:04.657Z" }, { url = "https://files.pythonhosted.org/packages/8a/33/56d97ade41c8db611e727168c52ae46c9224c362ec28d4b65d7e9869e8da/charset_normalizer-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:aea996a6aba25260827c9ea511d1addfde2da9eb686ac961838509086188b7e6", size = 199295, upload-time = "2026-08-15T08:18:01.506Z" },
{ url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328, upload-time = "2026-07-07T14:34:06.115Z" }, { url = "https://files.pythonhosted.org/packages/5b/75/5b20dd1e6573a01a08158fe104104fa2c8abf941745596954185726cd46c/charset_normalizer-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:fd0a274c0e5f9a21565cd9d3dd749b61f96b7aa1e20a93aa1ba4029518f2e5c0", size = 179856, upload-time = "2026-08-15T08:18:02.929Z" },
{ url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406, upload-time = "2026-07-07T14:34:07.554Z" }, { url = "https://files.pythonhosted.org/packages/29/cd/2b812ce5e888f1ce69a5350281e58aab07ae64a958ecae8912f30865718e/charset_normalizer-3.5.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8", size = 212318, upload-time = "2026-08-15T08:18:04.403Z" },
{ url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157, upload-time = "2026-07-07T14:34:09.061Z" }, { url = "https://files.pythonhosted.org/packages/9e/4a/a6ee107430768a5334e6d63f31f148a04a1a491ef161a1ac9415a73f2fa8/charset_normalizer-3.5.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102", size = 224897, upload-time = "2026-08-15T08:18:05.997Z" },
{ url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095, upload-time = "2026-07-07T14:34:10.901Z" }, { url = "https://files.pythonhosted.org/packages/c3/d9/35ae3f64f29d0179c35c3baefe575904df2913dde519129c7f75995a2b1d/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5", size = 194848, upload-time = "2026-08-15T08:18:07.397Z" },
{ url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796, upload-time = "2026-07-07T14:34:12.47Z" }, { url = "https://files.pythonhosted.org/packages/74/76/f2fc7380f056cc273a53af37f50d08ad54b2c59f61078f31432edcf1c2bd/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3", size = 198163, upload-time = "2026-08-15T08:18:08.989Z" },
{ url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334, upload-time = "2026-07-07T14:34:14.044Z" }, { url = "https://files.pythonhosted.org/packages/e9/40/095ce62fa078483cccc1fa2b36e6bc9580b85422a20ee9f925341c50e44f/charset_normalizer-3.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c", size = 341823, upload-time = "2026-08-15T08:18:10.458Z" },
{ url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848, upload-time = "2026-07-07T14:34:15.688Z" }, { url = "https://files.pythonhosted.org/packages/f1/5a/0e58b1c04a1596e0256f407274a92d5fb2ee21324409d1fab1da48a65b5b/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0", size = 242458, upload-time = "2026-08-15T08:18:11.989Z" },
{ url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022, upload-time = "2026-07-07T14:34:17.248Z" }, { url = "https://files.pythonhosted.org/packages/22/95/b4618ce912e6db0b1aae89ba788e38e8a7eba0f3025cc66e8c0699f977b2/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96", size = 226717, upload-time = "2026-08-15T08:18:13.401Z" },
{ url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590, upload-time = "2026-07-07T14:34:18.813Z" }, { url = "https://files.pythonhosted.org/packages/8a/76/c681192bbda3d55356db5dadd64381d5202b37c6b598fcda5282e88b5d3d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc", size = 266111, upload-time = "2026-08-15T08:18:14.961Z" },
{ url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584, upload-time = "2026-07-07T14:34:20.52Z" }, { url = "https://files.pythonhosted.org/packages/88/be/55127bfca72c0cff6c022488d140d7c5b04c771e3b72e9bdb4836d54979d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f", size = 263128, upload-time = "2026-08-15T08:18:16.515Z" },
{ url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224, upload-time = "2026-07-07T14:34:22.189Z" }, { url = "https://files.pythonhosted.org/packages/e0/91/39c3af510b0aa32bbda03374259200f28430febfd1bf5e511fe765282ce5/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90", size = 251240, upload-time = "2026-08-15T08:18:18.127Z" },
{ url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667, upload-time = "2026-07-07T14:34:23.857Z" }, { url = "https://files.pythonhosted.org/packages/1c/a5/cbe418bbc6ecdfc3e05a0116002897c4b403a5e838d697e64c78e9f0190d/charset_normalizer-3.5.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506", size = 245282, upload-time = "2026-08-15T08:18:19.625Z" },
{ url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179, upload-time = "2026-07-07T14:34:25.586Z" }, { url = "https://files.pythonhosted.org/packages/cc/a4/689bb42e8e7cd492f3cb64907c6bc00ad247ec9a3628cd3f8eed126e8ae1/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5", size = 244597, upload-time = "2026-08-15T08:18:21.121Z" },
{ url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372, upload-time = "2026-07-07T14:34:27.212Z" }, { url = "https://files.pythonhosted.org/packages/c1/ce/9962938e179cf9f699d3f1e7b3114b5d7642dee6a893745229f9dd04f274/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e", size = 231376, upload-time = "2026-08-15T08:18:22.57Z" },
{ url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222, upload-time = "2026-07-07T14:34:28.774Z" }, { url = "https://files.pythonhosted.org/packages/85/54/46000450ada53bd9eac5429a2c8c54cd2d9b39c0c255f229aea9af0948a5/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5", size = 266715, upload-time = "2026-08-15T08:18:24.235Z" },
{ url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958, upload-time = "2026-07-07T14:34:30.345Z" }, { url = "https://files.pythonhosted.org/packages/3d/bb/618749d70f792b44252a777bf89bfb86823b9bbc1ea13fe8ce759b07f38a/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3", size = 245848, upload-time = "2026-08-15T08:18:25.726Z" },
{ url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" }, { url = "https://files.pythonhosted.org/packages/7e/3f/ffb64458527c7668031d5eb095d978de561958dc9f5b53f8e488a533e603/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3", size = 264521, upload-time = "2026-08-15T08:18:27.193Z" },
{ url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" }, { url = "https://files.pythonhosted.org/packages/4f/ab/74a55fd803916a35ac461daf002708191aac19b546b80dc8cabfedc63d98/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36", size = 253054, upload-time = "2026-08-15T08:18:28.568Z" },
{ url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" }, { url = "https://files.pythonhosted.org/packages/a0/2a/6a9034b7d3c60b17499afb482df5878bf9fa20b50cc3887d5ef017a833db/charset_normalizer-3.5.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7", size = 140580, upload-time = "2026-08-15T08:18:30.214Z" },
{ url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, { url = "https://files.pythonhosted.org/packages/f3/46/1d362e1a00d035d66b9869e1281eee115907f7e390a16a07824ab5737360/charset_normalizer-3.5.1-cp314-cp314-win32.whl", hash = "sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b", size = 180325, upload-time = "2026-08-15T08:18:31.877Z" },
{ url = "https://files.pythonhosted.org/packages/7a/7c/4938c329b6a9d446f6a59aa2092ff7118f274209b5ed0e26893d1d30a63c/charset_normalizer-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b", size = 204175, upload-time = "2026-08-15T08:18:33.466Z" },
{ url = "https://files.pythonhosted.org/packages/ac/33/eeb384dbd8dec570661354592f4f2e1b2fcc92585624d146a000caf53841/charset_normalizer-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687", size = 184123, upload-time = "2026-08-15T08:18:34.913Z" },
{ url = "https://files.pythonhosted.org/packages/1c/6c/c73fa9d5a85f6ab05395de61c5f6984e0a9ff40bb5ff888d46dff02526c6/charset_normalizer-3.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348", size = 381682, upload-time = "2026-08-15T08:18:36.349Z" },
{ url = "https://files.pythonhosted.org/packages/30/c7/63565f860921457feba93bae6c86fb7746deb4cffeed2f375cb845318146/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef", size = 240826, upload-time = "2026-08-15T08:18:37.887Z" },
{ url = "https://files.pythonhosted.org/packages/06/ae/7ae8807410dfa33f8e6f1715740adeaafa8a816cc4cb33508f54b1f7c896/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885", size = 227861, upload-time = "2026-08-15T08:18:39.315Z" },
{ url = "https://files.pythonhosted.org/packages/e9/a3/887c1642f0da26000b0e0652d91071113c0e72cea33952e225cf589f49a9/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375", size = 260758, upload-time = "2026-08-15T08:18:40.88Z" },
{ url = "https://files.pythonhosted.org/packages/3e/11/e6f5b9a3d0e55b0ef7505cd3765cdd48f22db89994c947b316f52f801fd8/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1", size = 259950, upload-time = "2026-08-15T08:18:42.351Z" },
{ url = "https://files.pythonhosted.org/packages/1b/ee/e4e10a94d51cd1ee638aa7e00b65399e6b2a4e8376ab6d2eac9f95586671/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65", size = 249329, upload-time = "2026-08-15T08:18:43.914Z" },
{ url = "https://files.pythonhosted.org/packages/c4/25/d5f4198819e6059735a84e8d0bfb72dc33976da67b97adcd3fb5a5e07ec6/charset_normalizer-3.5.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5", size = 243137, upload-time = "2026-08-15T08:18:45.368Z" },
{ url = "https://files.pythonhosted.org/packages/a5/e9/e925ca7569cf9fb9701fd82503fee73eea5268fdb856bdd64947092d3daa/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af", size = 242820, upload-time = "2026-08-15T08:18:46.842Z" },
{ url = "https://files.pythonhosted.org/packages/34/17/672c251a888ed2aebcdd2fe830ad0104e25ff83c43f5c4f9c15e9fc6853c/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1", size = 230504, upload-time = "2026-08-15T08:18:48.353Z" },
{ url = "https://files.pythonhosted.org/packages/3f/fc/f6a85abebd42ce4da2f1db0aa56cc6a0df1995e318b3875d14401b8381d1/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9", size = 263087, upload-time = "2026-08-15T08:18:49.859Z" },
{ url = "https://files.pythonhosted.org/packages/98/66/7c42677e739ba66746b297e2046918d793078094dc239e1e72768cffccc6/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a", size = 243269, upload-time = "2026-08-15T08:18:51.601Z" },
{ url = "https://files.pythonhosted.org/packages/de/d8/a50b79237f417af10f8c2a501ce8d1ca87829a22e69117891ca4ba20a69e/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032", size = 258766, upload-time = "2026-08-15T08:18:53.23Z" },
{ url = "https://files.pythonhosted.org/packages/2e/1d/0fc91aeaeb3c83b748f532399ce67cf84604b48297405d740000f7a9e786/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e", size = 250814, upload-time = "2026-08-15T08:18:54.768Z" },
{ url = "https://files.pythonhosted.org/packages/ae/10/3d8c777cf9024615295aa1b808324ad5b4a77855869c00824bad74ffaf8a/charset_normalizer-3.5.1-cp314-cp314t-win32.whl", hash = "sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4", size = 191074, upload-time = "2026-08-15T08:18:56.305Z" },
{ url = "https://files.pythonhosted.org/packages/4d/81/ae557d3c44d1a1d688696d60563413a0866a91b7ebc50f20df838be3d8c8/charset_normalizer-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00", size = 216476, upload-time = "2026-08-15T08:18:57.889Z" },
{ url = "https://files.pythonhosted.org/packages/27/e9/61c01fb8b804692569c036b3fc50495814502dcf13a60649c6055390b02c/charset_normalizer-3.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f", size = 194115, upload-time = "2026-08-15T08:18:59.418Z" },
{ url = "https://files.pythonhosted.org/packages/4a/4e/8544831ef59d8f27ce92c80871380fdacc8076a8a56ed62f82e54f991333/charset_normalizer-3.5.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9085f87b0e38a2b92b8923059b4e8789fe40d9279712d15dcc670048d77079af", size = 342048, upload-time = "2026-08-15T08:19:01.054Z" },
{ url = "https://files.pythonhosted.org/packages/7f/a6/e3b46852424246065355644f4fb6dbccc0239a42a2eee27ecfc8957f0bcd/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8", size = 242997, upload-time = "2026-08-15T08:19:02.492Z" },
{ url = "https://files.pythonhosted.org/packages/03/3b/0cc9a26777334ab2f2e3089b948bbf4e4fe72ea70b897715ef6415043ec8/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90", size = 237014, upload-time = "2026-08-15T08:19:03.943Z" },
{ url = "https://files.pythonhosted.org/packages/8c/c2/027335f0aa337a2a2e121bac1ad88c4f02ba6053ea0926802784f3db11af/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac8c94b6539074e0f40899301273ac8402b9b3e01c7b7ba269ff30340aaaf20", size = 266174, upload-time = "2026-08-15T08:19:05.598Z" },
{ url = "https://files.pythonhosted.org/packages/86/d3/e367787febe4e74769dec0f406f2c3c8d1b955fce5aee1fd0f94e8367a45/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe532b3c966d1fb794e0698e4589d0444017ae77fc0b31edea13c0e35bcc449", size = 263361, upload-time = "2026-08-15T08:19:07.251Z" },
{ url = "https://files.pythonhosted.org/packages/af/3d/391b193eb9f3e84b02f9314088c386debdc0debee843535aaea2e2c6715d/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a", size = 252143, upload-time = "2026-08-15T08:19:08.816Z" },
{ url = "https://files.pythonhosted.org/packages/2e/57/de221f1745a90d418199761967e2776bfe2c275a1194220985e8c1d37833/charset_normalizer-3.5.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0", size = 252086, upload-time = "2026-08-15T08:19:10.255Z" },
{ url = "https://files.pythonhosted.org/packages/c8/e3/d119f86a01f9331e8186175f24873b1d74a7ee9e2e4b4d68f9947dae5afd/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:21b82d8082f6f5e7f456ef0bd16323d08de1266efbfeb476e64b2a91d1471a4e", size = 245231, upload-time = "2026-08-15T08:19:11.807Z" },
{ url = "https://files.pythonhosted.org/packages/26/de/d8e48c135ae480879539cdb179c8d3b50c7879497d75dd899b5763b69cee/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2", size = 241546, upload-time = "2026-08-15T08:19:13.416Z" },
{ url = "https://files.pythonhosted.org/packages/67/c4/217755fd1abc50d326c252922cd642002758095a81ff45010337b8b3ef65/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626", size = 267033, upload-time = "2026-08-15T08:19:14.981Z" },
{ url = "https://files.pythonhosted.org/packages/b8/d7/34d8e404e358d2adcc5a228c2134643af00104c8fb0bf525f3688d756f05/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5", size = 252045, upload-time = "2026-08-15T08:19:16.618Z" },
{ url = "https://files.pythonhosted.org/packages/5e/fa/40414471acf0aa0692ca77305aa00e434fcd8288f0941c93c30e9a5f8f2f/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774", size = 264866, upload-time = "2026-08-15T08:19:18.101Z" },
{ url = "https://files.pythonhosted.org/packages/32/90/fcc850bae791abd2e0c041847f13e270aa08692a79f3e00de6d2dce1cb50/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:687c9ca3035544b113bea2055e180af96fb63c0c476e22a9180f51925186e7b7", size = 253932, upload-time = "2026-08-15T08:19:19.734Z" },
{ url = "https://files.pythonhosted.org/packages/af/af/53afe99068b3c10b4cbae592a52ef72a7c92c0188440e83ee3a078fd8f75/charset_normalizer-3.5.1-cp315-cp315-win32.whl", hash = "sha256:706bfd38730a5ac7a365793269a00f4e988178cec121391f4248d84ad8c972e9", size = 180320, upload-time = "2026-08-15T08:19:21.37Z" },
{ url = "https://files.pythonhosted.org/packages/c9/bc/f46a132041b29e4a8779ed712d3df1bf112e94ca8de58b66d7ec2c0cf8b9/charset_normalizer-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712", size = 204174, upload-time = "2026-08-15T08:19:23.088Z" },
{ url = "https://files.pythonhosted.org/packages/a1/5d/9ed554480eda8e447b673648628fdc29574d23dbad01fe11837adedd1cae/charset_normalizer-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7", size = 184126, upload-time = "2026-08-15T08:19:24.471Z" },
{ url = "https://files.pythonhosted.org/packages/3b/32/9b8929bf384061ee1fe5d9c27c6f9776d3d824039ad4e14c88ec00c7808e/charset_normalizer-3.5.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663", size = 381441, upload-time = "2026-08-15T08:19:26.038Z" },
{ url = "https://files.pythonhosted.org/packages/96/10/e9aa7923d3ddac652c99a1c5f7be494e737e151566a44abe018daf757f2c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9dbdd9205662134957cf0c324f639bdc5031c0ca056e2369e238db75187c0f11", size = 241742, upload-time = "2026-08-15T08:19:27.532Z" },
{ url = "https://files.pythonhosted.org/packages/28/53/a2d249ebddf47b889a100c0bdcb61a2f9dbb8bc24ef325cc062e4f476877/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc", size = 235298, upload-time = "2026-08-15T08:19:29.274Z" },
{ url = "https://files.pythonhosted.org/packages/7d/07/469f78af590f7d5cd48e20d8dbfa3d66deeff9ba37768c04d886b5afd45c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a", size = 262500, upload-time = "2026-08-15T08:19:30.955Z" },
{ url = "https://files.pythonhosted.org/packages/55/66/3bb56a47f7dcba014055b1a1d33c6f08bbe9c1e74dba154cfa25f90ae885/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4", size = 258888, upload-time = "2026-08-15T08:19:32.458Z" },
{ url = "https://files.pythonhosted.org/packages/ff/c1/2adc2800903fb013210349313b710a5376856578d9e33e6b9a1d8b36714a/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004", size = 250243, upload-time = "2026-08-15T08:19:33.94Z" },
{ url = "https://files.pythonhosted.org/packages/95/b5/a18d0dd1157ab655cc2cb14a545f4a4784bbad70ab3502412e36097502d9/charset_normalizer-3.5.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b", size = 249871, upload-time = "2026-08-15T08:19:35.413Z" },
{ url = "https://files.pythonhosted.org/packages/ad/c3/525f508cd1e58d0450ac55ed40ac75bc3a97482c59def5278456a5fbf03c/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263", size = 243580, upload-time = "2026-08-15T08:19:36.886Z" },
{ url = "https://files.pythonhosted.org/packages/7c/c1/49a91fe7e97c8140094ca5c64161ab623a70d9f636bf834eace14048acb5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:252d099029bcbea642f2a06c4ed5046bdf8b5a8150b64afa5e027e88b106e5ee", size = 239807, upload-time = "2026-08-15T08:19:38.392Z" },
{ url = "https://files.pythonhosted.org/packages/d3/58/56a48c296601274c4689b864a8e2dfb209b81dfcb39472753ce95eea662b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c", size = 264083, upload-time = "2026-08-15T08:19:39.856Z" },
{ url = "https://files.pythonhosted.org/packages/10/4c/dc48409274a1817ff349711d26c62aa0c597df865d4d69ef79160c859193/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e", size = 250317, upload-time = "2026-08-15T08:19:41.53Z" },
{ url = "https://files.pythonhosted.org/packages/81/58/d325912115caec62d6bdd77bbab5e0b7da5d234a9f20affdffcbcb530d0b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d", size = 258173, upload-time = "2026-08-15T08:19:43.07Z" },
{ url = "https://files.pythonhosted.org/packages/34/f7/b13b1ccae2c8ec63980d13be1890eb73f8aeabbfce02a24aabc0908788f5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61", size = 251960, upload-time = "2026-08-15T08:19:44.587Z" },
{ url = "https://files.pythonhosted.org/packages/1e/25/ed3f9919c5aef8cc818be1f972f565f7610d7b2076b8ebb98839516ffc3c/charset_normalizer-3.5.1-cp315-cp315t-win32.whl", hash = "sha256:ac13b004224fb341e1e25a1ed5e19d32f57cdb2a403e01f003b46f051a550f6f", size = 191186, upload-time = "2026-08-15T08:19:46.293Z" },
{ url = "https://files.pythonhosted.org/packages/69/d5/43c2b3e9d8267092b913eb8b0603f0f71993c395632886bd37a7223f96cf/charset_normalizer-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:35aea775dc2bd5f54cd84a1cd2696cc3207c479cb9cf0bd346f0d343e4300ddb", size = 215947, upload-time = "2026-08-15T08:19:47.853Z" },
{ url = "https://files.pythonhosted.org/packages/a8/76/9aad3e9c8865e5e0efa9a7f6f81c37a67635a985145ecd44528a81e088ee/charset_normalizer-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:fb78f6e7fcd8ad785d28cd577168bc1aaee827b25bb8755638f694794ea98f0a", size = 193909, upload-time = "2026-08-15T08:19:49.383Z" },
{ url = "https://files.pythonhosted.org/packages/5b/97/fb4e82231aba271ffd775a1b4993b0defc4e3059f286ae41d9433409fe85/charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2", size = 331467, upload-time = "2026-08-15T08:19:50.959Z" },
{ url = "https://files.pythonhosted.org/packages/9f/2f/fe3f187327aac18e2d54e9d2b08e15d27bf9b642d9e51c219f130fc34d1a/charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99", size = 253057, upload-time = "2026-08-15T08:19:52.654Z" },
{ url = "https://files.pythonhosted.org/packages/d7/c7/9e48cee5c161fe24da823b61bf381921d77cb994a0a4de148e95018c1984/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2", size = 240930, upload-time = "2026-08-15T08:19:54.163Z" },
{ url = "https://files.pythonhosted.org/packages/49/e0/716601f3cc69be7b198951150c75ead1ece33c3c8036ff6ffa46029659a0/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235", size = 230822, upload-time = "2026-08-15T08:19:55.807Z" },
{ url = "https://files.pythonhosted.org/packages/d3/05/71bfc5caa0abcc45aea1f6a4d50ac68e59605ddc7666fe8494f4cd229665/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598", size = 260037, upload-time = "2026-08-15T08:19:57.312Z" },
{ url = "https://files.pythonhosted.org/packages/c3/92/de7e32ed05341e7a9c4c877c318418197b7f2d66a3b68d561bf2ac57ca3e/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96", size = 255097, upload-time = "2026-08-15T08:19:59.056Z" },
{ url = "https://files.pythonhosted.org/packages/f5/7b/ade0a122600319dfa0b1000ab0f9731c94a817904cf3c5de408c73a4ede7/charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962", size = 250166, upload-time = "2026-08-15T08:20:00.612Z" },
{ url = "https://files.pythonhosted.org/packages/75/9c/019fbb9f4834491a160951349b1a3714439376f66e5f7cf18b4f18f0c7aa/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3", size = 241821, upload-time = "2026-08-15T08:20:02.321Z" },
{ url = "https://files.pythonhosted.org/packages/2b/b8/11d4840bfc99330cc7fbcc2681ee5a044553a6e77655508d8f9b2bff7b34/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950", size = 232529, upload-time = "2026-08-15T08:20:04.008Z" },
{ url = "https://files.pythonhosted.org/packages/18/96/2b3a21492d9f65171ac75d872f5018260013d00bfa0ff70ec9f179148cbd/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8", size = 260348, upload-time = "2026-08-15T08:20:05.877Z" },
{ url = "https://files.pythonhosted.org/packages/d6/aa/a69a2028e8bd052476c245460ab19d7de595de084dd968f2d75cd50c3e25/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031", size = 247234, upload-time = "2026-08-15T08:20:07.487Z" },
{ url = "https://files.pythonhosted.org/packages/35/8a/3d130aeabcaf3d2466af76b7b141c08d9e89c9016ab4b7cdd0f7dc2d1c62/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072", size = 256917, upload-time = "2026-08-15T08:20:09.142Z" },
{ url = "https://files.pythonhosted.org/packages/80/c2/a7379b840292d0c1ab9fbd17d1f3967aa81794dc95bc74be8999d7fedcf7/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d", size = 254846, upload-time = "2026-08-15T08:20:10.727Z" },
{ url = "https://files.pythonhosted.org/packages/01/65/d43b714731bb2f40d4053dfa00ecfc1c5a301f8e3316c5db3a09af59fe94/charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc", size = 174216, upload-time = "2026-08-15T08:20:12.334Z" },
{ url = "https://files.pythonhosted.org/packages/35/4f/b911ed898b26a09789eba9c9200c999aff6c61b4bafaf4838e56d1a1e1a3/charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959", size = 199764, upload-time = "2026-08-15T08:20:13.908Z" },
{ url = "https://files.pythonhosted.org/packages/f0/a7/920baf467bfd9bf689f3b318340f37aee4572a71f162bd8db51da55ba4fa/charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e", size = 287318, upload-time = "2026-08-15T08:20:15.551Z" },
{ url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" },
] ]
[[package]] [[package]]
@@ -366,15 +433,15 @@ wheels = [
[[package]] [[package]]
name = "deno" name = "deno"
version = "2.9.3" version = "2.9.5"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/98/ab/638749d76881f74d100a079414745f0a0fd20bba38e27ab4a3a6495d8264/deno-2.9.3.tar.gz", hash = "sha256:73268cbac7f7c4ff1983e49420a93f3f3f2f2e4e4372436f0942d04d02c9e943", size = 8166, upload-time = "2026-07-15T15:37:50.565Z" } sdist = { url = "https://files.pythonhosted.org/packages/26/27/06ad530f3be68dafdfae57457c534d5bb014cd6da58c2bd0f5b28f9ab8fa/deno-2.9.5.tar.gz", hash = "sha256:2f9681d7d2118a5e92e18915d1d88a6966fd6173ba4a8fb772f04080057e5a6a", size = 8166, upload-time = "2026-08-06T15:08:32.319Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/07/fa/b4e1e2b3b894ed992c78a6796a43dc775edff895239d83c0e9a10457f4f7/deno-2.9.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:1deef9fe97ab28d5d4fea07693075e9ccc62f4ea97a653047a770beb01ecd307", size = 42354026, upload-time = "2026-07-15T15:37:34.977Z" }, { url = "https://files.pythonhosted.org/packages/a8/99/1a1ff9e40e82d857b426a86d2159e3560000c2f98edb0f63170256582067/deno-2.9.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d321f54f82cb535904a108afd970c1c3e1db23716db1ab0cda5e0ca993dbf824", size = 42353187, upload-time = "2026-08-06T15:08:10.431Z" },
{ url = "https://files.pythonhosted.org/packages/3d/cc/4090ef7370005b740efe57ec6ff75a3471393abe9816f1459e73dbbf0429/deno-2.9.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:78635cd9f0802ce7125aa3ba6feb5775460f55d432611374f16b454ffb8a1308", size = 37997909, upload-time = "2026-07-15T15:37:38.291Z" }, { url = "https://files.pythonhosted.org/packages/ce/3a/d60dac70124d045dfc6f01bbd181e2865904ecca5710b2d319869ff59727/deno-2.9.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:696dffb666d6336a3527857b2f9b665db33b07883bb6adc3595dbb3b78748cf1", size = 38520058, upload-time = "2026-08-06T15:08:14.978Z" },
{ url = "https://files.pythonhosted.org/packages/14/07/70b7c965bbca5f3d158859acda0e90c041f1fa98b8c9ac8eff3fafd8b96c/deno-2.9.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:7c1c669827a480ef9e2710dd762560811da35dca04a9e90b23c0518587dadb8a", size = 42100873, upload-time = "2026-07-15T15:37:41.539Z" }, { url = "https://files.pythonhosted.org/packages/a4/b4/5d462bc738b2172043a470ab97d7b6f3e7a287fa1916065e777e034ef1b1/deno-2.9.5-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:bccec0965917b6daa323f9ce6ad8e884e0854506b0cdaec9de41bdac7620109a", size = 39908447, upload-time = "2026-08-06T15:08:19.751Z" },
{ url = "https://files.pythonhosted.org/packages/7b/f5/0a07cc19a27476011719dd017a98950c7ccc33058c2ffc07019ba3ad9a5e/deno-2.9.3-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:3e724d5c8df13a90f6b50b16cc8df1acfe64c61a7736a774e11d61f3bca3a3b1", size = 43928967, upload-time = "2026-07-15T15:37:45.175Z" }, { url = "https://files.pythonhosted.org/packages/60/54/c6e0fb4b5e96dd7611bc75c8137046d7436666bd2e278f686e36d6a805af/deno-2.9.5-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:e5aec0419739fd0359663a78765d4feda19d1d0b60a682d25add9b78e19f95b4", size = 41642333, upload-time = "2026-08-06T15:08:24.965Z" },
{ url = "https://files.pythonhosted.org/packages/e1/9a/030847bd4ea6cefbb471cae2b63c9ab29a3ab6c1809484447752bbcbcd52/deno-2.9.3-py3-none-win_amd64.whl", hash = "sha256:e2ebe2b5c1a7ee9daeaf8caf14ebc33fda96b4c2399da259e761fe2f82d22fa4", size = 41630201, upload-time = "2026-07-15T15:37:48.344Z" }, { url = "https://files.pythonhosted.org/packages/7e/0b/3ca6468ec928d817ce6ef062ddfa11e71a3c68035c3401057d7cb33845f6/deno-2.9.5-py3-none-win_amd64.whl", hash = "sha256:570d4ee6f1ddb16d14848d36dac6cd2f586d8a0c94cf9c3e6b5da130da2597d5", size = 41615800, upload-time = "2026-08-06T15:08:30.015Z" },
] ]
[[package]] [[package]]
@@ -656,20 +723,20 @@ wheels = [
[[package]] [[package]]
name = "packaging" name = "packaging"
version = "26.2" version = "26.3"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
] ]
[[package]] [[package]]
name = "platformdirs" name = "platformdirs"
version = "4.10.0" version = "4.11.3"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } sdist = { url = "https://files.pythonhosted.org/packages/b8/d7/e7bfbc86e9f99ff7807e24de7703f032e9c9ba80bb355cf26e0e9bc5a75e/platformdirs-4.11.3.tar.gz", hash = "sha256:66a73d38a849810252df809a3d8bcbda8e26f6c189920e7535ad608a48dbb5ab", size = 33050, upload-time = "2026-08-13T22:43:27.52Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, { url = "https://files.pythonhosted.org/packages/19/a9/c34aebedd3a4c9afe5101b1b8713710b3fec18087c8a36c35d2f909861bd/platformdirs-4.11.3-py3-none-any.whl", hash = "sha256:5ed065d443751de711da036041a7a214122efc4a4de393b3f4137ba5576540e7", size = 23491, upload-time = "2026-08-13T22:43:26.121Z" },
] ]
[[package]] [[package]]
@@ -808,7 +875,7 @@ wheels = [
[[package]] [[package]]
name = "pylint" name = "pylint"
version = "4.0.6" version = "4.0.7"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "astroid" }, { name = "astroid" },
@@ -819,9 +886,9 @@ dependencies = [
{ name = "platformdirs" }, { name = "platformdirs" },
{ name = "tomlkit" }, { name = "tomlkit" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/7d/1d/3bb57f303701549550d74bf7ced2b07412be97125c167a0c9d216aa9f762/pylint-4.0.6.tar.gz", hash = "sha256:52f19191bee08bf103f9705ad1a0ece4aa5a0a4ef2bdcbd969375a1e6f6579d5", size = 1585588, upload-time = "2026-06-14T14:43:26.772Z" } sdist = { url = "https://files.pythonhosted.org/packages/de/92/98dace02f2d11b88160354c53944f77ea7327aa78bce1c75971e7aaa4347/pylint-4.0.7.tar.gz", hash = "sha256:9b2d1d15791c84b77a4fe2aafe8f0d9570717e2dea06d53b19c105cf60275a52", size = 1594770, upload-time = "2026-08-09T19:13:23.289Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/ab/da/acb2e7d4dbd2dfb792d38c0d850481f29ad7049b356d23f56c687d35203b/pylint-4.0.6-py3-none-any.whl", hash = "sha256:d11a0e1fdb7b1cd46ec5d6fc78fee8b95f28695b2d6140e5809925f61e32ea54", size = 538389, upload-time = "2026-06-14T14:43:24.873Z" }, { url = "https://files.pythonhosted.org/packages/e0/b0/3a8040e53df6c5c1e04b0e23ed53fdbeb64f333723a334d313fba2f581ce/pylint-4.0.7-py3-none-any.whl", hash = "sha256:be4a3111557a614411ed1fc89347ce4a8e1013a59e1f33d11485227a02e3304d", size = 539710, upload-time = "2026-08-09T19:13:21.228Z" },
] ]
[[package]] [[package]]
@@ -868,27 +935,27 @@ wheels = [
[[package]] [[package]]
name = "python-engineio" name = "python-engineio"
version = "4.13.3" version = "4.13.5"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "simple-websocket" }, { name = "simple-websocket" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/fb/a0/f75491f942184d9960b15e763270f765fe9f239745ca5f9e16289011aed4/python_engineio-4.13.3.tar.gz", hash = "sha256:572b7783e341fed21edbc7cea297ccd378dad79265fdde96aa4664420a7c06c9", size = 79734, upload-time = "2026-06-20T22:53:52.197Z" } sdist = { url = "https://files.pythonhosted.org/packages/df/d8/65cc479ab697a2e7fdee83a9bd8a06b61ec68bf763a58a302cf161bf38bb/python_engineio-4.13.5.tar.gz", hash = "sha256:b5764d62243e3ffbc4c76dda3d7897c329dc52294c80c27105f9faa054e76897", size = 80035, upload-time = "2026-08-12T22:52:23.78Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/5b/96/82f6328e410515fab21d5602ba35b9377a47b5a141a0c1f9efa00ce21eb4/python_engineio-4.13.3-py3-none-any.whl", hash = "sha256:1f60ecaf1358190f0e26c48c578a60428dc02a8f1295bc3dbf53d1b31116821f", size = 59993, upload-time = "2026-06-20T22:53:50.775Z" }, { url = "https://files.pythonhosted.org/packages/6b/01/f804208061b504894546fddc479f6075e0f00dfe88cb1703dafaa8c3c67e/python_engineio-4.13.5-py3-none-any.whl", hash = "sha256:05c9f4951d242ad33d613b4245299562e5f64e4199f00e5390f9888505831704", size = 59963, upload-time = "2026-08-12T22:52:22.5Z" },
] ]
[[package]] [[package]]
name = "python-socketio" name = "python-socketio"
version = "5.16.3" version = "5.16.4"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "bidict" }, { name = "bidict" },
{ name = "python-engineio" }, { name = "python-engineio" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/32/2d/ffce71017c106b75099fea569df6518c63fee5d6202ce0cfe7b01e6f22c3/python_socketio-5.16.3.tar.gz", hash = "sha256:89b136f677ae65607a84cecda9b4d6c5377b40a97582c504c25df89af16d520e", size = 128095, upload-time = "2026-06-15T22:07:04.003Z" } sdist = { url = "https://files.pythonhosted.org/packages/06/5e/87d6b547c87c6d64f4a05f5bfaf6f42e9b786561216434290fdaa83f8667/python_socketio-5.16.4.tar.gz", hash = "sha256:f7fa4a43cc8e687930b5c6e44d6e2efc2071eca4bef49b8bb3dc0827f7f92235", size = 128140, upload-time = "2026-08-06T23:11:21.346Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/0a/38/8c5e72d53ff8eb27497c4f268a7f6d9121e727a50b65248288ad79a93053/python_socketio-5.16.3-py3-none-any.whl", hash = "sha256:e7ad14202a5e6448824c7c2f86161d04e13dec05992257df5c709e6a2798c041", size = 82087, upload-time = "2026-06-15T22:07:02.498Z" }, { url = "https://files.pythonhosted.org/packages/94/d9/463feca73ec119a135d90c9f40c0172b4758150b5ed442f0ca1e8fed807a/python_socketio-5.16.4-py3-none-any.whl", hash = "sha256:0eb9c7687e7fbf59e60d714fd62afba77dfaf8ef8a06a0bff05a86c351accc2f", size = 82098, upload-time = "2026-08-06T23:11:19.851Z" },
] ]
[[package]] [[package]]
@@ -933,11 +1000,11 @@ wheels = [
[[package]] [[package]]
name = "tomlkit" name = "tomlkit"
version = "0.15.0" version = "0.15.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/51/db/03eaf4331631ef6b27d6e3c9b68c54dc6f0d63d87201fed600cc409307fd/tomlkit-0.15.0.tar.gz", hash = "sha256:7d1a9ecba3086638211b13814ea79c90dd54dd11993564376f3aa92271f5c7a3", size = 161875, upload-time = "2026-05-10T07:38:22.245Z" } sdist = { url = "https://files.pythonhosted.org/packages/94/96/e07752635b98536177fa1f37671c8f3cdde2e724c6bcf6034b2cfb571565/tomlkit-0.15.1.tar.gz", hash = "sha256:e25bbf38843005246210a12982776f27f99cb9be67160e14434d0c0d21ee1e97", size = 180129, upload-time = "2026-07-17T01:48:04.562Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/6a/43/8bd850ee71a191bf072e31302c73a66be413fecdd98fdcd111ecbcce13ca/tomlkit-0.15.0-py3-none-any.whl", hash = "sha256:4dbc8f0fc024412b57ced8757ac7461305126a648ff8c2c807fcb8e133a78738", size = 41328, upload-time = "2026-05-10T07:38:23.517Z" }, { url = "https://files.pythonhosted.org/packages/13/bc/8c13eb66537dce1d2bd3a57132902f38d0e7f5bb46fa9f4daed9fe9d76ee/tomlkit-0.15.1-py3-none-any.whl", hash = "sha256:177a05aece5a8ca5266fd3c448abb47b8d352f09d477d3ca8332db4d89b24304", size = 49449, upload-time = "2026-07-17T01:48:05.728Z" },
] ]
[[package]] [[package]]
@@ -1023,62 +1090,71 @@ wheels = [
[[package]] [[package]]
name = "websockets" name = "websockets"
version = "16.1" version = "17.0.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8c/02/b9a097e1e16fee4e2fd1ec8c39f6a9c5d6257bae8fa12640caf869f54436/websockets-16.1.tar.gz", hash = "sha256:299468cbe42e2b9981134c7c51d99387d8a7bf562b00183b3eec53f882846dad", size = 182530, upload-time = "2026-07-10T06:32:57.734Z" } sdist = { url = "https://files.pythonhosted.org/packages/f7/96/e01084f83a64bcb3a27994bd0cb0db68ff29d9c6707fae37ec19b18ba990/websockets-17.0.1.tar.gz", hash = "sha256:5baa9bc0dfbae8c507e51c8cf1b6d4628086f7a87bbd3a9952bd5f035451f1cc", size = 183298, upload-time = "2026-07-31T11:31:27.665Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/d9/63/df158b155420b566f025e75613424ad9649a24bcb0e9f259321ab3d58bea/websockets-16.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:b0232ed141cec3df2af5a3959a071c51f40036336b0d37e17faf9ef52fc73e47", size = 179791, upload-time = "2026-07-10T06:31:33.108Z" }, { url = "https://files.pythonhosted.org/packages/ba/a8/79c577bc2f874ee22f6f5ccdab97ba9ce6b96806be3fcc3a6d8490f88a21/websockets-17.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:55b12e47dcee83673a40d07686cfb6f9d6dfc285976ade9463f61d2bef3fad22", size = 212593, upload-time = "2026-07-31T11:29:56.518Z" },
{ url = "https://files.pythonhosted.org/packages/74/cf/00fe9414dfeafa6fe54eae9f5716c8c8e9ac59d192be3b893c096d395846/websockets-16.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a71b73d143991714144e159f767b698f03c4a70b8a65ae1733b650cff488045b", size = 177472, upload-time = "2026-07-10T06:31:34.522Z" }, { url = "https://files.pythonhosted.org/packages/db/99/e1cfaf419bb3b2fcfd6792a846f1d936293132b0b9a56530ced016c83c7b/websockets-17.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c1c118a6b0e25bfc9a6802075d748fa6321714ffbdf3c88d29d9a0e3c7386c75", size = 210280, upload-time = "2026-07-31T11:29:57.768Z" },
{ url = "https://files.pythonhosted.org/packages/8b/76/b10633424d40681b4e892ffd08ca5226322b2426e62d4ab71eae484c3a32/websockets-16.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:187323204c3b2fc465e8fc2609e60437c521790cb9c1acb49c4c452a33e57f37", size = 177737, upload-time = "2026-07-10T06:31:35.964Z" }, { url = "https://files.pythonhosted.org/packages/a2/ef/cc994494bf7d97e41833f6ff55c24f535e4d527a10370b9631737e9c2f00/websockets-17.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:734d20364dc2cfe03674883cafcf580b6e431c5ce42b476312b9285310230cf9", size = 210538, upload-time = "2026-07-31T11:29:59.021Z" },
{ url = "https://files.pythonhosted.org/packages/dc/61/d3bb03b2229bb1afd72008742d586cf1ea240dce64dd48c71c8c7fd3294c/websockets-16.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9dba74233c8c3ce368850818c98354dad2570f57231b3fd3bd00d7aa57628881", size = 187403, upload-time = "2026-07-10T06:31:37.496Z" }, { url = "https://files.pythonhosted.org/packages/87/32/fbf2d132f63ba3e67f675bccf333469786a24e0418969ce1d8e6ff9e6f02/websockets-17.0.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9493314a99e599163c854fb5900ad7f7ea38c5cb9d9103aa30b3c6b8181c01fa", size = 219925, upload-time = "2026-07-31T11:30:00.298Z" },
{ url = "https://files.pythonhosted.org/packages/26/16/cc2e80478f688fc3c39c67dc1fac6a0783858058914ebc2489917462cb42/websockets-16.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:63339bc8c63c86a463177775cb7c677691f5bcfac7b3b2f01b286d42acd41600", size = 188639, upload-time = "2026-07-10T06:31:38.86Z" }, { url = "https://files.pythonhosted.org/packages/16/50/64eee3d25a47fe744a9490e0627cc373dca096755db740f91c28bd61cd35/websockets-17.0.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:18ded646ce98cdd3c0235825b3252f1df55765ba49b616bb10282f758667b4d0", size = 220206, upload-time = "2026-07-31T11:30:01.52Z" },
{ url = "https://files.pythonhosted.org/packages/15/d6/ad87b2507e57de1cbf897a56c963f2925962ed5e85fbe06aaa83ced27acd/websockets-16.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:23e545ea8ae4263e37cdfd4e22a217f519e48e432728bc461185bbf585f38a83", size = 190078, upload-time = "2026-07-10T06:31:40.218Z" }, { url = "https://files.pythonhosted.org/packages/15/56/10ed4bc4dd75f204e3c62bd4898e44a8742a27773c80b188cfa7888aad2d/websockets-17.0.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c1bec5d6a19f5fbe87e4940739cfc65e7bb53d8b353e1029b8037a1653b321bc", size = 221445, upload-time = "2026-07-31T11:30:02.788Z" },
{ url = "https://files.pythonhosted.org/packages/9e/1a/5b37b3fd335d5811f29fc829f2646a3e6d1463a4bf09c3100708684c766e/websockets-16.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2237081454846fb40403a80ba86d82e2038b9c45865ab96af0abe7d002a91045", size = 189267, upload-time = "2026-07-10T06:31:41.523Z" }, { url = "https://files.pythonhosted.org/packages/bf/be/bb14328614c068ab09569962fbf218fc00413ce3febc6d2684c764b6f37e/websockets-17.0.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:872273e629ca7e3d35f16a2dc6ede84e1d5c831e616b8277de6e4f83114e7c58", size = 222887, upload-time = "2026-07-31T11:30:03.943Z" },
{ url = "https://files.pythonhosted.org/packages/42/98/06afc33e9450d4230f94c664db78875d90f5f6a5fb77f0bc6ec15ae74e1c/websockets-16.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5f5218de1ed047385ca53744caba9435d65f75d008364970a3fae95a05812cf9", size = 188022, upload-time = "2026-07-10T06:31:42.838Z" }, { url = "https://files.pythonhosted.org/packages/32/1b/4cb0eec2fee310007104687493175af190019f705940c864f9c523fe9f6f/websockets-17.0.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1df81d174c1561292de9e40b141cafc04f69077272f6c352afe1d743e20810df", size = 222072, upload-time = "2026-07-31T11:30:05.258Z" },
{ url = "https://files.pythonhosted.org/packages/8c/bf/42fef5d5887c18cf2d148b02debf56cecb9cfbffc68027cde9b12c8f432c/websockets-16.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:75c98e3920039d0edff03b74478ada504b7ce3a1bc406db2cabfca84320f7baf", size = 185435, upload-time = "2026-07-10T06:31:44.219Z" }, { url = "https://files.pythonhosted.org/packages/6c/9c/14e6391de777ddb39c439c450deb551406d445e25a5877d6fa25c49d4544/websockets-17.0.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:759adeb5b0c5775b563254ec63b5b79089fc0045b479143a0b1b8c0ebaae1253", size = 220826, upload-time = "2026-07-31T11:30:06.53Z" },
{ url = "https://files.pythonhosted.org/packages/a0/9b/8021c133add5fe40ed40312553a6cd1408c069d7efe3444ad483d4973ed3/websockets-16.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1facd189d8190af30487a55b4c3688484dd50801628a3b5b2ccd26db08e67057", size = 188080, upload-time = "2026-07-10T06:31:45.986Z" }, { url = "https://files.pythonhosted.org/packages/cb/57/96e94e384442247bbed5d3ab67381c7257355c2d66b62c3ad33a17f5d385/websockets-17.0.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1d99db29b5444e3982f1ce2ba8a833508ad44b2f1fbd0bd99e81d825c0b461", size = 218107, upload-time = "2026-07-31T11:30:07.766Z" },
{ url = "https://files.pythonhosted.org/packages/69/54/1e37384f395eaa127383aab15c1c45e200890a7d7b99db5c312233d193e0/websockets-16.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:cc0c6a6eef613c7da32d4fb068f82ef834b58134f6a16b54e6c1e5bf9529ab3d", size = 186678, upload-time = "2026-07-10T06:31:47.449Z" }, { url = "https://files.pythonhosted.org/packages/c0/8c/9c9dedd14c3919435df9b35cdee7111268c751252b87652f3a6a4f56e760/websockets-17.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:02ed63bf26dda9fa27df730a41f6664586c4ee05972c8fb667ce1725b3fd13d3", size = 220889, upload-time = "2026-07-31T11:30:09.035Z" },
{ url = "https://files.pythonhosted.org/packages/68/79/1caeacab5bc2081e4519288d248bc8bd2de30652e6eaa94be6be09a1fe5b/websockets-16.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:ad9411eded8988b879be6038206698bf7106c85a78f642c004485bcb95be17eb", size = 188554, upload-time = "2026-07-10T06:31:48.886Z" }, { url = "https://files.pythonhosted.org/packages/94/4d/ca73c2ac82c00f50c529784bacb323e42da4816333211bc1543d90c9cf11/websockets-17.0.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:eab6de8a98b9a7772cf686d00b4de439fc7efb8ab05ae106ef227291d06f87c5", size = 219486, upload-time = "2026-07-31T11:30:10.289Z" },
{ url = "https://files.pythonhosted.org/packages/ee/83/b3dca5fad71487b726e31cb0acf56f226792c1cc34e6ab18cbf146bd2d74/websockets-16.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:cd68f0914f3b64694895bc5e9b14e8b447e41d7bf5ffaf989bb8dcb5e2dfdce7", size = 186109, upload-time = "2026-07-10T06:31:50.508Z" }, { url = "https://files.pythonhosted.org/packages/5b/da/fb37ac09dcd7c69dd73bac979ed393df35f78a3c232e293d1ff3bd586d24/websockets-17.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2a855b6dfe21c4d3420be265ae031829ba8ba0be0ea350d9f7c3ef30ae63ebe2", size = 220258, upload-time = "2026-07-31T11:30:11.605Z" },
{ url = "https://files.pythonhosted.org/packages/5b/0b/8f246c3712f07f207b52ea5fb47f3b2b66fafec7303162644c74aed51c6a/websockets-16.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fef2debfe7f7ebdda12176f26166f95b7af17af05ba06150fcf889032e0213e9", size = 187061, upload-time = "2026-07-10T06:31:51.861Z" }, { url = "https://files.pythonhosted.org/packages/fc/04/9693f191d968a93f37326a17301a101d49580889c688f466699f89ecdee1/websockets-17.0.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7002d5f9e1c3ddd991cdfdbfee18cc8c8b196b2445022892badacd6cb338bbbc", size = 221358, upload-time = "2026-07-31T11:30:12.858Z" },
{ url = "https://files.pythonhosted.org/packages/47/eb/27d6c92a01696b6495386af4fc941d7d0a13f2eab2bf9c336111d7321491/websockets-16.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a3cd6c9b798218798f4bb7b2e71c38f0e744bb94ca537b13376f88019d46384d", size = 187347, upload-time = "2026-07-10T06:31:53.246Z" }, { url = "https://files.pythonhosted.org/packages/cf/29/ad0d85c01db5dcf22898d51648bd2c25af0dd0a4a41c550b11acddeeeba7/websockets-17.0.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c395bda8e7d8f51a02e80261fb57127979e5c472675d9a96b2860619ad47da48", size = 218921, upload-time = "2026-07-31T11:30:14.064Z" },
{ url = "https://files.pythonhosted.org/packages/6b/d5/eeee439921f55d5eaeabcea18d0f7ce32cdc39cb8fc1e185431a094c5c7b/websockets-16.1-cp313-cp313-win32.whl", hash = "sha256:84c170c6869633536921e4474b1cce7254c0c9b0053ef5725f966cee47e718e4", size = 180149, upload-time = "2026-07-10T06:31:55.058Z" }, { url = "https://files.pythonhosted.org/packages/18/3b/bf8e855e495dcca63f2b8aa019cf2ada3160e1fa66d833c7417f3b1f7f38/websockets-17.0.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aadc298969ad229d8e3029fc5cc751fdad286696230f9cf014e90ff9cd8e6ea0", size = 219871, upload-time = "2026-07-31T11:30:15.358Z" },
{ url = "https://files.pythonhosted.org/packages/a3/03/971e98d4a4864cf263f9e94c5b2b7c9a9b7682d77bfbba4e732c55ee85a9/websockets-16.1-cp313-cp313-win_amd64.whl", hash = "sha256:bef52d327d70fa75dad93ee61ea2cb1d1489aca9f35c188833563f5a3b4df0a5", size = 180458, upload-time = "2026-07-10T06:31:56.767Z" }, { url = "https://files.pythonhosted.org/packages/3b/db/c7abd6639a93a40279cd1ddc57e09e1c4f8381c4cfccdb775aa5aac9770a/websockets-17.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f11a398d8170b7ac5000baf7f258dcda579ef3ea744e0cc6a165e0dfbc0d3198", size = 220154, upload-time = "2026-07-31T11:30:16.96Z" },
{ url = "https://files.pythonhosted.org/packages/8d/e6/da1dc11507f8118145a81c751fe0c77e5e1c11b8554496addb39389e2dc2/websockets-16.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:f881fca0a45dd6789939bd6637cd98169b92f1c3fdc78262f2cb9ec2cb1f324e", size = 179833, upload-time = "2026-07-10T06:31:58.19Z" }, { url = "https://files.pythonhosted.org/packages/f6/2a/25a9f8f2e5a6ef34e911d2f55d9f756bdeb92b4c28cfb77b8430bbc73cb1/websockets-17.0.1-cp313-cp313-win32.whl", hash = "sha256:846a4a8b0833e3cad57523d9e3bd50ec8ea05ab9d06c582f82a1340ba096af5f", size = 213038, upload-time = "2026-07-31T11:30:18.434Z" },
{ url = "https://files.pythonhosted.org/packages/6e/ac/c0d46f62e31e232487b2c123bc3cfd9a4e45684ca7dc0c37f0987f29baae/websockets-16.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:30c379d5b207d3a7f0ba4c2e4602a895b0bcc63fb5f5371a4ae7fbddb03b672b", size = 177524, upload-time = "2026-07-10T06:31:59.563Z" }, { url = "https://files.pythonhosted.org/packages/81/2f/ea1380f72bb11b64fc5bc7ae0d42de5bbf3e6dc13b965706b2a1d4e17cdf/websockets-17.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:409d93efcaa14f7a99592c5baaef5ec6ca94fba0f5aec1a86f693977c69c9c1c", size = 213348, upload-time = "2026-07-31T11:30:19.693Z" },
{ url = "https://files.pythonhosted.org/packages/4a/33/abd966074b34a51e4f134e0aaed80f5a4a0a35163ea5ac58a1bc5a076d23/websockets-16.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:98ab58a4faa72b46da0127ccc1931dcbfc0985b0778892300a092185910c4cbe", size = 177743, upload-time = "2026-07-10T06:32:00.959Z" }, { url = "https://files.pythonhosted.org/packages/e6/c7/b956ed9151c3c74530ebc62d716fbfdbde7507a6acc6423a64f9ecfb6b8a/websockets-17.0.1-cp313-cp313-win_arm64.whl", hash = "sha256:90246fa9e6cb192a778ce6ce024057ec54317a894db7899c922dcdc1f4cbf6a5", size = 213282, upload-time = "2026-07-31T11:30:21.045Z" },
{ url = "https://files.pythonhosted.org/packages/ea/30/646e47b8a8dff04e227bdab512e6dde60663a647eeac7bbd6edddd92bbc5/websockets-16.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e9c4e369fc181b2d41a99e01477215cecdc8546a39f7d41a59cc0a7065a0b09", size = 187474, upload-time = "2026-07-10T06:32:02.54Z" }, { url = "https://files.pythonhosted.org/packages/98/dc/cadab608924ac605647031472fb1f8792d7d4ea07565ba1899ec42028e0d/websockets-17.0.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:53b90c00bc6201ab6695c7ff51a04d0e425514c37515e9eeecd2c1b978ac6c0e", size = 212640, upload-time = "2026-07-31T11:30:22.436Z" },
{ url = "https://files.pythonhosted.org/packages/d2/72/890ab9d77494af93ea65268230bfbc0a90ba789401ed7a44356a44785644/websockets-16.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0704df094b2d5fa7f6f410925a594c2a5c9a09167731a76292e5410934208209", size = 188717, upload-time = "2026-07-10T06:32:04.156Z" }, { url = "https://files.pythonhosted.org/packages/16/7a/b034d13ca181211bbd58bb50835cb196a7784cd505b5a2079d4d03374f9f/websockets-17.0.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:5f33a649bfcb8312524173cc4bbafa7dbb236e18eee9aa31a1d324ca0ddda28c", size = 210332, upload-time = "2026-07-31T11:30:23.608Z" },
{ url = "https://files.pythonhosted.org/packages/d5/aa/baedbbaa6bf9ed6029617ed5e8976535bd805f483ca9b3484e7ad9ee08bf/websockets-16.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b22b1f4950f6ab7126623329c3b47b3b90a14c05db517f2db2a026ad6c928352", size = 190090, upload-time = "2026-07-10T06:32:05.822Z" }, { url = "https://files.pythonhosted.org/packages/2f/4d/943ede39b53744768edf1ed84a3f9401527388228a3d6c1249c02c3d6bd7/websockets-17.0.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cddc675ec31bca65473321f9a9794e488b43b3b8de5d02c8ef4810c5d5792163", size = 210546, upload-time = "2026-07-31T11:30:24.932Z" },
{ url = "https://files.pythonhosted.org/packages/52/4f/d813ec94e18002571ef4959d87a630eff6e01b72a51bcb0832b75ae8c51a/websockets-16.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1ae4a686a662964a6671069f84f7f908cc3475e782227726b0c622c715962105", size = 189320, upload-time = "2026-07-10T06:32:07.223Z" }, { url = "https://files.pythonhosted.org/packages/cf/e3/88dc159d2ae66743c669443246243f28d873b0c5e58271b8cc1ca0440334/websockets-17.0.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b3ff0ad440ad52dda64138f16895f66403f40192365e39b1010e889f289746b0", size = 219928, upload-time = "2026-07-31T11:30:26.221Z" },
{ url = "https://files.pythonhosted.org/packages/b8/3c/8ec52a6662f3df64090fba28cd521d405d54759268d8e820477037e8c80d/websockets-16.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:856bdd638f8277f86465057bfdd4da097c73058fb0f9d2bd5baea29e2bf2d367", size = 188068, upload-time = "2026-07-10T06:32:08.586Z" }, { url = "https://files.pythonhosted.org/packages/fe/f2/ff27eaefa15851a5cf7f004ab827a022bf2d6632cb520f89cb100db7e84b/websockets-17.0.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:72d7f2a5aeb4e82daa4ee18f125b4277f427033359be5c745ad709608446cc2c", size = 220279, upload-time = "2026-07-31T11:30:27.49Z" },
{ url = "https://files.pythonhosted.org/packages/96/7f/f0ae6042b14f86fa5f996c6563ea4cf107adc036ccbedc9d4f418d0095f9/websockets-16.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9003a1fde1c21a322a3ca3fa0c4bda8c639da81dbc925162766086643b05ba87", size = 185493, upload-time = "2026-07-10T06:32:09.968Z" }, { url = "https://files.pythonhosted.org/packages/19/2e/a5166149f363d2449c1cb2dde6486a245521979509d53b87a09f3e79662b/websockets-17.0.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6fd88365da261c53d3e943fb37e0d0721b9cde119f6b2e3fc84369b6ab234d63", size = 221525, upload-time = "2026-07-31T11:30:28.872Z" },
{ url = "https://files.pythonhosted.org/packages/89/ad/5ffc53af9939c49fd653d147fa5b8f78ced1f6bce6c49a7446860945b0ce/websockets-16.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:39e947b1f5fdab045174306e3916785bf3ed537648acc1549827c08c33b10953", size = 188141, upload-time = "2026-07-10T06:32:11.434Z" }, { url = "https://files.pythonhosted.org/packages/9b/b5/f46931269b3ff3bde65d27c65ddb22f9bb8ce92ac2c6c4df0910128f6219/websockets-17.0.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ab9f962a5b64a5c3c845d556b7dc4e6fb683f7b67179f8205e814bb2e0213ffe", size = 222897, upload-time = "2026-07-31T11:30:30.164Z" },
{ url = "https://files.pythonhosted.org/packages/67/62/729206c0ee577a4db8eae6dd06e0eef725a1287c6df11b2ef831d003df31/websockets-16.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5dd0e666b5931c0509cf65714686a1c5126771e663a79ac5d40da4f58b1f9502", size = 186653, upload-time = "2026-07-10T06:32:12.845Z" }, { url = "https://files.pythonhosted.org/packages/42/f4/deccf3439f35df953ec35e13fe07986821c5f1ab5785d69614283bdb9034/websockets-17.0.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8c07f145d0b9e90cbd96035f31fb79199aef4da1872854e36ebeb258e3d57594", size = 222129, upload-time = "2026-07-31T11:30:31.489Z" },
{ url = "https://files.pythonhosted.org/packages/1b/86/e8806a99ec4589914f255e6b658853fe537bf359c05e6ba5762ad9c27917/websockets-16.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a0285df7925657ad65a65fb8dc330808bce082827538fd50ef45fa12d1fc5bca", size = 188614, upload-time = "2026-07-10T06:32:14.236Z" }, { url = "https://files.pythonhosted.org/packages/35/a5/e1b57a59da92ade37fd021567a17b518ea8267b28e5530075844cdb525fe/websockets-17.0.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9f7747d3daa41a11f25f7cca5dc988fc51da97b311bed4c9d843860f79779283", size = 220875, upload-time = "2026-07-31T11:30:32.805Z" },
{ url = "https://files.pythonhosted.org/packages/89/38/ac554e2fc6ff0b8deeff9798b92e7abd8f99e2bd9731532e7033de208220/websockets-16.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:82d1c2cab3c133e9d059b3a5420bed9376bd30e21c185c63dda4ddadf6ddda47", size = 186165, upload-time = "2026-07-10T06:32:15.626Z" }, { url = "https://files.pythonhosted.org/packages/f0/30/e7d0889c790a854156de424575fd67af79ddbaed9ff3157ae863dfd1c1dc/websockets-17.0.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2abb1ba0a5133b7d2ef3c1c9f4b0c1e8a101012dce0b594ab2b2888d9a64820e", size = 218160, upload-time = "2026-07-31T11:30:34.512Z" },
{ url = "https://files.pythonhosted.org/packages/6c/c5/4ef4d8e53342f94f3c49e1ae089b32c1e8b3878e15e0022c7708c647f351/websockets-16.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c39907f1eaf11f6277def65aa02d68f30576b693d0c1ca332aafa3caa723ac6d", size = 187119, upload-time = "2026-07-10T06:32:17.114Z" }, { url = "https://files.pythonhosted.org/packages/09/2e/43db785d6ed9ae7594fae7b62bbc9cb4dfee2b015e06a1005f1e5ce283b6/websockets-17.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f3fd9a1f87f8f0f3f8e9f9bd0195f7516562d13f5b178db8c5784d1f60b60bed", size = 220951, upload-time = "2026-07-31T11:30:35.806Z" },
{ url = "https://files.pythonhosted.org/packages/3a/33/4788b1dd417bd97eeb2698af3b9df6775ac656f96e9987da0419a067602f/websockets-16.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:45c5ea55446171949eb99fd34b771ceddd511ca21958d40d0197ced33159e5ee", size = 187411, upload-time = "2026-07-10T06:32:18.629Z" }, { url = "https://files.pythonhosted.org/packages/eb/f5/4ac3cab3d5e8a830657a822f64a8910e3803229c6783e59c3fd9a3487427/websockets-17.0.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2bc14b481e05e331811108daa1aeb41a5e237a5564ef2f02ec5a356a0f102f78", size = 219460, upload-time = "2026-07-31T11:30:37.273Z" },
{ url = "https://files.pythonhosted.org/packages/30/38/00d37aad6dc3244ce349e2864815362e50b3cfc00cac28d216db20efe40f/websockets-16.1-cp314-cp314-win32.whl", hash = "sha256:b8ef8b1c8d6bd029a475ac432e730fba2dfd456715d26c473e2a82291024b99c", size = 179822, upload-time = "2026-07-10T06:32:20.233Z" }, { url = "https://files.pythonhosted.org/packages/03/0e/c3a4020673ffc17c82cf1a467835038a196a555d3b4f2a50f0f063cf8ccc/websockets-17.0.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:57d2ee9b24b404ce75f3814f92073c0ed88106c950148d2427fe8d25ca254d1f", size = 220248, upload-time = "2026-07-31T11:30:38.527Z" },
{ url = "https://files.pythonhosted.org/packages/9d/37/2a8cb0eaddee5eaebda47a90a3ba0898d1ce3d866b02a4857fea17d82e5b/websockets-16.1-cp314-cp314-win_amd64.whl", hash = "sha256:7358ff21632b5d062707f73e859c824f1c3807e73d8ca25e71caca7c4cdcf145", size = 180167, upload-time = "2026-07-10T06:32:21.749Z" }, { url = "https://files.pythonhosted.org/packages/7d/87/e47a6a278cc1dfade38444c893ce18322943c25d4b780a74450d9d164be1/websockets-17.0.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1b363bfd72a52c0658a3154a4cff219f15a474b35a235057d38853bf151acce7", size = 221421, upload-time = "2026-07-31T11:30:39.879Z" },
{ url = "https://files.pythonhosted.org/packages/07/5a/262ad5fcaef4198997b165060f09a63f861e76939b1786ab546ccc3f8120/websockets-16.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d0f38f4c3e9b359e257c339c2cc1967ccaeedb102e57c1c986bdce4bf4f32268", size = 180166, upload-time = "2026-07-10T06:32:23.278Z" }, { url = "https://files.pythonhosted.org/packages/da/8f/473d5fc4e3836e375b0233c6ef26777e6e5e3f7bfc84ccd524eae4090ed5/websockets-17.0.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:10b1587c599fa0f2c89154587c80e0fda98ade6c9fa8c0260a2823fb1800b685", size = 218975, upload-time = "2026-07-31T11:30:41.192Z" },
{ url = "https://files.pythonhosted.org/packages/1a/c7/36377db690f4292826e4501a6dec2801dc55fd1cf0405923b04937e478df/websockets-16.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:3c3d2cbd1602593bad49bd86fa3fbb25407d87a3b4bf8857c0ac5ac4914e1901", size = 177697, upload-time = "2026-07-10T06:32:25.164Z" }, { url = "https://files.pythonhosted.org/packages/d4/b9/819ec2dcdf69031d7e9cab11247f3a6ff9bbc8c7c53ada1dbcb9055b227b/websockets-17.0.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d7d72843691f50b91127c50688df10cb72ec6f4c4b1d7e2c11ab33b16acf8e51", size = 219925, upload-time = "2026-07-31T11:30:42.524Z" },
{ url = "https://files.pythonhosted.org/packages/fa/c7/07171abce1e39799a76f473608580fe98bd43a1230f5146159622c02bccf/websockets-16.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:36069b74671e7e667f48a7484249f84c45a825a134c8b1bdc01875d0daa10d79", size = 177902, upload-time = "2026-07-10T06:32:26.564Z" }, { url = "https://files.pythonhosted.org/packages/85/b9/6c0da301f6118502e079cf92f4e864adf28e56b3f8c0f6085076ced7b876/websockets-17.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:90973a3a00f23afdfd1c9b06fb84289bf0220f247ef8a62501a1967c7af54f7b", size = 220218, upload-time = "2026-07-31T11:30:44.04Z" },
{ url = "https://files.pythonhosted.org/packages/14/17/c831f48e250bc4749f57c00dcce73337c41cd32f6d59a64567b84e782601/websockets-16.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:587f83c2ce8a5d628e166384d77fa7f0ac69b9007d515ab442123e6615aa8da3", size = 187766, upload-time = "2026-07-10T06:32:27.981Z" }, { url = "https://files.pythonhosted.org/packages/55/f9/cba32dc9dd856565263d6272f594255bd0e2781deb8cd982c026a54760ad/websockets-17.0.1-cp314-cp314-win32.whl", hash = "sha256:599b03beb77633bffc095334338fad79cafc2b01fbd58953838130a9ae967d7b", size = 212626, upload-time = "2026-07-31T11:30:45.579Z" },
{ url = "https://files.pythonhosted.org/packages/2c/2e/4dfe63e245b0ecfaf470cf082d25c6ce35808159135fd88c82653a6b11ab/websockets-16.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6db7972d52bc1b66cefe2246902e256cbaebc9ba8a45eac09343d7eb6671b2", size = 188939, upload-time = "2026-07-10T06:32:29.365Z" }, { url = "https://files.pythonhosted.org/packages/fa/95/91cdd8c192287d7ea741f37cf7d64fdc1a14410f06f73805e428a1a590af/websockets-17.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:81ce19c6046ace11da7001781be7317bb1dc389f399af4b2ed962190f76f9add", size = 212969, upload-time = "2026-07-31T11:30:46.983Z" },
{ url = "https://files.pythonhosted.org/packages/ba/e5/5faf65aebd9562f6b4bc473d24ce38cc56f84eb5f5bee66ed9b86733f93c/websockets-16.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e7d6014888a0632e1ed7a4095248bb3095232999447f2d83bfb1900987dd9ed9", size = 191081, upload-time = "2026-07-10T06:32:30.868Z" }, { url = "https://files.pythonhosted.org/packages/8e/fd/8c98a1e431960661c5769ab1a4dd66494e87ab02d791cc79e51e0d9a289f/websockets-17.0.1-cp314-cp314-win_arm64.whl", hash = "sha256:efe0ae052a8d023b87198921e8a7ce1dc7768816bcd2fbc20df171ac73a04891", size = 212850, upload-time = "2026-07-31T11:30:48.304Z" },
{ url = "https://files.pythonhosted.org/packages/49/cd/2634f2f2c0556c1aae6501ed6840019cc569dd6fdbcac6494378daea4dc0/websockets-16.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9cb074d150e4ad2a77aa8a332c2be85f3f64f2681519d2570c1225c12c9821ff", size = 189513, upload-time = "2026-07-10T06:32:32.399Z" }, { url = "https://files.pythonhosted.org/packages/13/c1/142f5186ee7dc3beee0426b998a79e223e067b7689afcaa95890d64aa800/websockets-17.0.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ab56439c9f74c52770690c7b2f616b3bf775cb3920453ee355ac765c032d8bbf", size = 212967, upload-time = "2026-07-31T11:30:49.688Z" },
{ url = "https://files.pythonhosted.org/packages/59/bb/2c700b51196104f09715b326b1f092ed25326bdf79a03e00a4842e503743/websockets-16.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d19c9067e1fe9490f974bffbc0e443b80a7674c5efb4980c429cc00771f07c5a", size = 188240, upload-time = "2026-07-10T06:32:33.897Z" }, { url = "https://files.pythonhosted.org/packages/02/de/4b03ed316c9dee180365286c298219809ff247be39beeaaf9958b21167ab/websockets-17.0.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:20a92f78ac8250984ed459faa9ca48c285adbfc0038ddc3fdac6046990a9c9ed", size = 210504, upload-time = "2026-07-31T11:30:50.987Z" },
{ url = "https://files.pythonhosted.org/packages/f1/20/86283636e499a1a357fa9441f690ba34f255e731f2fea174132b3b762b57/websockets-16.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d440ff0c6c7469ad59c0a412c383c235935b43635e89425e3f6a0c36de90c31b", size = 185955, upload-time = "2026-07-10T06:32:35.279Z" }, { url = "https://files.pythonhosted.org/packages/cd/d8/ad2b3e8f867e1e8cac3077e2f33ffb60b71bd763d6cfc71bd916f113c3bf/websockets-17.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6a434e59962a4fb9016bea327e1d14d6cd67670ecfb8942b4f4a0c24036634ce", size = 210702, upload-time = "2026-07-31T11:30:52.261Z" },
{ url = "https://files.pythonhosted.org/packages/91/23/d7fb734b0095d43bc7f1c9f68afd50adb4176e7e513403e8c70ad7daa4fa/websockets-16.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8613129a2533f08de24505e69a3e403cedaadae49abdb043c4d170ca71b7e4bd", size = 188491, upload-time = "2026-07-10T06:32:36.673Z" }, { url = "https://files.pythonhosted.org/packages/48/18/7a77a82ce9d6f831c07b176da3942f7e71acd0f115f3ecdb1d00a040eb01/websockets-17.0.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2503c7e2a5049a12d5dac917a46d5d52591283a766165b8176bb167560421b38", size = 220290, upload-time = "2026-07-31T11:30:53.582Z" },
{ url = "https://files.pythonhosted.org/packages/6a/5e/168a192689db468405ecf3b8e4a2c18811936b0724d017ad7e6d252734f0/websockets-16.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:a5bf9c23f197b4ec88290fd5463f33db67362a1bb10f85fc2e8e7627f0ddab97", size = 186983, upload-time = "2026-07-10T06:32:38.207Z" }, { url = "https://files.pythonhosted.org/packages/f5/af/43c3e3c3ea7ba4693c2181743f3221957df28bededadcbd9fc8a0661bde0/websockets-17.0.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:28012a54510fe8301bb893ef143cec30a2780a2d3bc20b7bbdf4379d7a63945d", size = 220573, upload-time = "2026-07-31T11:30:54.966Z" },
{ url = "https://files.pythonhosted.org/packages/7e/9b/66795fa91ebe49019ebe4fa910282172252e37046b80e08fc52e0c365150/websockets-16.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:520b0fd0395f075febb283c76755af724ab9fd19dffa4f3bfd18cb4e622790a3", size = 188890, upload-time = "2026-07-10T06:32:39.545Z" }, { url = "https://files.pythonhosted.org/packages/1f/bd/ed48eca15725743ee7e2dc172e15c61de29e85ec98dace7b14257f366836/websockets-17.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22bd00f8bae2bccdb5dbe41e20f58ba44ca9fff0b4b561aaf39099c35da762ed", size = 221747, upload-time = "2026-07-31T11:30:56.762Z" },
{ url = "https://files.pythonhosted.org/packages/5a/32/126bbc844be5afb3613fd43211dac10a9645f4cf39741d04acaa2ec7030c/websockets-16.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:7143aa09a67e1c013be44e81a88dfe90fc6244198ab86c7edd064152cf619805", size = 186583, upload-time = "2026-07-10T06:32:41.038Z" }, { url = "https://files.pythonhosted.org/packages/99/50/838deb7937a8225c4925dd4a977eafea473fabf444178a99de0bc7e92bb0/websockets-17.0.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e98ec9ec61cce5bc4b8b218322ad090b0994eb060bb04da704c62ef0a3d864e6", size = 223891, upload-time = "2026-07-31T11:30:58.127Z" },
{ url = "https://files.pythonhosted.org/packages/22/b9/0b5db9cbcf6e4970db4496893244a8d92e07f71a8ef27cf34b08aa02fef1/websockets-16.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:7acb811fad08e611755800d1560e395c67e11a6bd563598ea6abb319afb86938", size = 187353, upload-time = "2026-07-10T06:32:42.501Z" }, { url = "https://files.pythonhosted.org/packages/f8/e9/657fb70c6eb6bcd01adfa5d2b06496e9911e1c1a8813d353b8c00f7591cd/websockets-17.0.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8e387adb0c692c6b5571bdeafc8ac9d1901ea30f10309134780b16ecd35e6605", size = 222317, upload-time = "2026-07-31T11:30:59.416Z" },
{ url = "https://files.pythonhosted.org/packages/99/2e/254b2131a10d831b76e2c18dfe7add9729c6292c674a8085bf8de01ad151/websockets-16.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c5cf88e3faa2f7931bc6baeee7599c97656a3f6ac7f831f4fccba233e141783a", size = 187784, upload-time = "2026-07-10T06:32:43.929Z" }, { url = "https://files.pythonhosted.org/packages/07/4c/82cb722afa5428fed981331210c4c07600570db01bb1620579f655b5adaf/websockets-17.0.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd1470d2c53fe53269bf5619da7725d30dd9b9693f1689f7a85eab8dea734442", size = 221047, upload-time = "2026-07-31T11:31:00.757Z" },
{ url = "https://files.pythonhosted.org/packages/21/dc/e7288aa8e3ac5a88a0924619984d663c1abf2a87d0ea98290c66fdaee0ec/websockets-16.1-cp314-cp314t-win32.whl", hash = "sha256:589f8842521c8307684ce0b40ce4ad70c5e0aa46484c6f1225a94ef4b8970341", size = 179947, upload-time = "2026-07-10T06:32:45.495Z" }, { url = "https://files.pythonhosted.org/packages/90/84/bd6d67d6bc65f0de0cb50de55dab42f256a9876a351c4736522eb168fda0/websockets-17.0.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:884af729b8ab50486acd94d9768c2b60914bf39b579ebba0a5cb73bfdfd61fd2", size = 218626, upload-time = "2026-07-31T11:31:02.48Z" },
{ url = "https://files.pythonhosted.org/packages/d3/de/37edf1260ff0fbbd2f82433489c4cfbe799ac2ff21355331609879329fe6/websockets-16.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c0e0857c30bbbc2bb5c30687508f0b7ec19aa026cd9f2ff8424d0fee42dcc07", size = 180291, upload-time = "2026-07-10T06:32:47.119Z" }, { url = "https://files.pythonhosted.org/packages/96/cb/6a372c8553976f0d8f97f5115826ed47e34b3be6b8bf0d0249af249a7416/websockets-17.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a60fa1a25cca1bcc2bf87b8d6be37a741f0a3239fb5e9cfb7a37173b68ffcf87", size = 221299, upload-time = "2026-07-31T11:31:03.795Z" },
{ url = "https://files.pythonhosted.org/packages/66/58/bd83247f39ddc26ffc2c24eb05087a3b749e00cb4509fc6d19daa23c8495/websockets-16.1-py3-none-any.whl", hash = "sha256:c5149dfe490ec7e5ee5dbf624c642fb725f93a5575c7f00ab594ca9eddb8dd81", size = 174031, upload-time = "2026-07-10T06:32:56.079Z" }, { url = "https://files.pythonhosted.org/packages/bc/31/f966e8472337974f74d788b3ef6c6f3b8b9a5f201efd16a843c91d269fa5/websockets-17.0.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:e8208f2729cba030ff872a92064c97584eeb9502f53d32a05a0f05d5a17ca6c6", size = 219789, upload-time = "2026-07-31T11:31:05.08Z" },
{ url = "https://files.pythonhosted.org/packages/57/34/404e83a6cc7b0efcac810b7041bffd72ff76900e6fd0aa45a26c92fb2ffe/websockets-17.0.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:54cdcaa56f5d3eafd57058f0fa4a3de93a310b43a3c4699f06efc4c0bd054a5a", size = 220678, upload-time = "2026-07-31T11:31:06.635Z" },
{ url = "https://files.pythonhosted.org/packages/e5/70/8946188c2a68d67251859b589a3634918cf7867bf0b891347a5ecaa43d30/websockets-17.0.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4d41c0a1d47a478bc432b3b9068097bee1ce0c5b19327ea6f75c2ab34ab1f2fb", size = 221697, upload-time = "2026-07-31T11:31:08.023Z" },
{ url = "https://files.pythonhosted.org/packages/04/16/ee73fc2083a2938ac6209f4ec804960496835b20a0068dbcfe8424957c04/websockets-17.0.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f991247276797d0c61ab7770bc9791eadc16f683b4d83517f624932adc1a8bab", size = 219390, upload-time = "2026-07-31T11:31:09.378Z" },
{ url = "https://files.pythonhosted.org/packages/94/6a/d5f88033c69932af6cdaa72da62516ade47c257e3bf69f4c0ba5f40e12a2/websockets-17.0.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:733e3cc7171fa1b899edbe725ef9382d0e960657dc1fd933f3281ae910c01dab", size = 220161, upload-time = "2026-07-31T11:31:10.915Z" },
{ url = "https://files.pythonhosted.org/packages/e1/2e/6183dd2c0370287ecf4afe0bb33aca364208e5e7b0e1a286adcaecc0c78b/websockets-17.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:810cb3fb5fa6e447216f4e82d9a85cb8aed0929ae3538153ddfe8a6e3121a58d", size = 220591, upload-time = "2026-07-31T11:31:12.289Z" },
{ url = "https://files.pythonhosted.org/packages/26/93/70f6516d85b9744f7eac224c4b1b9ef4e84133f80b53be02080cb1c3e663/websockets-17.0.1-cp314-cp314t-win32.whl", hash = "sha256:17ac37716c0244e82c9e384c41653c090b1864c6610224ca3857e7f7b58fce10", size = 212755, upload-time = "2026-07-31T11:31:13.883Z" },
{ url = "https://files.pythonhosted.org/packages/f1/2c/9d9c1da5a7ea9af307b386d25f64d1dead4729644198d2b92e36db5dfd41/websockets-17.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:bb31f42ea095ea826463c770829aa188a86c9a5c976b1467cbbf583c811de833", size = 213094, upload-time = "2026-07-31T11:31:15.367Z" },
{ url = "https://files.pythonhosted.org/packages/5b/24/a585e7573e128070605d003b5544729bcd58d9756c7e99d550818ca4b916/websockets-17.0.1-cp314-cp314t-win_arm64.whl", hash = "sha256:dbfae8e75b342e31fc6fd1a8bbb393b7cbb91d6cfd581650300a94381e7b7e2b", size = 213009, upload-time = "2026-07-31T11:31:16.776Z" },
{ url = "https://files.pythonhosted.org/packages/09/ce/3929538b2b9918f5eee623fbf3346893973191f6df93f19bbda097bd7bb7/websockets-17.0.1-py3-none-any.whl", hash = "sha256:c6be9cba65c65cc76dfa3d4619e359ff02a4476c74e179b215236c11a0b32345", size = 206718, upload-time = "2026-07-31T11:31:26.037Z" },
] ]
[[package]] [[package]]
@@ -1095,67 +1171,67 @@ wheels = [
[[package]] [[package]]
name = "yarl" name = "yarl"
version = "1.24.2" version = "1.24.5"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "idna" }, { name = "idna" },
{ name = "multidict" }, { name = "multidict" },
{ name = "propcache" }, { name = "propcache" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/79/12/1e8f37460ea0f7eb59c221fdaf0ed75e7ac43e97f8093b9c6f411df50a78/yarl-1.24.2.tar.gz", hash = "sha256:9ac374123c6fd7abf64d1fec93962b0bd4ee2c19751755a762a72dd96c0378f8", size = 210798, upload-time = "2026-05-19T21:31:05.599Z" } sdist = { url = "https://files.pythonhosted.org/packages/31/33/ebe9e3d1f86c7a0b51094c0a146392045ca1631d2664889539dec8088a33/yarl-1.24.5.tar.gz", hash = "sha256:e81b83143bee16329c23db3c1b2d82b29892fcbcb849186d2f6e98a5abe9a57f", size = 228679, upload-time = "2026-07-20T02:07:45.435Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/82/62/fcf0ce677f17e5c471c06311dd25964be38a4c586993632910d2e75278bc/yarl-1.24.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:491ac9141decf49ee8030199e1ee251cdff0e131f25678817ff6aa5f837a3536", size = 128978, upload-time = "2026-05-19T21:29:23.83Z" }, { url = "https://files.pythonhosted.org/packages/e1/63/64ef361967cc983573149dc1515d531db5da8a4c92d22bb833d59e01b313/yarl-1.24.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:79af890482fc94648e8cde4c68620378f7fef60932710fa17a66abc039244da2", size = 135075, upload-time = "2026-07-20T02:05:59.671Z" },
{ url = "https://files.pythonhosted.org/packages/d3/58/8e63299bb71ed61a834121d9d3fe6c9fcf2a6a5d09754ff4f20f2d20baf5/yarl-1.24.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e89418f65eda18f99030386305bd44d7d504e328a7945db1ead514fbe03a0607", size = 91733, upload-time = "2026-05-19T21:29:25.375Z" }, { url = "https://files.pythonhosted.org/packages/bb/89/55920fd853ce43e608adbc3962456f0d649d6bb15250dc2988321da0fe1c/yarl-1.24.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:46c2f213e23a04b93a392942d782eb9e413e6ef6bf7c8c53884e599a5c174dcb", size = 97225, upload-time = "2026-07-20T02:06:01.769Z" },
{ url = "https://files.pythonhosted.org/packages/c1/24/16748d5dab6daec8b0ed81ccec639a1cded0f18dcc62a4f696b4fe366c37/yarl-1.24.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cdfcce633b4a4bb8281913c57fcafd4b5933fbc19111a5e3930bbd299d6102f1", size = 91113, upload-time = "2026-05-19T21:29:26.928Z" }, { url = "https://files.pythonhosted.org/packages/15/f0/7688d3f2cfff7590df2af38ec46d969f4281a4dddb08a9ad2eafbcdddf98/yarl-1.24.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92ab3e11448f2ff7bf53c5a26eff0edc086898ec8b21fb154b85839ce1d88075", size = 96751, upload-time = "2026-07-20T02:06:03.676Z" },
{ url = "https://files.pythonhosted.org/packages/1b/66/b63fff7b71211e866624b21432d5943cbb633eb0c2872d9ee3070648f22c/yarl-1.24.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:863297ddede92ee49024e9a9b11ecb59f310ca85b60d8537f56bed9bbb5b1986", size = 103899, upload-time = "2026-05-19T21:29:28.842Z" }, { url = "https://files.pythonhosted.org/packages/05/1a/a851a0f94aaaf379dd4f901bfc80f634280bec51eb260b47363e2a4cd62e/yarl-1.24.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ebb0ec7f17803063d5aeb982f3b1bd2b2f4e4fae6751226cbd6ba1fcfe9e63ff", size = 107960, upload-time = "2026-07-20T02:06:05.699Z" },
{ url = "https://files.pythonhosted.org/packages/9d/ac/ba1974b8533909636f7733fe86cf677e3619527c3c2fa913e0ea89c48757/yarl-1.24.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:374423f70754a2c96942ede36a29d37dc6b0cb8f92f8d009ddf3ed78d3da5488", size = 97862, upload-time = "2026-05-19T21:29:31.086Z" }, { url = "https://files.pythonhosted.org/packages/6c/a8/faea066c12f9c77ca0de90641f1655f9dd7b412477bf28c76d692f3aecff/yarl-1.24.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:82632daed195dcc8ea664e8556dc9bdbd671960fb3776bd92806ce05792c2448", size = 103500, upload-time = "2026-07-20T02:06:07.556Z" },
{ url = "https://files.pythonhosted.org/packages/1b/a5/123ac993b5c2ba6f554a140305620cb8f150fa543711bbc49be3ec0a65a4/yarl-1.24.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33a29b5d00ccbf3219bb3e351d7875739c19481e030779f48cc46a7a71681a9b", size = 111060, upload-time = "2026-05-19T21:29:32.657Z" }, { url = "https://files.pythonhosted.org/packages/fb/9c/1e67084c2a6e2f2db0e3be798328cb3be42c0119b621d25461479a224d21/yarl-1.24.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:53e549287ef628fecba270045c9701b0c564563a9b0577d24a4ec75b8ab8040f", size = 115780, upload-time = "2026-07-20T02:06:09.599Z" },
{ url = "https://files.pythonhosted.org/packages/23/37/c472d3af3509688392134a88a825276770a187f1daa4de3f6dc0a327a751/yarl-1.24.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a9532c57211730c515341af11fef6e9b61d157487272a096d0c04da445642592", size = 110613, upload-time = "2026-05-19T21:29:34.379Z" }, { url = "https://files.pythonhosted.org/packages/58/86/1f94664e147474337e3359f52012cf3d02f825f694317b178bfba1078c62/yarl-1.24.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fcd3b77e2f17bbe4ca56ec7bcb07992647d19d0b9c05d84886dcd6f9eb810afd", size = 115308, upload-time = "2026-07-20T02:06:11.352Z" },
{ url = "https://files.pythonhosted.org/packages/df/88/09c28dad91e662ccfaa1b78f1c57badde74fc9d0b23e74aef644750ecd73/yarl-1.24.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:91e72cf093fd833483a97ee648e0c053c7c629f51ff4a0e7edd84f806b0c5617", size = 107012, upload-time = "2026-05-19T21:29:36.216Z" }, { url = "https://files.pythonhosted.org/packages/0a/43/8e55ae7538ba5f28ccb3c845c6dd4549cf7016d5992e5326512519107cdd/yarl-1.24.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d46b86567dd4e248c6c159fcbcdcce01e0a5c8a7cd2334a0fff759d0fa075b16", size = 110574, upload-time = "2026-07-20T02:06:13.129Z" },
{ url = "https://files.pythonhosted.org/packages/07/ab/9d4f69d571a94f4d112fa7e2e007200f5a54d319f58c82ac7b7baa61f5c6/yarl-1.24.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b3177bc0a768ef3bacceb4f272632990b7bea352f1b2f1eee9d6d6ff16516f92", size = 105887, upload-time = "2026-05-19T21:29:38.746Z" }, { url = "https://files.pythonhosted.org/packages/ce/ba/a889ec8765cedcf2ac44dcb02d6a21e4861399b243b263c5f2dde27ee740/yarl-1.24.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f72c74aa99359e27a2ee8d6613fefa28b5f76a983c083074dfc2aaa4ab46213", size = 109914, upload-time = "2026-07-20T02:06:15.243Z" },
{ url = "https://files.pythonhosted.org/packages/8e/9a/000b2b66c0d772a499fc531d21dab92dfeb73b640a12eed6ba89f49bb2d0/yarl-1.24.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e196952aacaf3b232e265ff02980b64d483dc0972bd49bcb061171ff22ac203a", size = 103620, upload-time = "2026-05-19T21:29:40.368Z" }, { url = "https://files.pythonhosted.org/packages/9c/c3/e45f821af67b791c2dbbe4a9f4137a1d33f8d386654a05a0c3f47bdfa25d/yarl-1.24.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3f45789ce415a7ec0820dc4f82925f9b5f7732070be1dec1f5f23ec381435a24", size = 107712, upload-time = "2026-07-20T02:06:17.443Z" },
{ url = "https://files.pythonhosted.org/packages/41/7c/7c1050f73450fbdaa3f0c72017059f00ce5e13366692f3dba25275a1083d/yarl-1.24.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:204e7a61ce99919c0de1bf904ab5d7aa188a129ea8f690a8f76cfb6e2844dc44", size = 100599, upload-time = "2026-05-19T21:29:42.66Z" }, { url = "https://files.pythonhosted.org/packages/02/00/2ab0f42c9857fcb490bfaa6647b14540b53d241ab209f23220b958cc5832/yarl-1.24.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:6e73e7fe93f17a7b191f52ec9da9dd8c06a8fe735a1ecbd13b97d1c723bff385", size = 104251, upload-time = "2026-07-20T02:06:19.259Z" },
{ url = "https://files.pythonhosted.org/packages/ec/b1/29e5756b3926705f5f6089bd5b9f50a56eaac550da6e260bf713ead44d04/yarl-1.24.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b156914620f0b9d78dc1adb3751141daee561cfec796088abb89ed49d220f1a", size = 110604, upload-time = "2026-05-19T21:29:44.632Z" }, { url = "https://files.pythonhosted.org/packages/7a/70/709d9a286e98af2c7fd8e4e6cada658b5c0e30d87dd7e2a63c2fb5767217/yarl-1.24.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4a36f9becdd4c5c52a20c3e9484128b070b1dcfc8944c006f3a528295a359a9c", size = 115319, upload-time = "2026-07-20T02:06:21.207Z" },
{ url = "https://files.pythonhosted.org/packages/a3/4b/8415bc96e9b150cde942fbac9a8182985e58f40ce5c54c34ed015407d3ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8372a2b976cf70654b2be6619ab6068acabb35f724c0fda7b277fbf53d66a5cf", size = 105161, upload-time = "2026-05-19T21:29:46.755Z" }, { url = "https://files.pythonhosted.org/packages/5c/6c/3eaa515142991fe84cfc483ff986492211f1978f90161ccefdbec919d09b/yarl-1.24.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:7bcbe0fcf850eae67b6b01749815a4f7161c560a844c769ad7b48fcd99f791c4", size = 109163, upload-time = "2026-07-20T02:06:23.006Z" },
{ url = "https://files.pythonhosted.org/packages/8b/d4/cde059abfa229553b7298a2eadde2752e723d50aeedaef86ce59da2718ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f9a1e9b622ca284143aab5d885848686dcd85453bb1ca9abcdb7503e64dc0056", size = 110619, upload-time = "2026-05-19T21:29:48.972Z" }, { url = "https://files.pythonhosted.org/packages/bb/64/711dafce66c323a3144d470547a71c5384c57623308ac8bb5e4b903ac148/yarl-1.24.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:24e861e9630e0daddcb9191fb187f60f034e17a4426f8101279f0c475cd74144", size = 115435, upload-time = "2026-07-20T02:06:24.923Z" },
{ url = "https://files.pythonhosted.org/packages/e7/2c/d6a6c9a61549f7b6c7e6dc6937d195bcf069582b47b7200dcd0e7b256acf/yarl-1.24.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:810e19b685c8c3c5862f6a38160a1f4e4c0916c9390024ec347b6157a45a0992", size = 107362, upload-time = "2026-05-19T21:29:51Z" }, { url = "https://files.pythonhosted.org/packages/cf/f3/9b9d0e6d84bea851eb1ba99e4bdc755b86fd813e49ec86dfe42f26befdef/yarl-1.24.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9335a099ad87287c37fe5d1a982ff392fa5efe5d14b40a730b1ec1d6a41382b4", size = 110691, upload-time = "2026-07-20T02:06:26.973Z" },
{ url = "https://files.pythonhosted.org/packages/92/dd/3ae5fe417e9d1c353a548553326eb9935e76b6b727161563b424cc296df3/yarl-1.24.2-cp313-cp313-win_amd64.whl", hash = "sha256:7d37fb7c38f2b6edab0f845c4f85148d4c44204f52bc127021bd2bc9fdbf1656", size = 92667, upload-time = "2026-05-19T21:29:52.743Z" }, { url = "https://files.pythonhosted.org/packages/86/e4/62a06b7e87c4246ac76b7c2da136f972eb4a3a1fc94abb07e7022d6fdb0a/yarl-1.24.5-cp313-cp313-win_amd64.whl", hash = "sha256:2dbe06fc16bc91502bca713704022182e5729861ae00277c3a23354b40929740", size = 97454, upload-time = "2026-07-20T02:06:29.163Z" },
{ url = "https://files.pythonhosted.org/packages/10/cc/a7beb239f78f27fca1b053c8e8595e4179c02e62249b4687ec218c370c50/yarl-1.24.2-cp313-cp313-win_arm64.whl", hash = "sha256:1e831894be7c2954240e49791fa4b50c05a0dc881de2552cfe3ffd8631c7f461", size = 87069, upload-time = "2026-05-19T21:29:54.442Z" }, { url = "https://files.pythonhosted.org/packages/9e/c9/5fc8025b318ab10db413b61056bd0d95c557a70e8df4210c7511f866329c/yarl-1.24.5-cp313-cp313-win_arm64.whl", hash = "sha256:6b8536851f9f65e7f00c7a1d49ba7f2be0ffe2c11555367fc9f50d9f842410a1", size = 92813, upload-time = "2026-07-20T02:06:31.113Z" },
{ url = "https://files.pythonhosted.org/packages/40/0e/e08087695fc12789263821c5dc0f8dc52b5b17efd0887cacf419f8a43ba3/yarl-1.24.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:f9312b3c02d9b3d23840f67952913c9c8721d7f1b7db305289faefa878f364c2", size = 129670, upload-time = "2026-05-19T21:29:56.631Z" }, { url = "https://files.pythonhosted.org/packages/a9/08/5f3085fef9564217074db9dd8573de1795bc82cde61a7ad10b6a7234a569/yarl-1.24.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2729fcfc4f6a596fb0c50f32090400aa9367774ac296a00387e65098c0befa76", size = 135680, upload-time = "2026-07-20T02:06:33.273Z" },
{ url = "https://files.pythonhosted.org/packages/3a/98/ab4b5ed1b1b5cd973c8a3eb994c3a6aefb6ce6d399e21bb5f0316c33815c/yarl-1.24.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a4f4d6cd615823bfc7fb7e9b5987c3f41666371d870d51058f77e2680fbe9630", size = 91916, upload-time = "2026-05-19T21:29:58.645Z" }, { url = "https://files.pythonhosted.org/packages/98/35/ba9436e579bd48a8801f2021d842d9ab4994c26e4c7dd3a4c1f1bcb57a9e/yarl-1.24.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ff330d3c30db4eb6b01d79e29d2d0b407a7ecad39cfd9ec993ece57396a2ec0d", size = 97395, upload-time = "2026-07-20T02:06:35.259Z" },
{ url = "https://files.pythonhosted.org/packages/ba/b1/5297bb6a7df4782f7605bffc43b31f5044070935fbbcaa6c705a07e6ac65/yarl-1.24.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0c3063e5c0a8e8e62fae6c2596fa01da1561e4cd1da6fec5789f5cf99a8aefd8", size = 91625, upload-time = "2026-05-19T21:30:00.412Z" }, { url = "https://files.pythonhosted.org/packages/18/a9/a07f76f3c44e02b25cc743af5ef93eef27f7013eadca770451b6a6ccb5db/yarl-1.24.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e42d75862735da90e7fc5a7b23db0c976f737113a54b3c9777a9b665e9cbff75", size = 97223, upload-time = "2026-07-20T02:06:37.216Z" },
{ url = "https://files.pythonhosted.org/packages/02/a7/45baabfff76829264e623b185cff0c340d7e11bf3e1cd9ea37e7d17934bd/yarl-1.24.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fecd17873a096036c1c87ab3486f1aef7f269ada7f23f7f856f93b1cc7744f14", size = 104574, upload-time = "2026-05-19T21:30:02.544Z" }, { url = "https://files.pythonhosted.org/packages/77/f7/a9a1d6fa7dd9e388f95b30f6ad3ec4e285f6c8f61f44ce16070c3fcfe414/yarl-1.24.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3732e66413163e72508da9eff9ce9d2846fde51fae45d3605393d3e6cd303e9", size = 108777, upload-time = "2026-07-20T02:06:39.292Z" },
{ url = "https://files.pythonhosted.org/packages/f3/40/3a5ab144d3d650ca37d4f4b57e56169be8af3ca34c448793e064b30baaed/yarl-1.24.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a46d1ab4ba4d32e6dc80daf8a28ce0bd83d08df52fbc32f3e288663427734535", size = 97534, upload-time = "2026-05-19T21:30:04.319Z" }, { url = "https://files.pythonhosted.org/packages/2f/44/e0b86c302471fabd6f02808ecf2ac52b8412b624787849d4bf2cdb466f6f/yarl-1.24.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5b8ee53be440a0cffc991a27be3057e0530122548dbe7c0892df08822fce5ede", size = 103119, upload-time = "2026-07-20T02:06:41.456Z" },
{ url = "https://files.pythonhosted.org/packages/9c/b5/5658fef3681fb5776b4513b052bec750009f47b3a592251c705d75375798/yarl-1.24.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73e68edf6dfd5f73f9ca127d84e2a6f9213c65bdffb736bda19524c0564fcd14", size = 111481, upload-time = "2026-05-19T21:30:05.988Z" }, { url = "https://files.pythonhosted.org/packages/d1/16/9c16d180bf8faaf223225eb50e1245870ff1ae0e302a27153988e65c51fd/yarl-1.24.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:af3aefa655adb5869491fa907e652290386800ae99cc50095cba71e2c6aefdca", size = 116471, upload-time = "2026-07-20T02:06:43.696Z" },
{ url = "https://files.pythonhosted.org/packages/4c/06/fdcd7dde037f00866dce123ed4ba23dba94beb56fc4cf561668d27be37f2/yarl-1.24.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a296ca617f2d25fbceafb962b88750d627e5984e75732c712154d058ae8d79a3", size = 111529, upload-time = "2026-05-19T21:30:07.738Z" }, { url = "https://files.pythonhosted.org/packages/d2/8d/b219b9df28a02ce95cfbdd41d2f7caa5669d0ff979c1c9975697145e33c5/yarl-1.24.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2120b96872df4a117cde97d270bac96aea7cc52205d305cf4611df694a487027", size = 115974, upload-time = "2026-07-20T02:06:45.874Z" },
{ url = "https://files.pythonhosted.org/packages/c2/53/d81269aaafccea0d33396c03035de997b743f11e648e6e27a0df99c72980/yarl-1.24.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51b2cf5ec89a8b8470177641ed62a3ba22d74e1e898e06ad53aa77972487208", size = 107338, upload-time = "2026-05-19T21:30:09.713Z" }, { url = "https://files.pythonhosted.org/packages/9b/e8/f20557aca240d88e69850ad1ee91756821d094bb1310565c04d25c6682a2/yarl-1.24.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:66410eb6345d467151934b49bfa70fb32f5b35a6140baa40ad97d6436abea2e9", size = 110830, upload-time = "2026-07-20T02:06:47.852Z" },
{ url = "https://files.pythonhosted.org/packages/ae/04/23049463f729bd899df203a7960505a75333edd499cda8aa1d5a82b64df5/yarl-1.24.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:310fc687f7b2044ec54e372c8cbe923bb88f5c37bded0d3079e5791c2fc3cf50", size = 106147, upload-time = "2026-05-19T21:30:11.365Z" }, { url = "https://files.pythonhosted.org/packages/db/18/199b85109a53eeca64ee19c9cca228287e8e4ab0cc1a09b28f530e65cce0/yarl-1.24.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4af7b7e1be0a69bee8210735fe6dcfc38879adfac6d62e789d53ba432d1ffa41", size = 110054, upload-time = "2026-07-20T02:06:49.84Z" },
{ url = "https://files.pythonhosted.org/packages/14/18/04a4b5830b43ed5e4c5015b40e9f6241ad91487d71611061b4e111d6ac80/yarl-1.24.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:297a2fe352ecf858b30a98f87948746ec16f001d279f84aebdbd3bd965e2f1bd", size = 104272, upload-time = "2026-05-19T21:30:12.978Z" }, { url = "https://files.pythonhosted.org/packages/aa/2f/ed28147f8cd7f48c49367c90713b30a555284b6105a6a56f3a05568da795/yarl-1.24.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa139875ff98ab97da323cfadfaff08900d1ad42f1b5087b0b812a55c5a06373", size = 108312, upload-time = "2026-07-20T02:06:51.835Z" },
{ url = "https://files.pythonhosted.org/packages/5a/f7/8cffdf319aee7a7c1dbd07b61d91c3e3fda460c7a93b5f93e445f3806c4c/yarl-1.24.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2a263e76b97bc42bdcd7c5f4953dec1f7cd62a1112fa7f869e57255229390d67", size = 99962, upload-time = "2026-05-19T21:30:15.001Z" }, { url = "https://files.pythonhosted.org/packages/c5/c5/55e16ae0a5c227cea8df1c6871ba57d614a34243146c05729caf2a1bd9c5/yarl-1.24.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:0055afc45e864b92729ac7600e2d102c17bef060647e74bca75fa84d66b9ff36", size = 103662, upload-time = "2026-07-20T02:06:54.061Z" },
{ url = "https://files.pythonhosted.org/packages/d7/39/b3cce3b7dbef64ac700ad4cea156a207d01bede0f507587616c364b5468e/yarl-1.24.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:822519b64cf0b474f1a0aaef1dc621438ea46bb77c94df97a5b4d213a7d8a8b1", size = 111063, upload-time = "2026-05-19T21:30:16.683Z" }, { url = "https://files.pythonhosted.org/packages/8d/ea/dbd7c2caec459c9a426f18b02688ecbfb58620d0f6a3422d24769fbaf8ab/yarl-1.24.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f0e466ed7511fe9d459a819edbc6c2585c0b6eabde9fa8a8947552468a7a6ef0", size = 116090, upload-time = "2026-07-20T02:06:56.015Z" },
{ url = "https://files.pythonhosted.org/packages/a1/ea/100818505e7ebf165c7242ff17fdf7d9fee79e27234aeca871c1082920d7/yarl-1.24.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b6067060d9dc594899ba83e6db6c48c68d1e494a6dab158156ed86977ca7bcb1", size = 105438, upload-time = "2026-05-19T21:30:18.769Z" }, { url = "https://files.pythonhosted.org/packages/06/84/39ce4ce3059e07fece5fbdbee8c4053406af9aca911ce9fa5f8548aab6af/yarl-1.24.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f141474e85b7e54998ec5180530a7cda99ab29e282fa50e0756d89981a9b43c5", size = 109523, upload-time = "2026-07-20T02:06:57.926Z" },
{ url = "https://files.pythonhosted.org/packages/8f/d2/e075a0b32aa6625087de9e653087df0759fed5de4a435fef594181102a77/yarl-1.24.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0063adad533e57171b79db3943b229d40dfafeeee579767f96541f106bac5f1b", size = 111458, upload-time = "2026-05-19T21:30:21.024Z" }, { url = "https://files.pythonhosted.org/packages/a9/8b/71ff44137b405c64a7788075669c24010019f57a7464b78c3a6cbee539d9/yarl-1.24.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:e2935f8c39e3b03e83519292d78f075189978f3f4adc15a78144c7c8e2a1cba5", size = 116084, upload-time = "2026-07-20T02:06:59.868Z" },
{ url = "https://files.pythonhosted.org/packages/e6/5c/ceea7ba98b65c8eb8d947fdc52f9bedfcd43c6a57c9e3c90c17be8f324a3/yarl-1.24.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ee8e3fb34513e8dc082b586ef4910c98335d43a6fab688cd44d4851bacfce3e8", size = 107589, upload-time = "2026-05-19T21:30:23.412Z" }, { url = "https://files.pythonhosted.org/packages/62/c0/423078fdd4042e1862c11f0ffd977a0ffa393783c12bee94685923bc189e/yarl-1.24.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9d1216a7f6f77836617dba35687c5b78a4170afc3c3f18fc788f785ba26565c4", size = 111006, upload-time = "2026-07-20T02:07:01.907Z" },
{ url = "https://files.pythonhosted.org/packages/fa/d9/5582d57e2b2db9b85eb6663a22efdd78e08805f3f5389566e9fcad254d1b/yarl-1.24.2-cp314-cp314-win_amd64.whl", hash = "sha256:afb00d7fd8e0f285ca29a44cc50df2d622ff2f7a6d933fa641577b5f9d5f3db0", size = 94424, upload-time = "2026-05-19T21:30:25.425Z" }, { url = "https://files.pythonhosted.org/packages/cf/52/6daa2ee9d95e5c98b8128f8df91eb692eb423ab274b8cf08db52152fad26/yarl-1.24.5-cp314-cp314-win_amd64.whl", hash = "sha256:5ba4f78df2bcc19f764a4b26a8a4f5049c110090ad5825993aacb052bf8003ad", size = 99215, upload-time = "2026-07-20T02:07:03.852Z" },
{ url = "https://files.pythonhosted.org/packages/92/10/7dc07a0e22806a9280f42a57361395506e800c64e22737cd7b0886feab42/yarl-1.24.2-cp314-cp314-win_arm64.whl", hash = "sha256:68cf6eacd6028ef1142bc4b48376b81566385ca6f9e7dde3b0fa91be08ffcb57", size = 88690, upload-time = "2026-05-19T21:30:27.623Z" }, { url = "https://files.pythonhosted.org/packages/ec/0e/464a847d7359e0da75dd9fc5c1d1aa35d0159ea31e5f8e66a3c1c29ff3d0/yarl-1.24.5-cp314-cp314-win_arm64.whl", hash = "sha256:9e4e16c73d717c5cf27626c524d0a2e261ad20e46932b2670f64ad5dde23e26f", size = 94566, upload-time = "2026-07-20T02:07:06.074Z" },
{ url = "https://files.pythonhosted.org/packages/9e/13/d5b8e2c8667db955bcb3de233f18798fefe7edf1d7429c2c9d4f9c401114/yarl-1.24.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:221ce1dd921ac4f603957f17d7c18c5cc0797fbb52f156941f92e04605d1d67b", size = 136248, upload-time = "2026-05-19T21:30:29.297Z" }, { url = "https://files.pythonhosted.org/packages/e2/55/e03acc4446772660bc335e86e41ef31e4d0d838fd641531a11a5ee33b493/yarl-1.24.5-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e1ae548a9d901adca07899a4147a7c826bbcc06239d3ce9a59f57886a28a4c88", size = 142533, upload-time = "2026-07-20T02:07:08.284Z" },
{ url = "https://files.pythonhosted.org/packages/de/46/a4a97c05c9c9b8fd266bb2a0df12992c7fbd02391eb9640583411b6dab32/yarl-1.24.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5f3224db28173a00d7afacdee07045cc4673dfab2b15492c7ae10deddbece761", size = 95084, upload-time = "2026-05-19T21:30:31.031Z" }, { url = "https://files.pythonhosted.org/packages/ae/71/4acd3a1fc7cf14345cdb302665ecd2097f62c365b4f14ca17d4f37775cf9/yarl-1.24.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ff405d91509d88e8d44129cd87b18d70acd1f0c1aeabd7bc3c46792b1fe2acba", size = 100776, upload-time = "2026-07-20T02:07:10.197Z" },
{ url = "https://files.pythonhosted.org/packages/95/b2/845cf2074a015e6fe0d0808cf1a2d9e868386c4220d657ebd8302b199043/yarl-1.24.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c557165320d6244ebe3a02431b2a201a20080e02f41f0cfa0ccc47a183765da8", size = 95272, upload-time = "2026-05-19T21:30:33.062Z" }, { url = "https://files.pythonhosted.org/packages/ff/0b/cfb76b7fe99686db264bff829779a539d923e7564ffd7ef18da6c54c3774/yarl-1.24.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:47e98aab9d8d82ff682e7b0b5dded33bf138a32b817fcf7fa3b27b2d7c412928", size = 100913, upload-time = "2026-07-20T02:07:12.357Z" },
{ url = "https://files.pythonhosted.org/packages/fe/16/e69d4aa244aef45235ddfebc0e04036a6829842bc5a6a795aedc6c998d23/yarl-1.24.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:904065e6e85b1fa54d0d87438bd58c14c0bad97aad654ad1077fd9d87e8478ed", size = 101497, upload-time = "2026-05-19T21:30:34.842Z" }, { url = "https://files.pythonhosted.org/packages/8b/3f/7116e782992abbd4fb6948488aec72078895e929a23078290739e8396fce/yarl-1.24.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0a658a6d3fafee5c6f63c58f3e785c8c43c93fbc02bf9f2b6663f8185e0971f", size = 106507, upload-time = "2026-07-20T02:07:14.173Z" },
{ url = "https://files.pythonhosted.org/packages/15/94/c07107715d621076863ee88b3ddf183fa5e9d4aba5769623c9979828410a/yarl-1.24.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8cec2a38d70edc10e0e856ceda886af5327a017ccbde8e1de1bd44d300357543", size = 94002, upload-time = "2026-05-19T21:30:37.724Z" }, { url = "https://files.pythonhosted.org/packages/33/90/d4d2d73ee78229cc889872eb8e085d8f5c6f51abdb178409fd9b23cf74fd/yarl-1.24.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4377407001ca3c057773f44d8ddd6358fa5f691407c1ba92210bd3cf8d9e4c95", size = 99219, upload-time = "2026-07-20T02:07:16.019Z" },
{ url = "https://files.pythonhosted.org/packages/a9/35/fc1bbdd895b5e4010b8fdd037f7ed3aa289d3863e08231b30231ca9a0815/yarl-1.24.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e7484b9361ed222ee1ca5b4337aa4cbdcc4618ce5aff57d9ef1582fd95893fc0", size = 106524, upload-time = "2026-05-19T21:30:40.196Z" }, { url = "https://files.pythonhosted.org/packages/3e/fa/a6df1a9bccd644eec00abee0dff4277416222cec435330fd1f2858523ec1/yarl-1.24.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7c0494a31a1ac5461a226e7947a9c9b78c44e1dc7185164fa7e9651557a5d9bc", size = 111804, upload-time = "2026-07-20T02:07:18.141Z" },
{ url = "https://files.pythonhosted.org/packages/1f/f2/32b66d0a4ba47c296cf86d03e2c67bff58399fe6d6d84d5205c04c66cc6d/yarl-1.24.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:84f9670b89f34db07f81e53aee83e0b938a3412329d51c8f922488be7fcc4024", size = 106165, upload-time = "2026-05-19T21:30:41.888Z" }, { url = "https://files.pythonhosted.org/packages/8a/9e/7b2a1f4bcc20e9447156dd2b1c4d01f70d9df0759025ee7d09a84ffae134/yarl-1.24.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a7cff474ab7cd149765bb784cf6d78b32e18e20473fb7bda860bce98ab58e9da", size = 110943, upload-time = "2026-07-20T02:07:20.06Z" },
{ url = "https://files.pythonhosted.org/packages/95/47/37cb5ff50c5e825d4d38e81bb04d1b7e96bf960f7ab89f9850b162f3f114/yarl-1.24.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:abb2759733d63a28b4956500a5dd57140f26486c92b2caedfb964ab7d9b79dbf", size = 103010, upload-time = "2026-05-19T21:30:43.985Z" }, { url = "https://files.pythonhosted.org/packages/08/ff/22c92affb0f9b623ca753d27d968b5625b868f12c6378d049d55ae247643/yarl-1.24.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cbb833ccacdb5519eff9b8b71ee618cc2801c878e77e288775d77c3a2ced858a", size = 108251, upload-time = "2026-07-20T02:07:22.217Z" },
{ url = "https://files.pythonhosted.org/packages/6f/d2/4597912315096f7bb359e46e13bf8b60994fcbb2db29b804c0902ef4eff5/yarl-1.24.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:081c2bf54efe03774d0311172bc04fedf9ca01e644d4cd8c805688e527209bdc", size = 101128, upload-time = "2026-05-19T21:30:46.291Z" }, { url = "https://files.pythonhosted.org/packages/45/44/5769b96298c1e195fb412997b6090af2a84105cf59c17613558a2d011d1f/yarl-1.24.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:82f75e05912e84b7a0fe57075d9c59de3cb352b928330f2eb69b2e1f54c3e1f0", size = 106025, upload-time = "2026-07-20T02:07:24.083Z" },
{ url = "https://files.pythonhosted.org/packages/b9/d5/c8e86e120521e646013d02a8e3b8884392e28494be8f392366e50d208efc/yarl-1.24.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:86746bef442aa479107fe28132e1277237f9c24c2f00b0b0cf22b3ee0904f2bb", size = 101382, upload-time = "2026-05-19T21:30:48.085Z" }, { url = "https://files.pythonhosted.org/packages/4c/40/009e8e791fd9762c0e1567e69248acb4f49064597e1680874c16dd8bb798/yarl-1.24.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:16a2f5010280020e90f5330257e6944bc33e73593b136cc5a241e6c1dc292498", size = 106573, upload-time = "2026-07-20T02:07:26.248Z" },
{ url = "https://files.pythonhosted.org/packages/fa/98/70b229236118f89dbeb739b76f10225bbf53b5497725502594c9a01d699a/yarl-1.24.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:2d07d21d0bc4b17558e8de0b02fbfdf1e347d3bb3699edd00bb92e7c57925420", size = 95964, upload-time = "2026-05-19T21:30:49.785Z" }, { url = "https://files.pythonhosted.org/packages/20/c6/b7480578f8a0a80946f36ad6df547ecec704f9ba69d2de60f8aa6f1c1cbf/yarl-1.24.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ffcd54362564dc1a30fb74d8b8a6e5a6b11ebd5e27266adc3b7427a21a6c9104", size = 100751, upload-time = "2026-07-20T02:07:28.098Z" },
{ url = "https://files.pythonhosted.org/packages/87/f8/56c386981e3c8648d279fdef2397ffec577e8320fd5649745e34d54faeb7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4fb1ac3fc5fecd8ae7453ea237e4d22b49befa70266dfe1629924245c21a0c7f", size = 106204, upload-time = "2026-05-19T21:30:51.862Z" }, { url = "https://files.pythonhosted.org/packages/d4/27/4476f3360b91a48c5cf125e91f59a3bd35299d84a431a258d57f5977bb11/yarl-1.24.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0465ec8cedc2349b97a6b595ace64084a50c6e839eca40aa0626f38b8350e331", size = 111643, upload-time = "2026-07-20T02:07:30.88Z" },
{ url = "https://files.pythonhosted.org/packages/1a/1e/765afe97811ca35933e2a7de70ac57b1997ea2e4ee895719ee7a231fb7e5/yarl-1.24.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4da31a5512ed1729ca8d8aacde3f7faeb8843cde3165d6bcf7f88f74f17bb8aa", size = 101510, upload-time = "2026-05-19T21:30:53.62Z" }, { url = "https://files.pythonhosted.org/packages/4c/4b/5cdd3e5ee944e8af31e52f6cd3d3af5fd7b937e036ccbbba2c9ffebede95/yarl-1.24.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4db9aecb141cb7a5447171b57aa1ed3a8fee06af40b992ffc31206c0b0121550", size = 106312, upload-time = "2026-07-20T02:07:33.06Z" },
{ url = "https://files.pythonhosted.org/packages/ee/78/393913f4b9039e1edd09ae8a9bbb9d539be909a8abf6d8a2084585bed4b7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:533ded4dceb5f1f3da7906244f4e82cf46cfd40d84c69a1faf5ac506aa65ecbe", size = 105584, upload-time = "2026-05-19T21:30:55.962Z" }, { url = "https://files.pythonhosted.org/packages/18/86/f406b0c2a6f99575de2da671ef47aa06f89a5be83a27a46971c3b86cecdb/yarl-1.24.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f540c013589084679a6c7fac07096b10159737918174f5dfc5e11bf5bca4dfe6", size = 110379, upload-time = "2026-07-20T02:07:35.155Z" },
{ url = "https://files.pythonhosted.org/packages/78/87/deb17b7049bbe74ea11a713b86f8f27800cc1c8648b0b797243ebb4830ba/yarl-1.24.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7b3a85525f6e7eeabcfdd372862b21ee1915db1b498a04e8bf0e389b607ff0bd", size = 103410, upload-time = "2026-05-19T21:30:57.962Z" }, { url = "https://files.pythonhosted.org/packages/f0/6c/9f3adfbd3b30b4fa0f7ccb3a83eba2c1152d3fff554d535e640ba0f7ba2b/yarl-1.24.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a61834fb15d81322d872eaafd333838ae7c9cea84067f232656f75965933d047", size = 108497, upload-time = "2026-07-20T02:07:37.35Z" },
{ url = "https://files.pythonhosted.org/packages/8f/be/f9f7594e23b5b93affff0318e4593c1920331bcaefda326cabcad94296a1/yarl-1.24.2-cp314-cp314t-win_amd64.whl", hash = "sha256:a7624b1ca46ca5d7b864ef0d2f8efe3091454085ee1855b4e992314529972215", size = 102980, upload-time = "2026-05-19T21:30:59.735Z" }, { url = "https://files.pythonhosted.org/packages/dd/37/91eb2e5ca883a529c1b390348a74cd9fc0512171727f547ce70bfe02be5c/yarl-1.24.5-cp314-cp314t-win_amd64.whl", hash = "sha256:5c88e5815a49d289e599f3513aa7fde0bc2092ff188f99c940f007f90f53d104", size = 102450, upload-time = "2026-07-20T02:07:39.578Z" },
{ url = "https://files.pythonhosted.org/packages/65/a4/ba80dccd3593ff1f01051a818694d07b58cb8232677ee9a22a5a1f93a9fc/yarl-1.24.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e434a45ce2e7a947f951fc5a8944c8cc080b7e59f9c50ae80fd39107cf88126d", size = 91219, upload-time = "2026-05-19T21:31:01.934Z" }, { url = "https://files.pythonhosted.org/packages/bf/f4/ed5c402ac8fde4403ed3366c2716bfddc8a6677ebd59f3d62772cc7fe468/yarl-1.24.5-cp314-cp314t-win_arm64.whl", hash = "sha256:cf139c02f5f23ef6532040a30ff662c00a318c952334f211046b8e60b7f17688", size = 97222, upload-time = "2026-07-20T02:07:41.55Z" },
{ url = "https://files.pythonhosted.org/packages/fd/4d/4b880086bd0d3e034d25647be1d830afc3e3f610e98c4ab3490af6b1b6d5/yarl-1.24.2-py3-none-any.whl", hash = "sha256:2783d9226db8797636cd6896e4de81feed252d1db72265686c9558d97a4d94b9", size = 53576, upload-time = "2026-05-19T21:31:03.909Z" }, { url = "https://files.pythonhosted.org/packages/61/02/962c1cbfc401a30c1d034dc67ff395f64b52302c6d62de556c1fca99acc0/yarl-1.24.5-py3-none-any.whl", hash = "sha256:a33700d13d9b7d84fd10947b09ff69fb9a792e519c8cb9764a3ca70baa6c23a7", size = 58612, upload-time = "2026-07-20T02:07:43.461Z" },
] ]
[[package]] [[package]]