Merge pull request #420 from arabcoders/dev
Some checks failed
native-build / build (amd64, ubuntu-latest) (push) Has been cancelled
native-build / build (amd64, windows-latest) (push) Has been cancelled
native-build / build (arm64, macos-latest) (push) Has been cancelled
native-build / build (arm64, ubuntu-latest) (push) Has been cancelled
native-build / build (arm64, windows-latest) (push) Has been cancelled
Some checks failed
native-build / build (amd64, ubuntu-latest) (push) Has been cancelled
native-build / build (amd64, windows-latest) (push) Has been cancelled
native-build / build (arm64, macos-latest) (push) Has been cancelled
native-build / build (arm64, ubuntu-latest) (push) Has been cancelled
native-build / build (arm64, windows-latest) (push) Has been cancelled
Support using GPU encoding for playback
This commit is contained in:
commit
871f9e803e
26 changed files with 3377 additions and 804 deletions
12
.vscode/settings.json
vendored
12
.vscode/settings.json
vendored
|
|
@ -22,14 +22,18 @@
|
|||
"asyncio",
|
||||
"attl",
|
||||
"autonumber",
|
||||
"autouse",
|
||||
"bgutil",
|
||||
"bgutilhttp",
|
||||
"bilibili",
|
||||
"brainicism",
|
||||
"brotlicffi",
|
||||
"buildcache",
|
||||
"cand",
|
||||
"caplog",
|
||||
"Cfmrc",
|
||||
"choco",
|
||||
"cifs",
|
||||
"consoletitle",
|
||||
"continuedl",
|
||||
"cookiesfrombrowser",
|
||||
|
|
@ -41,6 +45,7 @@
|
|||
"dateparser",
|
||||
"daterange",
|
||||
"defusedxml",
|
||||
"delenv",
|
||||
"dlfields",
|
||||
"dotdot",
|
||||
"dotenv",
|
||||
|
|
@ -55,8 +60,10 @@
|
|||
"euuo",
|
||||
"eventbus",
|
||||
"excepthook",
|
||||
"falsey",
|
||||
"faststart",
|
||||
"Fetc",
|
||||
"filterwarnings",
|
||||
"finaldir",
|
||||
"flac",
|
||||
"forcejson",
|
||||
|
|
@ -77,6 +84,7 @@
|
|||
"levelno",
|
||||
"libcurl",
|
||||
"libjavascriptcoregtk",
|
||||
"libmfx",
|
||||
"libstdc",
|
||||
"libwebkit",
|
||||
"libx",
|
||||
|
|
@ -97,6 +105,7 @@
|
|||
"multidict",
|
||||
"muxdelay",
|
||||
"mweb",
|
||||
"nfsvers",
|
||||
"nodesc",
|
||||
"noninteractive",
|
||||
"noprogress",
|
||||
|
|
@ -118,6 +127,7 @@
|
|||
"pypi",
|
||||
"pythonpath",
|
||||
"quicktime",
|
||||
"ratecontrol",
|
||||
"rejecttitle",
|
||||
"remux",
|
||||
"reqs",
|
||||
|
|
@ -147,7 +157,9 @@
|
|||
"urandom",
|
||||
"urlsafe",
|
||||
"usegmt",
|
||||
"usermod",
|
||||
"ustr",
|
||||
"vainfo",
|
||||
"vcodec",
|
||||
"vconvert",
|
||||
"Vitest",
|
||||
|
|
|
|||
13
Dockerfile
13
Dockerfile
|
|
@ -17,8 +17,7 @@ ENV UV_CACHE_DIR=/root/.cache/uv
|
|||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
ENV UV_INSTALL_DIR=/usr/bin
|
||||
|
||||
# Install build dependencies and uv
|
||||
# RUN apt-get update && apt-get install -y --no-install-recommends build-essential libffi-dev libssl-dev curl ca-certificates pkg-config && pip install --no-cache-dir uv
|
||||
SHELL ["/bin/bash","-lc"]
|
||||
RUN echo 1 && curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
|
||||
WORKDIR /opt/
|
||||
|
|
@ -27,7 +26,7 @@ COPY ./pyproject.toml ./uv.lock ./
|
|||
RUN --mount=type=cache,target=/root/.cache/pip,id=pip-cache \
|
||||
--mount=type=cache,target=/root/.cache/uv,id=uv-cache \
|
||||
uv venv --system-site-packages --relocatable ./python && \
|
||||
VIRTUAL_ENV=/opt/python uv sync --link-mode=copy --active
|
||||
VIRTUAL_ENV=/opt/python uv sync --no-dev --link-mode=copy --active
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
|
|
@ -48,8 +47,12 @@ ENV PYTHONFAULTHANDLER=1
|
|||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN mkdir /config /downloads && ln -snf /usr/share/zoneinfo/${TZ} /etc/localtime && echo ${TZ} > /etc/timezone && \
|
||||
apt-get update && apt-get install -y --no-install-recommends \
|
||||
bash mkvtoolnix patch aria2 curl ca-certificates xz-utils git sqlite3 tzdata file libmagic1 \
|
||||
apt-get update && \
|
||||
ARCH="$(dpkg --print-architecture)" && \
|
||||
EXTRA_PACKAGES="" && \
|
||||
if [ "$ARCH" = "amd64" ]; then EXTRA_PACKAGES="intel-media-va-driver i965-va-driver libmfx-gen1.2"; fi && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
bash mkvtoolnix patch aria2 curl ca-certificates xz-utils git sqlite3 tzdata file libmagic1 vainfo ${EXTRA_PACKAGES} \
|
||||
&& useradd -u ${USER_ID:-1000} -U -d /app -s /bin/bash app \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
|
|
|||
180
FAQ.md
180
FAQ.md
|
|
@ -1,47 +1,54 @@
|
|||
# The origin of the project.
|
||||
|
||||
The project first started as a fork [meTube](https://github.com/alexta69/metube), since then it has been completely
|
||||
rewritten and redesigned. The original project was a great starting point, but it didn't align with my vision for the
|
||||
project and what i wanted to achieve with it.
|
||||
|
||||
# Environment variables
|
||||
|
||||
Certain configuration values can be set via environment variables, using the `-e` parameter on the docker command line,
|
||||
or the `environment:` section in `compose.yaml` file.
|
||||
|
||||
| Environment Variable | Description | Default |
|
||||
| ------------------------------ | ------------------------------------------------------------------ | -------------------------- |
|
||||
| TZ | The timezone to use for the application | `(not_set)` |
|
||||
| YTP_OUTPUT_TEMPLATE | The template for the filenames of the downloaded videos | `%(title)s.%(ext)s` |
|
||||
| YTP_DEFAULT_PRESET | The default preset to use for the download | `default` |
|
||||
| YTP_INSTANCE_TITLE | The title of the instance | `empty string` |
|
||||
| YTP_FILE_LOGGING | Whether to log to file | `false` |
|
||||
| YTP_DOWNLOAD_PATH | Path to where the downloads will be saved | `/downloads` |
|
||||
| YTP_MAX_WORKERS | How many works to use for downloads | `1` |
|
||||
| YTP_AUTH_USERNAME | Username for basic authentication | `empty string` |
|
||||
| YTP_AUTH_PASSWORD | Password for basic authentication | `empty string` |
|
||||
| YTP_CONSOLE_ENABLED | Whether to enable the console | `false` |
|
||||
| YTP_REMOVE_FILES | Remove the actual file when clicking the remove button | `false` |
|
||||
| YTP_CONFIG_PATH | Path to where the config files will be stored. | `/config` |
|
||||
| YTP_TEMP_PATH | Path to where tmp files are stored. | `/tmp` |
|
||||
| YTP_TEMP_KEEP | Whether to keep the Individual video temp directory or remove it | `false` |
|
||||
| YTP_HOST | Which IP address to bind to | `0.0.0.0` |
|
||||
| YTP_PORT | Which port to bind to | `8081` |
|
||||
| YTP_LOG_LEVEL | Log level | `info` |
|
||||
| YTP_STREAMER_VCODEC | The video codec to use for in-browser streaming | `libx264` |
|
||||
| YTP_STREAMER_ACODEC | The audio codec to use for in-browser streaming | `aac` |
|
||||
| YTP_ACCESS_LOG | Whether to log access to the web server | `true` |
|
||||
| YTP_DEBUG | Whether to turn on debug mode | `false` |
|
||||
| YTP_DEBUGPY_PORT | The port to use for the debugpy debugger | `5678` |
|
||||
| YTP_EXTRACT_INFO_TIMEOUT | The timeout for extracting video information | `70` |
|
||||
| YTP_UI_UPDATE_TITLE | Whether to update the title of the page with the current stats | `true` |
|
||||
| YTP_PIP_PACKAGES | A space separated list of pip packages to install | `empty string` |
|
||||
| YTP_PIP_IGNORE_UPDATES | Do not update the custom pip packages | `false` |
|
||||
| YTP_PICTURES_BACKENDS | A comma separated list of pictures urls to use | `empty string` |
|
||||
| YTP_BROWSER_CONTROL_ENABLED | Whether to enable the file browser actions | `false` |
|
||||
| YTP_YTDLP_AUTO_UPDATE | Whether to enable the auto update for yt-dlp | `true` |
|
||||
| YTP_YTDLP_DEBUG | Whether to turn debug logging for the internal `yt-dlp` package | `false` |
|
||||
| YTP_YTDLP_VERSION | The version of yt-dlp to use. Defaults to latest version | `empty string` |
|
||||
| YTP_BASE_PATH | Set this if you are serving YTPTube from sub-folder | `/` |
|
||||
| YTP_PREVENT_LIVE_PREMIERE | Prevents the initial youtube premiere stream from being downloaded | `false` |
|
||||
| YTP_TASKS_HANDLER_TIMER | The cron expression for the tasks handler timer | `15 */1 * * *` |
|
||||
| YTP_PLAYLIST_ITEMS_CONCURRENCY | The number of playlist items be to processed at same time | `1` |
|
||||
| YTP_TEMP_DISABLED | Disable temp files handling. | `false` |
|
||||
| YTP_DOWNLOAD_PATH_DEPTH | How many subdirectories to show in auto complete. | `1` |
|
||||
| Environment Variable | Description | Default |
|
||||
| ------------------------------ | ------------------------------------------------------------------ | --------------------- |
|
||||
| TZ | The timezone to use for the application | `(not_set)` |
|
||||
| YTP_OUTPUT_TEMPLATE | The template for the filenames of the downloaded videos | `%(title)s.%(ext)s` |
|
||||
| YTP_DEFAULT_PRESET | The default preset to use for the download | `default` |
|
||||
| YTP_INSTANCE_TITLE | The title of the instance | `empty string` |
|
||||
| YTP_FILE_LOGGING | Whether to log to file | `false` |
|
||||
| YTP_DOWNLOAD_PATH | Path to where the downloads will be saved | `/downloads` |
|
||||
| YTP_MAX_WORKERS | How many works to use for downloads | `1` |
|
||||
| YTP_AUTH_USERNAME | Username for basic authentication | `empty string` |
|
||||
| YTP_AUTH_PASSWORD | Password for basic authentication | `empty string` |
|
||||
| YTP_CONSOLE_ENABLED | Whether to enable the console | `false` |
|
||||
| YTP_REMOVE_FILES | Remove the actual file when clicking the remove button | `false` |
|
||||
| YTP_CONFIG_PATH | Path to where the config files will be stored. | `/config` |
|
||||
| YTP_TEMP_PATH | Path to where tmp files are stored. | `/tmp` |
|
||||
| YTP_TEMP_KEEP | Whether to keep the Individual video temp directory or remove it | `false` |
|
||||
| YTP_HOST | Which IP address to bind to | `0.0.0.0` |
|
||||
| YTP_PORT | Which port to bind to | `8081` |
|
||||
| YTP_LOG_LEVEL | Log level | `info` |
|
||||
| YTP_STREAMER_VCODEC | The video encoding codec, default to GPU and fallback to software | `""` |
|
||||
| YTP_STREAMER_ACODEC | The audio codec to use for in-browser streaming | `aac` |
|
||||
| YTP_VAAPI_DEVICE | The VAAPI device to use for hardware acceleration. | `/dev/dri/renderD128` |
|
||||
| YTP_ACCESS_LOG | Whether to log access to the web server | `true` |
|
||||
| YTP_DEBUG | Whether to turn on debug mode | `false` |
|
||||
| YTP_DEBUGPY_PORT | The port to use for the debugpy debugger | `5678` |
|
||||
| YTP_EXTRACT_INFO_TIMEOUT | The timeout for extracting video information | `70` |
|
||||
| YTP_UI_UPDATE_TITLE | Whether to update the title of the page with the current stats | `true` |
|
||||
| YTP_PIP_PACKAGES | A space separated list of pip packages to install | `empty string` |
|
||||
| YTP_PIP_IGNORE_UPDATES | Do not update the custom pip packages | `false` |
|
||||
| YTP_PICTURES_BACKENDS | A comma separated list of pictures urls to use | `empty string` |
|
||||
| YTP_BROWSER_CONTROL_ENABLED | Whether to enable the file browser actions | `false` |
|
||||
| YTP_YTDLP_AUTO_UPDATE | Whether to enable the auto update for yt-dlp | `true` |
|
||||
| YTP_YTDLP_DEBUG | Whether to turn debug logging for the internal `yt-dlp` package | `false` |
|
||||
| YTP_YTDLP_VERSION | The version of yt-dlp to use. Defaults to latest version | `empty string` |
|
||||
| YTP_BASE_PATH | Set this if you are serving YTPTube from sub-folder | `/` |
|
||||
| YTP_PREVENT_LIVE_PREMIERE | Prevents the initial youtube premiere stream from being downloaded | `false` |
|
||||
| YTP_TASKS_HANDLER_TIMER | The cron expression for the tasks handler timer | `15 */1 * * *` |
|
||||
| YTP_PLAYLIST_ITEMS_CONCURRENCY | The number of playlist items be to processed at same time | `1` |
|
||||
| YTP_TEMP_DISABLED | Disable temp files handling. | `false` |
|
||||
| YTP_DOWNLOAD_PATH_DEPTH | How many subdirectories to show in auto complete. | `1` |
|
||||
|
||||
# Browser extensions & bookmarklets
|
||||
|
||||
|
|
@ -263,8 +270,95 @@ YTP_PICTURES_BACKENDS=https://watchstate.ip/v1/api/system/images/background?apik
|
|||
|
||||
Where `[api_key]` is the api key you get from your WatchState instance.
|
||||
|
||||
# The origin of the project.
|
||||
# How to use share folder or external storage as download target?
|
||||
|
||||
The project first started as a fork [meTube](https://github.com/alexta69/metube), since then it has been completely
|
||||
rewritten and redesigned. The original project was a great starting point, but it didn't align with my vision for the
|
||||
project and what i wanted to achieve with it.
|
||||
You can simply mount the share folder as target for `/downloads` path, but in some cases you might face issues with permissions or
|
||||
cross-device link errors. To avoid these issues, you can mount the share folder as a named volume, and then mount the
|
||||
named volume to `/downloads/smb` or `/downloads/nfs`.
|
||||
|
||||
```yaml
|
||||
services:
|
||||
ytptube:
|
||||
user: "${UID:-1000}:${UID:-1000}" # change this to your user id and group id, for example: "1000:1000"
|
||||
image: ghcr.io/arabcoders/ytptube:latest
|
||||
container_name: ytptube
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8081:8081"
|
||||
volumes:
|
||||
# Config must be mounted locally as read-write sqlite doesn't support network mounts.
|
||||
- ./config:/config:rw
|
||||
# Mount a local directory
|
||||
- ./downloads:/downloads/local:rw
|
||||
# Mount the NFS share
|
||||
- nfs-data:/downloads/nfs:rw
|
||||
# Mount the SMB share
|
||||
- smb-data:/downloads/smb:rw
|
||||
tmpfs:
|
||||
- /tmp
|
||||
|
||||
volumes:
|
||||
nfs-data:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: nfs
|
||||
o: addr=10.0.0.3,rw,nfsvers=4 # <--- Change server IP and options
|
||||
device: ":/exported/path" # <--- Remote NFS path
|
||||
|
||||
smb-data:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: cifs
|
||||
o: username=my_username,password=my_password,vers=3.0,uid=1000,gid=1000,file_mode=0777,dir_mode=0777 # <--- Change options to fit your needs
|
||||
device: "//10.0.0.3/public" # <--- Remote SMB path
|
||||
```
|
||||
|
||||
If you prefer, you can bypass YTPTube `download_path` and set it to `/` and completely manage your own mounts. However,
|
||||
please be aware that the file browser feature will expose whatever `download_path` is set to. **So, if you set it to `/`,
|
||||
the file browser will expose the entire container filesystem.**
|
||||
|
||||
# How to use hardware acceleration for video transcoding?
|
||||
|
||||
As the container is rootless, we cannot do the necessary changes to the container to enable hardware acceleration.
|
||||
However, We do have the drivers and ffmpeg already installed and the CPU transcoding should work regardless. To enable
|
||||
hardware acceleration You need to alter your `compose.yaml` file to mount the necessary devices to the container. Here
|
||||
is an example of how to do it for debian based systems.
|
||||
|
||||
```yaml
|
||||
services:
|
||||
ytptube:
|
||||
........ # see above for the rest of the configuration
|
||||
devices:
|
||||
# mount the dri devices to the container if you only have one gpu you can simply do the following
|
||||
- /dev/dri:/dev/dri
|
||||
# Otherwise, selectively mount the devices you need.
|
||||
- /dev/dri/card0 # Intel GPU device
|
||||
- /dev/dri/renderD128 # Intel GPU render node
|
||||
group_add:
|
||||
# Add the necessary groups to the container to access the gpu devices.
|
||||
- 44 # it might be different on your system.
|
||||
- 105 # it might be different on your system.
|
||||
```
|
||||
|
||||
This setup should work for at VAAPI encoding in `x86_64` containers.
|
||||
|
||||
> [!NOTE]
|
||||
> Your `video`, `render` group id might be different from mine, you can run the follow command in docker host server to get the group ids for both groups.
|
||||
|
||||
```bash
|
||||
$ cat /etc/group | grep -E 'render|video'
|
||||
|
||||
video:x:44:your_docker_username
|
||||
render:x:105:your_docker_username
|
||||
```
|
||||
|
||||
In my docker host the group id for `video` is `44` and for `render` is `105`. change what needed in the `compose.yaml`
|
||||
file to match your setup.
|
||||
|
||||
If for some reason the initial test for GPU encoding fails, YTPTube will fallback to software encoding. You can force
|
||||
software encoding by setting the `YTP_STREAMER_VCODEC` environment variable to `libx264`. If you want to force GPU encoding, set the
|
||||
`YTP_STREAMER_VCODEC` environment variable to one of the supported GPU codecs, for example `h264_vaapi` or `h264_nvenc` depending on your GPU.
|
||||
For more information about the supported codecs, please refer to the [SegmentEncoders.py](app/library/SegmentEncoders.py) file.
|
||||
|
||||
If GPU encoding fails and software encoding is used, you will have to restart the container to try GPU encoding again.
|
||||
as we only test for GPU encoding once on first video stream.
|
||||
|
|
|
|||
237
app/library/SegmentEncoders.py
Normal file
237
app/library/SegmentEncoders.py
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
# flake8: noqa: ARG002
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Protocol
|
||||
|
||||
SUPPORTED_CODECS: tuple[str] = ("h264_qsv", "h264_nvenc", "h264_amf", "h264_videotoolbox", "h264_vaapi", "libx264")
|
||||
"Supported encoder names in order of preference."
|
||||
|
||||
|
||||
def has_dri_devices() -> bool:
|
||||
"""
|
||||
Check if there are any /dev/dri devices.
|
||||
|
||||
Returns:
|
||||
bool: True if there are any /dev/dri devices, False otherwise.
|
||||
|
||||
"""
|
||||
try:
|
||||
dri = Path("/dev/dri")
|
||||
if not dri.exists() or not dri.is_dir():
|
||||
return False
|
||||
|
||||
for _ in dri.iterdir():
|
||||
return True
|
||||
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def ffmpeg_encoders() -> set[str]:
|
||||
"""
|
||||
Return a set of available ffmpeg encoders.
|
||||
|
||||
Returns:
|
||||
set[str]: A set of available ffmpeg encoder names.
|
||||
|
||||
"""
|
||||
try:
|
||||
result: subprocess.CompletedProcess[str] = subprocess.run(
|
||||
["ffmpeg", "-hide_banner", "-loglevel", "error", "-encoders"], # noqa: S607
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0,
|
||||
)
|
||||
out: str = (result.stdout or "") + (result.stderr or "")
|
||||
except Exception:
|
||||
return set()
|
||||
|
||||
encoders: set[str] = set()
|
||||
|
||||
if not out:
|
||||
return encoders
|
||||
|
||||
for name in SUPPORTED_CODECS:
|
||||
if name in out:
|
||||
encoders.add(name)
|
||||
|
||||
return encoders
|
||||
|
||||
|
||||
def select_encoder(configured: str) -> str:
|
||||
"""
|
||||
Select a concrete encoder.
|
||||
|
||||
Args:
|
||||
configured (str): The configured encoder name, or empty for auto-detect.
|
||||
|
||||
Returns:
|
||||
str: The selected concrete encoder name.
|
||||
|
||||
"""
|
||||
configured = (configured or "").strip()
|
||||
|
||||
avail: set[str] = ffmpeg_encoders()
|
||||
if configured and configured in avail:
|
||||
return configured
|
||||
|
||||
for name in SUPPORTED_CODECS:
|
||||
if name in avail:
|
||||
return name
|
||||
|
||||
return "libx264"
|
||||
|
||||
|
||||
class EncoderBuilder(Protocol):
|
||||
def input_args(self, ctx: dict[str, Any] | None = None) -> list[str]:
|
||||
"""Encoder-specific input/global args that must appear before '-i'."""
|
||||
|
||||
def add_video_args(self, args: list[str], ctx: dict[str, Any] | None = None) -> list[str]:
|
||||
"""Append encoder-specific video/output args and return the list."""
|
||||
|
||||
|
||||
class _BaseBuilder:
|
||||
codec_name: str
|
||||
|
||||
def input_args(self, ctx: dict[str, Any] | None = None) -> list[str]:
|
||||
return []
|
||||
|
||||
def add_video_args(self, args: list[str], ctx: dict[str, Any] | None = None) -> list[str]:
|
||||
return [*args, "-codec:v", self.codec_name]
|
||||
|
||||
|
||||
class SoftwareBuilder(_BaseBuilder):
|
||||
codec_name = "libx264"
|
||||
|
||||
def add_video_args(self, args: list[str], ctx: dict[str, Any] | None = None) -> list[str]:
|
||||
return super().add_video_args(["-pix_fmt", "yuv420p", *args])
|
||||
|
||||
|
||||
class NvencBuilder(_BaseBuilder):
|
||||
codec_name = "h264_nvenc"
|
||||
|
||||
|
||||
class AmfBuilder(_BaseBuilder):
|
||||
codec_name = "h264_amf"
|
||||
|
||||
|
||||
class AppleVideoToolboxBuilder(_BaseBuilder):
|
||||
codec_name = "h264_videotoolbox"
|
||||
|
||||
|
||||
class VaapiBuilder(_BaseBuilder):
|
||||
codec_name = "h264_vaapi"
|
||||
|
||||
def input_args(self, ctx: dict[str, Any] | None = None) -> list[str]:
|
||||
ctx = ctx or {}
|
||||
is_linux: bool = bool(ctx.get("is_linux", sys.platform.startswith("linux")))
|
||||
has_dri: bool = bool(ctx.get("has_dri", False))
|
||||
device: str = ctx.get("vaapi_device", "/dev/dri/renderD128")
|
||||
if is_linux and has_dri:
|
||||
return ["-hwaccel", "vaapi", "-vaapi_device", str(device)]
|
||||
return []
|
||||
|
||||
def add_video_args(self, args: list[str], ctx: dict[str, Any] | None = None) -> list[str]:
|
||||
ctx = ctx or {}
|
||||
new_args: list[str] = list(args)
|
||||
is_linux: bool = bool(ctx.get("is_linux", sys.platform.startswith("linux")))
|
||||
has_dri: bool = bool(ctx.get("has_dri", False))
|
||||
if is_linux and has_dri:
|
||||
new_args += [
|
||||
# Ensure frames are in VAAPI-compatible format and uploaded
|
||||
"-vf",
|
||||
"format=nv12,hwupload",
|
||||
# Optional quality/preset flags similar to user's working config
|
||||
"-crf",
|
||||
"23",
|
||||
"-preset:v",
|
||||
"fast",
|
||||
"-level",
|
||||
"4.1",
|
||||
"-profile:v",
|
||||
"main",
|
||||
]
|
||||
return super().add_video_args(new_args)
|
||||
|
||||
|
||||
class QsvBuilder(_BaseBuilder):
|
||||
codec_name = "h264_qsv"
|
||||
|
||||
def input_args(self, ctx: dict[str, Any] | None = None) -> list[str]:
|
||||
ctx = ctx or {}
|
||||
is_linux: bool = bool(ctx.get("is_linux", sys.platform.startswith("linux")))
|
||||
has_dri: bool = bool(ctx.get("has_dri", False))
|
||||
device: str = ctx.get("vaapi_device", "/dev/dri/renderD128")
|
||||
if is_linux and has_dri:
|
||||
return ["-init_hw_device", f"qsv=hw:{device}", "-filter_hw_device", "hw"]
|
||||
return []
|
||||
|
||||
def add_video_args(self, args: list[str], ctx: dict[str, Any] | None = None) -> list[str]:
|
||||
ctx = ctx or {}
|
||||
new_args: list[str] = list(args)
|
||||
is_linux: bool = bool(ctx.get("is_linux", sys.platform.startswith("linux")))
|
||||
has_dri: bool = bool(ctx.get("has_dri", False))
|
||||
if is_linux and has_dri:
|
||||
new_args += [
|
||||
"-vf",
|
||||
"scale=trunc(iw/2)*2:trunc(ih/2)*2,format=nv12,hwupload=extra_hw_frames=64",
|
||||
# Favor widely-supported constant quality path and disable LA
|
||||
"-b:v",
|
||||
"0",
|
||||
"-global_quality",
|
||||
"23",
|
||||
"-look_ahead",
|
||||
"0",
|
||||
]
|
||||
return super().add_video_args(new_args)
|
||||
|
||||
|
||||
def get_builder_for_codec(codec: str) -> EncoderBuilder:
|
||||
"""
|
||||
Return an EncoderBuilder instance for the given concrete codec name.
|
||||
|
||||
Args:
|
||||
codec (str): The concrete codec name.
|
||||
|
||||
Returns:
|
||||
EncoderBuilder: An instance of the corresponding EncoderBuilder.
|
||||
|
||||
"""
|
||||
soft = SoftwareBuilder()
|
||||
return {
|
||||
"libx264": soft,
|
||||
"h264_nvenc": NvencBuilder(),
|
||||
"h264_amf": AmfBuilder(),
|
||||
"h264_qsv": QsvBuilder(),
|
||||
"h264_videotoolbox": AppleVideoToolboxBuilder(),
|
||||
"h264_vaapi": VaapiBuilder(),
|
||||
}.get(codec, soft)
|
||||
|
||||
|
||||
def encoder_fallback_chain(codec: str) -> tuple[str, ...]:
|
||||
"""
|
||||
Return a fallback chain for the given codec.
|
||||
|
||||
Args:
|
||||
codec (str): The concrete codec name.
|
||||
|
||||
Returns:
|
||||
tuple[str, ...]: A tuple of codec names representing the fallback chain.
|
||||
|
||||
"""
|
||||
chains: dict[str, list[str]] = {
|
||||
"h264_qsv": ["h264_vaapi", "libx264"],
|
||||
"h264_vaapi": ["libx264"],
|
||||
"h264_nvenc": ["libx264"],
|
||||
"h264_amf": ["libx264"],
|
||||
"h264_videotoolbox": ["libx264"],
|
||||
"libx264": [],
|
||||
}
|
||||
|
||||
return chains.get(codec, chains["libx264"])
|
||||
|
|
@ -2,19 +2,45 @@ import asyncio
|
|||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import subprocess # type: ignore
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, ClassVar
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from .config import Config
|
||||
from .ffprobe import ffprobe
|
||||
from .SegmentEncoders import (
|
||||
SUPPORTED_CODECS,
|
||||
encoder_fallback_chain,
|
||||
get_builder_for_codec,
|
||||
has_dri_devices,
|
||||
select_encoder,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from asyncio.subprocess import Process
|
||||
|
||||
from .ffprobe import FFProbeResult
|
||||
from .SegmentEncoders import EncoderBuilder
|
||||
|
||||
LOG: logging.Logger = logging.getLogger("player.segments")
|
||||
|
||||
|
||||
class Segments:
|
||||
"""
|
||||
Build and stream MPEG-TS segments using ffmpeg with optional on-the-fly
|
||||
transcoding. The class can auto-detect an available hardware encoder on the
|
||||
first video segment and cache that choice for subsequent segments to avoid
|
||||
repeated probing.
|
||||
"""
|
||||
|
||||
# Cache of selected video encoder across all segment instances
|
||||
_cached_vcodec: ClassVar[str | None] = None
|
||||
_cache_initialized: ClassVar[bool] = False
|
||||
|
||||
def __init__(self, download_path: str, index: int, duration: float, vconvert: bool, aconvert: bool):
|
||||
config: Config = Config.get_instance()
|
||||
self.download_path: str = download_path
|
||||
|
|
@ -27,6 +53,8 @@ class Segments:
|
|||
"Whether to convert video."
|
||||
self.aconvert = bool(aconvert)
|
||||
"Whether to convert audio."
|
||||
|
||||
# Default to configured codec; can be replaced by auto-detection
|
||||
self.vcodec: str = config.streamer_vcodec
|
||||
"The video codec to use."
|
||||
self.acodec: str = config.streamer_acodec
|
||||
|
|
@ -37,51 +65,78 @@ class Segments:
|
|||
"Whether to convert video."
|
||||
self.aconvert = True
|
||||
"Whether to convert audio."
|
||||
self.attempted: set[str] = set()
|
||||
"The set of attempted codecs."
|
||||
|
||||
async def build_ffmpeg_args(self, file: Path) -> list[str]:
|
||||
async def build_ffmpeg_args(self, file: Path, s_codec: str) -> list[str]:
|
||||
try:
|
||||
ff = await ffprobe(file)
|
||||
ff: FFProbeResult = await ffprobe(file)
|
||||
except UnicodeDecodeError:
|
||||
pass
|
||||
|
||||
tmpFile = Path(tempfile.gettempdir()).joinpath(
|
||||
tmpFile: Path = Path(tempfile.gettempdir()).joinpath(
|
||||
f"ytptube_stream.{hashlib.sha256(str(file).encode()).hexdigest()}"
|
||||
)
|
||||
|
||||
if not tmpFile.exists():
|
||||
tmpFile.symlink_to(file, target_is_directory=False)
|
||||
try:
|
||||
tmpFile.symlink_to(file, target_is_directory=False)
|
||||
except FileExistsError:
|
||||
pass
|
||||
|
||||
startTime: str = f"{0:.6f}" if self.index == 0 else f"{self.duration * self.index:.6f}"
|
||||
|
||||
fargs = [
|
||||
if self.vconvert and ff and hasattr(ff, "has_video") and ff.has_video():
|
||||
builder: EncoderBuilder = get_builder_for_codec(s_codec)
|
||||
builder_ctx: dict[str, Any] = {
|
||||
"is_linux": sys.platform.startswith("linux"),
|
||||
"has_dri": has_dri_devices(),
|
||||
"vaapi_device": Config.get_instance().vaapi_device,
|
||||
}
|
||||
else:
|
||||
builder = None
|
||||
builder_ctx = {}
|
||||
|
||||
# Collect encoder-specific input/global flags that must precede the input
|
||||
input_args: list[str] = []
|
||||
if builder:
|
||||
input_args = builder.input_args(
|
||||
{
|
||||
"is_linux": sys.platform.startswith("linux"),
|
||||
"has_dri": has_dri_devices(),
|
||||
"vaapi_device": Config.get_instance().vaapi_device,
|
||||
}
|
||||
)
|
||||
|
||||
fargs: list[str] = [
|
||||
"-xerror",
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
# input trimming before the input file
|
||||
"-ss",
|
||||
str(startTime),
|
||||
"-t",
|
||||
str(f"{self.duration:.6f}"),
|
||||
"-copyts",
|
||||
# hardware/global input options must come before -i
|
||||
*input_args,
|
||||
"-i",
|
||||
f"file:{tmpFile}",
|
||||
"-map_metadata",
|
||||
"-1",
|
||||
]
|
||||
|
||||
if ff and ff.has_video():
|
||||
fargs += [
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-g",
|
||||
"52",
|
||||
"-map",
|
||||
"0:v:0",
|
||||
"-strict",
|
||||
"-2",
|
||||
"-codec:v",
|
||||
self.vcodec if self.vconvert else "copy",
|
||||
]
|
||||
v_args: list[str] = []
|
||||
if builder:
|
||||
v_args = builder.add_video_args(
|
||||
["-g", "52", "-map", "0:v:0", "-strict", "-2"],
|
||||
builder_ctx,
|
||||
)
|
||||
else:
|
||||
v_args += ["-codec:v", "copy"]
|
||||
|
||||
fargs += v_args
|
||||
|
||||
if ff and ff.has_audio():
|
||||
fargs += ["-map", "0:a:0", "-codec:a", self.acodec if self.aconvert else "copy"]
|
||||
|
|
@ -89,36 +144,51 @@ class Segments:
|
|||
fargs += ["-sn", "-muxdelay", "0", "-f", "mpegts", "pipe:1"]
|
||||
return fargs
|
||||
|
||||
async def stream(self, file: Path, resp: web.StreamResponse):
|
||||
ffmpeg_args: list[str] = await self.build_ffmpeg_args(file)
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
async def _run(self, resp: web.StreamResponse, file: Path, args: list[str]) -> tuple[bool, int, bool, str]:
|
||||
proc: Process = await asyncio.create_subprocess_exec(
|
||||
"ffmpeg",
|
||||
*ffmpeg_args,
|
||||
*args,
|
||||
stdin=asyncio.subprocess.DEVNULL,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0,
|
||||
)
|
||||
|
||||
client_disconnected = False
|
||||
client_disconnected_local = False
|
||||
wrote_any = False
|
||||
stderr_buf = bytearray()
|
||||
|
||||
LOG.debug(f"Streaming '{file}' segment '{self.index}'. ffmpeg: {' '.join(ffmpeg_args)}")
|
||||
async def _drain_stderr() -> None:
|
||||
try:
|
||||
assert proc.stderr is not None
|
||||
while True:
|
||||
chunk: bytes = await proc.stderr.read(4096)
|
||||
if not chunk:
|
||||
break
|
||||
stderr_buf.extend(chunk)
|
||||
except Exception:
|
||||
# best-effort only
|
||||
pass
|
||||
|
||||
stderr_task: asyncio.Task[None] = asyncio.create_task(_drain_stderr())
|
||||
|
||||
LOG.debug(f"Streaming '{file}' segment '{self.index}'. ffmpeg: {' '.join(args)}")
|
||||
|
||||
try:
|
||||
while True:
|
||||
chunk: bytes = await proc.stdout.read(1024 * 64)
|
||||
if not chunk:
|
||||
break
|
||||
wrote_any = True
|
||||
try:
|
||||
await resp.write(chunk)
|
||||
except (asyncio.CancelledError, ConnectionResetError, BrokenPipeError, ConnectionError):
|
||||
LOG.warning("Client disconnected or connection reset while writing.")
|
||||
client_disconnected = True
|
||||
client_disconnected_local = True
|
||||
break
|
||||
except asyncio.CancelledError:
|
||||
LOG.warning("Client disconnected. Terminating ffmpeg.")
|
||||
client_disconnected = True
|
||||
client_disconnected_local = True
|
||||
proc.terminate()
|
||||
try:
|
||||
await asyncio.wait_for(proc.wait(), timeout=5)
|
||||
|
|
@ -128,11 +198,66 @@ class Segments:
|
|||
raise
|
||||
except ConnectionResetError:
|
||||
LOG.warning("Connection reset by peer. Skipping further writes.")
|
||||
client_disconnected = True
|
||||
client_disconnected_local = True
|
||||
finally:
|
||||
if not client_disconnected:
|
||||
# Ensure process is stopped when client disconnected, to avoid hangs
|
||||
if client_disconnected_local:
|
||||
proc.terminate()
|
||||
try:
|
||||
await resp.write_eof()
|
||||
except (ConnectionResetError, RuntimeError):
|
||||
LOG.warning("Failed to write EOF; client already disconnected.")
|
||||
await proc.wait()
|
||||
rc: int = await asyncio.wait_for(proc.wait(), timeout=5)
|
||||
except TimeoutError:
|
||||
LOG.error("ffmpeg process did not terminate in time after disconnect. Killing it.")
|
||||
proc.kill()
|
||||
try:
|
||||
rc = await asyncio.wait_for(proc.wait(), timeout=5)
|
||||
except Exception:
|
||||
rc = -1
|
||||
else:
|
||||
# Normal termination path with a safety timeout
|
||||
try:
|
||||
rc = await asyncio.wait_for(proc.wait(), timeout=30)
|
||||
except TimeoutError:
|
||||
LOG.error("ffmpeg process wait timed out. Killing it.")
|
||||
proc.kill()
|
||||
try:
|
||||
rc = await asyncio.wait_for(proc.wait(), timeout=5)
|
||||
except Exception:
|
||||
rc = -1
|
||||
try:
|
||||
await asyncio.wait_for(stderr_task, timeout=1)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return (wrote_any, rc, client_disconnected_local, stderr_buf.decode("utf-8", errors="ignore").strip())
|
||||
|
||||
async def stream(self, file: Path, resp: web.StreamResponse) -> None:
|
||||
codec: str = Segments._cached_vcodec if Segments._cache_initialized else self.vcodec
|
||||
if not codec or codec not in SUPPORTED_CODECS:
|
||||
codec: str = select_encoder(self.vcodec or "")
|
||||
|
||||
LOG.debug(f"Selected video codec '{codec}' for segment streaming.")
|
||||
|
||||
if Segments._cached_vcodec and Segments._cache_initialized:
|
||||
codecs: list[str] = [Segments._cached_vcodec]
|
||||
else:
|
||||
codecs: list[str] = [codec, *list(encoder_fallback_chain(codec))]
|
||||
|
||||
for s_codec in codecs:
|
||||
if s_codec in self.attempted:
|
||||
continue
|
||||
|
||||
ffmpeg_args: list[str] = await self.build_ffmpeg_args(file, s_codec)
|
||||
_, rc, client_disconnected, stderr_text = await self._run(resp, file, ffmpeg_args)
|
||||
|
||||
if 0 == rc:
|
||||
Segments._cached_vcodec = s_codec
|
||||
Segments._cache_initialized = True
|
||||
return
|
||||
|
||||
if client_disconnected:
|
||||
return
|
||||
|
||||
if 0 != rc:
|
||||
err: str = stderr_text[:500] if stderr_text else "no error output"
|
||||
LOG.warning(f"transcoding has failed (cmd={ffmpeg_args}) (rc={rc}): {err}. Trying fallbacks.")
|
||||
self.attempted.add(s_codec)
|
||||
|
|
|
|||
|
|
@ -8,13 +8,13 @@ from pysubs2.time import ms_to_times
|
|||
|
||||
from .Utils import ALLOWED_SUBS_EXTENSIONS
|
||||
|
||||
LOG = logging.getLogger("player.subtitle")
|
||||
LOG: logging.Logger = logging.getLogger("player.subtitle")
|
||||
|
||||
|
||||
def ms_to_timestamp(ms: int) -> str:
|
||||
ms = max(0, ms)
|
||||
h, m, s, ms = ms_to_times(ms)
|
||||
cs = ms // 10
|
||||
cs: int = ms // 10
|
||||
return f"{h:01d}:{m:02d}:{s:02d}.{cs:02d}"
|
||||
|
||||
|
||||
|
|
@ -24,7 +24,7 @@ SubstationFormat.ms_to_timestamp = ms_to_timestamp
|
|||
class Subtitle:
|
||||
async def make(self, file: Path) -> str:
|
||||
if file.suffix not in ALLOWED_SUBS_EXTENSIONS:
|
||||
msg = f"File '{file}' subtitle type is not supported."
|
||||
msg: str = f"File '{file}' subtitle type is not supported."
|
||||
raise Exception(msg)
|
||||
|
||||
if file.suffix == ".vtt":
|
||||
|
|
|
|||
|
|
@ -1322,7 +1322,7 @@ def str_to_dt(time_str: str, now=None) -> datetime:
|
|||
"""
|
||||
from dateparser import parse as _parse
|
||||
|
||||
dt = _parse(
|
||||
dt: datetime | None = _parse(
|
||||
time_str,
|
||||
settings={
|
||||
"RELATIVE_BASE": now or datetime.now(tz=UTC),
|
||||
|
|
@ -1332,7 +1332,7 @@ def str_to_dt(time_str: str, now=None) -> datetime:
|
|||
)
|
||||
|
||||
if dt is None:
|
||||
msg = f"Couldn't parse date: {time_str!r}"
|
||||
msg: str = f"Couldn't parse date: {time_str!r}"
|
||||
raise ValueError(msg)
|
||||
|
||||
return dt
|
||||
|
|
|
|||
|
|
@ -72,12 +72,15 @@ class Config(metaclass=Singleton):
|
|||
max_workers: int = 1
|
||||
"""The maximum number of workers to use for downloading."""
|
||||
|
||||
streamer_vcodec: str = "libx264"
|
||||
"""The video codec to use for streaming."""
|
||||
streamer_vcodec: str = ""
|
||||
"""The video codec to use for streaming. If empty, auto-detect."""
|
||||
|
||||
streamer_acodec: str = "aac"
|
||||
"""The audio codec to use for streaming."""
|
||||
|
||||
vaapi_device: str = "/dev/dri/renderD128"
|
||||
"""VAAPI device path used for VAAPI encoder when available."""
|
||||
|
||||
auth_username: str | None = None
|
||||
"""The username to use for basic authentication."""
|
||||
|
||||
|
|
|
|||
91
app/tests/test_backgroundworker.py
Normal file
91
app/tests/test_backgroundworker.py
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import threading
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from app.library.BackgroundWorker import BackgroundWorker
|
||||
|
||||
|
||||
class TestBackgroundWorker:
|
||||
def setup_method(self) -> None:
|
||||
BackgroundWorker._reset_singleton()
|
||||
|
||||
def teardown_method(self) -> None:
|
||||
# Attempt to stop any running worker thread
|
||||
try:
|
||||
worker = BackgroundWorker()
|
||||
if worker.thread and worker.thread.is_alive():
|
||||
worker.running = False
|
||||
worker.queue.put((BackgroundWorker, (), {}))
|
||||
worker.thread.join(timeout=2)
|
||||
except Exception:
|
||||
pass
|
||||
BackgroundWorker._reset_singleton()
|
||||
|
||||
def test_attach_starts_and_shutdown_stops(self) -> None:
|
||||
app = web.Application()
|
||||
worker = BackgroundWorker()
|
||||
|
||||
worker.attach(app)
|
||||
assert worker.thread is not None
|
||||
assert worker.thread.is_alive() is True
|
||||
|
||||
# Shutdown should stop the worker thread
|
||||
# on_shutdown is async; call via event loop runner
|
||||
# Using a small helper to run the coroutine
|
||||
import asyncio
|
||||
|
||||
try:
|
||||
asyncio.set_event_loop(asyncio.new_event_loop())
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
asyncio.get_event_loop().run_until_complete(worker.on_shutdown(app))
|
||||
|
||||
# Give a short moment for the background thread to exit
|
||||
worker.thread.join(timeout=2)
|
||||
assert worker.thread.is_alive() is False
|
||||
|
||||
def test_submit_executes_sync_function(self) -> None:
|
||||
app = web.Application()
|
||||
worker = BackgroundWorker()
|
||||
worker.attach(app)
|
||||
|
||||
done = threading.Event()
|
||||
received: dict[str, Any] = {}
|
||||
|
||||
def job(x: int, y: int) -> None:
|
||||
received["sum"] = x + y
|
||||
done.set()
|
||||
|
||||
worker.submit(job, 2, 3)
|
||||
|
||||
assert done.wait(timeout=2.0) is True
|
||||
assert received["sum"] == 5
|
||||
|
||||
# Cleanup
|
||||
import asyncio
|
||||
|
||||
asyncio.get_event_loop().run_until_complete(worker.on_shutdown(app))
|
||||
|
||||
def test_submit_executes_async_coroutine(self) -> None:
|
||||
app = web.Application()
|
||||
worker = BackgroundWorker()
|
||||
worker.attach(app)
|
||||
|
||||
done = threading.Event()
|
||||
|
||||
async def coro_task(flag: threading.Event) -> None:
|
||||
# Simulate a small async operation
|
||||
await asyncio.sleep(0)
|
||||
flag.set()
|
||||
|
||||
# Submit coroutine factory
|
||||
import asyncio
|
||||
|
||||
worker.submit(coro_task, done)
|
||||
|
||||
assert done.wait(timeout=2.0) is True
|
||||
|
||||
# Cleanup
|
||||
asyncio.get_event_loop().run_until_complete(worker.on_shutdown(app))
|
||||
157
app/tests/test_datastore.py
Normal file
157
app/tests/test_datastore.py
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import json
|
||||
import sqlite3
|
||||
from dataclasses import asdict
|
||||
from datetime import UTC, datetime
|
||||
from email.utils import formatdate
|
||||
|
||||
import pytest
|
||||
|
||||
from app.library.DataStore import DataStore, StoreType
|
||||
from app.library.ItemDTO import ItemDTO
|
||||
|
||||
|
||||
class StubDownload:
|
||||
def __init__(self, info: ItemDTO, started: bool = False, cancelled: bool = False):
|
||||
self.info = info
|
||||
self._started = started
|
||||
self._cancelled = cancelled
|
||||
|
||||
def started(self) -> bool:
|
||||
return self._started
|
||||
|
||||
def is_cancelled(self) -> bool:
|
||||
return self._cancelled
|
||||
|
||||
|
||||
def make_conn() -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute(
|
||||
"CREATE TABLE history (id TEXT PRIMARY KEY, type TEXT, url TEXT, data TEXT, created_at TEXT)"
|
||||
)
|
||||
return conn
|
||||
|
||||
|
||||
def make_item(id: str, url: str = "http://u", title: str = "t", folder: str = "f") -> ItemDTO:
|
||||
return ItemDTO(id=id, title=title, url=url, folder=folder)
|
||||
|
||||
|
||||
class TestStoreType:
|
||||
def test_all_and_from_value_and_str(self) -> None:
|
||||
assert set(StoreType.all()) == {"done", "queue"}
|
||||
assert StoreType.from_value("queue") is StoreType.QUEUE
|
||||
assert StoreType.from_value("done") is StoreType.HISTORY
|
||||
assert str(StoreType.QUEUE) == "queue"
|
||||
with pytest.raises(ValueError, match="Invalid StoreType value"):
|
||||
StoreType.from_value("invalid")
|
||||
|
||||
|
||||
class TestDataStore:
|
||||
def test_saved_items_parses_rows(self) -> None:
|
||||
conn = make_conn()
|
||||
# Prepare a stored item JSON (without _id, it will be set from the row)
|
||||
dto = make_item(id="ignore", url="http://x", title="Title", folder="F")
|
||||
data = asdict(dto)
|
||||
data.pop("_id", None)
|
||||
created = datetime(2024, 1, 2, 3, 4, 5, tzinfo=UTC)
|
||||
conn.execute(
|
||||
"INSERT INTO history (id, type, url, data, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||
("abc", "queue", "http://x", json.dumps(data), created.strftime("%Y-%m-%d %H:%M:%S")),
|
||||
)
|
||||
store = DataStore(StoreType.QUEUE, conn)
|
||||
|
||||
items = store.saved_items()
|
||||
assert len(items) == 1
|
||||
key, item = items[0]
|
||||
assert key == "abc"
|
||||
assert item._id == "abc"
|
||||
assert item.url == "http://x"
|
||||
assert isinstance(item.datetime, str)
|
||||
assert item.datetime == formatdate(created.timestamp())
|
||||
|
||||
def test_put_and_delete_persist(self) -> None:
|
||||
conn = make_conn()
|
||||
store = DataStore(StoreType.QUEUE, conn)
|
||||
|
||||
item = make_item(id="vid1")
|
||||
d = StubDownload(info=item)
|
||||
|
||||
ret = store.put(d)
|
||||
assert ret is store._dict[item._id]
|
||||
|
||||
# Verify row written; JSON should not contain datetime field
|
||||
row = conn.execute("SELECT * FROM history WHERE id=?", (item._id,)).fetchone()
|
||||
assert row is not None
|
||||
assert row["type"] == "queue"
|
||||
assert row["url"] == item.url
|
||||
assert '"datetime"' not in row["data"]
|
||||
|
||||
# Delete and ensure removal
|
||||
store.delete(item._id)
|
||||
row2 = conn.execute("SELECT * FROM history WHERE id=?", (item._id,)).fetchone()
|
||||
assert row2 is None
|
||||
|
||||
def test_exists_and_get(self) -> None:
|
||||
conn = make_conn()
|
||||
store = DataStore(StoreType.QUEUE, conn)
|
||||
|
||||
item = make_item(id="a1", url="http://u1")
|
||||
d = StubDownload(info=item)
|
||||
store.put(d)
|
||||
|
||||
assert store.exists(key=item._id) is True
|
||||
assert store.exists(url=item.url) is True
|
||||
with pytest.raises(KeyError):
|
||||
store.exists()
|
||||
|
||||
got = store.get(key=item._id)
|
||||
assert got.info._id == item._id
|
||||
got2 = store.get(url=item.url)
|
||||
assert got2.info.url == item.url
|
||||
with pytest.raises(KeyError):
|
||||
store.get()
|
||||
with pytest.raises(KeyError):
|
||||
store.get(key="missing")
|
||||
|
||||
def test_next_and_empty(self) -> None:
|
||||
conn = make_conn()
|
||||
store = DataStore(StoreType.QUEUE, conn)
|
||||
|
||||
assert store.empty() is True
|
||||
d1 = StubDownload(info=make_item(id="x1"))
|
||||
d2 = StubDownload(info=make_item(id="x2"))
|
||||
store.put(d1)
|
||||
store.put(d2)
|
||||
assert store.empty() is False
|
||||
first_key, first_val = store.next()
|
||||
assert first_key == d1.info._id
|
||||
assert first_val.info._id == d1.info._id
|
||||
|
||||
def test_has_downloads_and_get_next_download(self) -> None:
|
||||
conn = make_conn()
|
||||
store = DataStore(StoreType.QUEUE, conn)
|
||||
|
||||
# One non-auto-start, one started, one cancelled, and one eligible
|
||||
i1 = make_item(id="n1")
|
||||
i1.auto_start = False
|
||||
i2 = make_item(id="s1")
|
||||
i3 = make_item(id="c1")
|
||||
i4 = make_item(id="ok1")
|
||||
|
||||
store.put(StubDownload(info=i1, started=False))
|
||||
store.put(StubDownload(info=i2, started=True))
|
||||
store.put(StubDownload(info=i3, started=False, cancelled=True))
|
||||
store.put(StubDownload(info=i4, started=False))
|
||||
|
||||
assert store.has_downloads() is True
|
||||
nxt = store.get_next_download()
|
||||
assert isinstance(nxt, StubDownload)
|
||||
assert nxt.info._id == i4._id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_test_method_executes_query(self) -> None:
|
||||
conn = make_conn()
|
||||
store = DataStore(StoreType.QUEUE, conn)
|
||||
# Should not raise
|
||||
ok = await store.test()
|
||||
assert ok is True
|
||||
376
app/tests/test_download.py
Normal file
376
app/tests/test_download.py
Normal file
|
|
@ -0,0 +1,376 @@
|
|||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from app.library.Download import Download, NestedLogger, Terminator
|
||||
from app.library.ItemDTO import ItemDTO
|
||||
|
||||
|
||||
class CaptureHandler(logging.Handler):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.records: list[logging.LogRecord] = []
|
||||
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
self.records.append(record)
|
||||
|
||||
|
||||
def make_item(id: str = "id1", title: str = "T", url: str = "http://u", folder: str = "f") -> ItemDTO:
|
||||
return ItemDTO(id=id, title=title, url=url, folder=folder)
|
||||
|
||||
|
||||
class DummyQueue:
|
||||
def __init__(self) -> None:
|
||||
self.items: list[Any] = []
|
||||
|
||||
def put(self, obj: Any) -> None:
|
||||
self.items.append(obj)
|
||||
|
||||
def get(self) -> Any:
|
||||
if not self.items:
|
||||
return None
|
||||
return self.items.pop(0)
|
||||
|
||||
|
||||
class TestNestedLogger:
|
||||
def test_debug_maps_levels_and_strips_prefix(self) -> None:
|
||||
logger = logging.getLogger("nl_test")
|
||||
logger.setLevel(logging.DEBUG)
|
||||
cap = CaptureHandler()
|
||||
# Remove existing handlers to avoid duplicates
|
||||
for h in list(logger.handlers):
|
||||
logger.removeHandler(h)
|
||||
logger.addHandler(cap)
|
||||
|
||||
nl = NestedLogger(logger)
|
||||
nl.debug("[debug] detail")
|
||||
nl.debug("[download] progress")
|
||||
nl.debug("[info] info message")
|
||||
|
||||
# Two DEBUG, one INFO
|
||||
levels = [r.levelno for r in cap.records]
|
||||
assert levels.count(logging.DEBUG) == 2
|
||||
assert levels.count(logging.INFO) == 1
|
||||
msgs = [r.getMessage() for r in cap.records]
|
||||
assert "[debug]" not in msgs[0]
|
||||
# [download] prefix is not stripped by NestedLogger
|
||||
assert msgs[1] == "[download] progress"
|
||||
assert msgs[2] == "info message"
|
||||
|
||||
|
||||
class TestDownloadHooks:
|
||||
@pytest.fixture(autouse=True)
|
||||
def cfg_and_bus(self, monkeypatch: pytest.MonkeyPatch):
|
||||
# Minimal Config stub
|
||||
class Cfg:
|
||||
debug = False
|
||||
ytdlp_debug = False
|
||||
max_workers = 1
|
||||
temp_keep = False
|
||||
temp_disabled = True
|
||||
download_info_expires = 3600
|
||||
|
||||
@staticmethod
|
||||
def get_instance():
|
||||
return Cfg
|
||||
|
||||
monkeypatch.setattr("app.library.Download.Config", Cfg)
|
||||
# EventBus.get_instance is used during __init__ and start, we don't hit start here
|
||||
class EB:
|
||||
@staticmethod
|
||||
def get_instance():
|
||||
return EB
|
||||
|
||||
@staticmethod
|
||||
def emit(*_args, **_kwargs):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr("app.library.Download.EventBus", EB)
|
||||
|
||||
def test_progress_hook_filters_fields(self) -> None:
|
||||
d = Download(make_item())
|
||||
q = DummyQueue()
|
||||
d.status_queue = q
|
||||
|
||||
payload = {
|
||||
"tmpfilename": "t",
|
||||
"filename": "f",
|
||||
"status": "downloading",
|
||||
"msg": "m",
|
||||
"total_bytes": 10,
|
||||
"total_bytes_estimate": 12,
|
||||
"downloaded_bytes": 5,
|
||||
"speed": 1,
|
||||
"eta": 2,
|
||||
"other": "x",
|
||||
}
|
||||
d._progress_hook(payload)
|
||||
assert len(q.items) == 1
|
||||
ev = q.items[0]
|
||||
assert ev["id"] == d.id
|
||||
assert ev["action"] == "progress"
|
||||
# ensure only whitelisted keys included
|
||||
assert "other" not in ev
|
||||
for k in ("tmpfilename", "filename", "status", "msg", "total_bytes", "total_bytes_estimate", "downloaded_bytes", "speed", "eta"):
|
||||
assert k in ev
|
||||
|
||||
def test_postprocessor_hook_movefiles_sets_final_name(self, tmp_path: Path) -> None:
|
||||
d = Download(make_item())
|
||||
q = DummyQueue()
|
||||
d.status_queue = q
|
||||
|
||||
finaldir = tmp_path / "out"
|
||||
finaldir.mkdir()
|
||||
path = finaldir / "file.mp4"
|
||||
# we don't need it to exist for this branch; hook doesn't check file existence
|
||||
payload = {
|
||||
"postprocessor": "MoveFiles",
|
||||
"status": "finished",
|
||||
"info_dict": {"__finaldir": str(finaldir), "filepath": str(path)},
|
||||
}
|
||||
d._postprocessor_hook(payload)
|
||||
assert len(q.items) == 1
|
||||
ev = q.items[0]
|
||||
assert ev["action"] == "moved"
|
||||
assert ev["status"] == "finished"
|
||||
assert ev["final_name"].endswith("file.mp4")
|
||||
|
||||
def test_post_hooks_pushes_filename(self) -> None:
|
||||
d = Download(make_item())
|
||||
q = DummyQueue()
|
||||
d.status_queue = q
|
||||
d.post_hooks(None)
|
||||
assert len(q.items) == 0
|
||||
d.post_hooks("name.ext")
|
||||
assert len(q.items) == 1
|
||||
assert q.items[0]["filename"] == "name.ext"
|
||||
|
||||
|
||||
class TestDownloadStale:
|
||||
@pytest.fixture(autouse=True)
|
||||
def cfg_and_bus(self, monkeypatch: pytest.MonkeyPatch):
|
||||
class Cfg:
|
||||
debug = False
|
||||
ytdlp_debug = False
|
||||
max_workers = 1
|
||||
temp_keep = False
|
||||
temp_disabled = True
|
||||
download_info_expires = 3600
|
||||
|
||||
@staticmethod
|
||||
def get_instance():
|
||||
return Cfg
|
||||
|
||||
monkeypatch.setattr("app.library.Download.Config", Cfg)
|
||||
|
||||
class EB:
|
||||
@staticmethod
|
||||
def get_instance():
|
||||
return EB
|
||||
|
||||
@staticmethod
|
||||
def emit(*_args, **_kwargs):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr("app.library.Download.EventBus", EB)
|
||||
|
||||
def test_is_stale_conditions(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
d = Download(make_item())
|
||||
# Auto-start disabled is never stale
|
||||
d.info.auto_start = False
|
||||
assert d.is_stale() is False
|
||||
|
||||
d.info.auto_start = True
|
||||
# Not started yet -> not stale
|
||||
d.started_time = 0
|
||||
assert d.is_stale() is False
|
||||
|
||||
# Less than 300 seconds elapsed -> not stale
|
||||
d.started_time = 1000
|
||||
monkeypatch.setattr("time.time", lambda: 1200)
|
||||
assert d.is_stale() is False
|
||||
|
||||
# Not running after 300s -> stale
|
||||
d.started_time = 0
|
||||
d.started_time = 1000
|
||||
monkeypatch.setattr("time.time", lambda: 1401)
|
||||
monkeypatch.setattr("app.library.Download.Download.running", lambda _self: False)
|
||||
d.info.status = "preparing"
|
||||
assert d.is_stale() is True
|
||||
|
||||
# Running but status stuck -> stale
|
||||
monkeypatch.setattr("app.library.Download.Download.running", lambda _self: True)
|
||||
d.info.status = "queued"
|
||||
assert d.is_stale() is True
|
||||
|
||||
# Running and in allowed statuses -> not stale
|
||||
for s in ("finished", "error", "cancelled", "downloading", "postprocessing"):
|
||||
d.info.status = s
|
||||
assert d.is_stale() is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_initializes_and_emits(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
class Cfg:
|
||||
debug = False
|
||||
ytdlp_debug = False
|
||||
max_workers = 1
|
||||
temp_keep = False
|
||||
temp_disabled = False
|
||||
download_info_expires = 3600
|
||||
|
||||
@staticmethod
|
||||
def get_instance():
|
||||
return Cfg
|
||||
|
||||
class _Mgr:
|
||||
@staticmethod
|
||||
def Queue(): # noqa: N802
|
||||
return DummyQueue()
|
||||
|
||||
@staticmethod
|
||||
def get_manager():
|
||||
return Cfg._Mgr
|
||||
|
||||
monkeypatch.setattr("app.library.Download.Config", Cfg)
|
||||
|
||||
events = []
|
||||
|
||||
class EB:
|
||||
@staticmethod
|
||||
def get_instance():
|
||||
return EB
|
||||
|
||||
@staticmethod
|
||||
def emit(event, data=None, **_kwargs):
|
||||
events.append((event, data))
|
||||
|
||||
monkeypatch.setattr("app.library.Download.EventBus", EB)
|
||||
|
||||
class FakeProc:
|
||||
def __init__(self, name: str, target, *_args, **_kwargs):
|
||||
self.name = name
|
||||
self.target = target
|
||||
self.started = False
|
||||
|
||||
def start(self) -> None:
|
||||
self.started = True
|
||||
|
||||
def join(self) -> int:
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr("app.library.Download.multiprocessing.Process", FakeProc)
|
||||
|
||||
item = make_item(id="start1")
|
||||
d = Download(item)
|
||||
d.temp_dir = str(tmp_path)
|
||||
d.download_dir = str(tmp_path)
|
||||
|
||||
await d.start()
|
||||
|
||||
assert d.info.status == "preparing"
|
||||
assert isinstance(d.status_queue, DummyQueue)
|
||||
assert d.temp_path is not None
|
||||
assert Path(d.temp_path).exists()
|
||||
assert len(events) >= 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_progress_update_processes_statuses(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
class Cfg:
|
||||
debug = False
|
||||
ytdlp_debug = False
|
||||
max_workers = 1
|
||||
temp_keep = False
|
||||
temp_disabled = True
|
||||
download_info_expires = 3600
|
||||
|
||||
@staticmethod
|
||||
def get_instance():
|
||||
return Cfg
|
||||
|
||||
monkeypatch.setattr("app.library.Download.Config", Cfg)
|
||||
|
||||
class EB:
|
||||
@staticmethod
|
||||
def get_instance():
|
||||
return EB
|
||||
|
||||
@staticmethod
|
||||
def emit(*_a, **_kw):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr("app.library.Download.EventBus", EB)
|
||||
|
||||
seen = []
|
||||
|
||||
async def spy(_self, status):
|
||||
seen.append(status)
|
||||
|
||||
d = Download(make_item(id="p1"))
|
||||
d.status_queue = DummyQueue()
|
||||
monkeypatch.setattr(Download, "_process_status_update", spy, raising=False)
|
||||
d.status_queue.put({"id": d.id, "status": "downloading"})
|
||||
d.status_queue.put({"id": d.id, "status": "postprocessing"})
|
||||
d.status_queue.put(Terminator())
|
||||
|
||||
await d.progress_update()
|
||||
|
||||
assert [s["status"] for s in seen] == ["downloading", "postprocessing"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test__process_status_update_finish_sets_fields(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
class Cfg:
|
||||
debug = False
|
||||
ytdlp_debug = False
|
||||
max_workers = 1
|
||||
temp_keep = False
|
||||
temp_disabled = True
|
||||
download_info_expires = 3600
|
||||
|
||||
@staticmethod
|
||||
def get_instance():
|
||||
return Cfg
|
||||
|
||||
monkeypatch.setattr("app.library.Download.Config", Cfg)
|
||||
|
||||
class EB:
|
||||
@staticmethod
|
||||
def get_instance():
|
||||
return EB
|
||||
|
||||
@staticmethod
|
||||
def emit(*_a, **_kw):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr("app.library.Download.EventBus", EB)
|
||||
|
||||
class FF:
|
||||
def has_video(self):
|
||||
return True
|
||||
|
||||
def has_audio(self):
|
||||
return True
|
||||
|
||||
metadata = {"duration": "7.9"}
|
||||
|
||||
async def fake_ffprobe(_p):
|
||||
return FF()
|
||||
|
||||
monkeypatch.setattr("app.library.Download.ffprobe", fake_ffprobe)
|
||||
|
||||
d = Download(make_item(id="f1"))
|
||||
d.download_dir = str(tmp_path)
|
||||
path = Path(tmp_path) / "file.bin"
|
||||
path.write_bytes(b"x" * 10)
|
||||
|
||||
await d._process_status_update({"id": d.id, "status": "finished", "final_name": str(path)})
|
||||
|
||||
assert d.info.file_size == 10
|
||||
assert d.info.extras["is_video"] is True
|
||||
assert d.info.extras["is_audio"] is True
|
||||
assert d.info.extras["duration"] == int(7.9)
|
||||
assert isinstance(d.info.datetime, str)
|
||||
167
app/tests/test_itemdto.py
Normal file
167
app/tests/test_itemdto.py
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.library.ItemDTO import Item, ItemDTO
|
||||
|
||||
|
||||
class TestItemFormatAndBasics:
|
||||
@patch("app.library.Presets.Presets.get_instance")
|
||||
def test_format_validates_and_normalizes(self, mock_presets_get):
|
||||
# Preset exists and is not default => allowed
|
||||
mock_presets_get.return_value.has.return_value = True
|
||||
|
||||
data = {
|
||||
"url": "dQw4w9WgXcQ", # 11-char YouTube ID
|
||||
"preset": "custom",
|
||||
"folder": "media",
|
||||
"cookies": "abc",
|
||||
"template": "%(title)s.%(ext)s",
|
||||
"auto_start": False,
|
||||
"extras": {"k": 1},
|
||||
"requeued": True,
|
||||
"cli": "--embed-metadata",
|
||||
}
|
||||
with (
|
||||
patch("app.library.ItemDTO.Item._default_preset", return_value="default"),
|
||||
patch("app.library.Utils.arg_converter") as mock_arg_conv,
|
||||
):
|
||||
mock_arg_conv.return_value = None
|
||||
item = Item.format(data)
|
||||
|
||||
assert isinstance(item, Item)
|
||||
# URL normalized to full YouTube URL
|
||||
assert item.url.startswith("https://www.youtube.com/watch?v=")
|
||||
assert item.preset == "custom"
|
||||
assert item.folder == "media"
|
||||
assert item.cookies == "abc"
|
||||
assert item.template == "%(title)s.%(ext)s"
|
||||
assert item.auto_start is False
|
||||
assert item.extras == {"k": 1}
|
||||
assert item.requeued is True
|
||||
assert item.cli == "--embed-metadata"
|
||||
|
||||
@patch("app.library.Presets.Presets.get_instance")
|
||||
def test_format_raises_for_missing_url_and_invalid_preset(self, mock_presets_get):
|
||||
# Missing url
|
||||
with pytest.raises(ValueError, match="url param is required"):
|
||||
Item.format({})
|
||||
|
||||
# Invalid preset name (not found)
|
||||
mock_presets_get.return_value.has.return_value = False
|
||||
with (
|
||||
patch("app.library.ItemDTO.Item._default_preset", return_value="default"),
|
||||
pytest.raises(ValueError, match="Preset 'bad' does not exist"),
|
||||
):
|
||||
Item.format({"url": "https://example.com", "preset": "bad"})
|
||||
|
||||
@patch("app.library.Utils.arg_converter")
|
||||
def test_format_cli_parse_error(self, mock_arg_conv):
|
||||
mock_arg_conv.side_effect = RuntimeError("bad cli")
|
||||
with pytest.raises(ValueError, match="Failed to parse command options"):
|
||||
Item.format({"url": "https://example.com", "cli": "--bad"})
|
||||
|
||||
def test_item_helpers(self):
|
||||
item = Item(url="https://example.com", extras={"a": 1}, cli="--x")
|
||||
assert item.has_extras() is True
|
||||
assert item.has_cli() is True
|
||||
assert item.get("url") == "https://example.com"
|
||||
assert "url" in item.serialize()
|
||||
assert json.loads(item.json())["url"] == "https://example.com"
|
||||
|
||||
@patch("app.library.ItemDTO.get_archive_id")
|
||||
def test_item_archive_id_and_is_archived(self, mock_get_id):
|
||||
mock_get_id.return_value = {"archive_id": "x", "id": "x", "ie_key": "k"}
|
||||
|
||||
# get_archive_id
|
||||
item = Item(url="https://example.com")
|
||||
assert item.get_archive_id() == "x"
|
||||
|
||||
# is_archived uses archive_read through get_archive_file + ytdlp opts
|
||||
with (
|
||||
patch("app.library.ItemDTO.YTDLPOpts") as mock_opts,
|
||||
patch("app.library.ItemDTO.archive_read") as mock_read,
|
||||
):
|
||||
mock_opts.get_instance.return_value.preset.return_value = mock_opts.get_instance.return_value
|
||||
mock_opts.get_instance.return_value.add_cli.return_value = mock_opts.get_instance.return_value
|
||||
mock_opts.get_instance.return_value.get_all.return_value = {"download_archive": "/tmp/archive.txt"}
|
||||
mock_read.return_value = ["x"]
|
||||
|
||||
assert item.is_archived() is True
|
||||
|
||||
|
||||
class TestItemDTO:
|
||||
@patch("app.library.ItemDTO.get_archive_id")
|
||||
@patch("app.library.ItemDTO.YTDLPOpts")
|
||||
@patch("app.library.ItemDTO.archive_read")
|
||||
def test_post_init_sets_archive_flags(self, mock_read, mock_opts, mock_get_id):
|
||||
# Setup archive id and archive file
|
||||
mock_get_id.return_value = {"archive_id": "arch", "id": "arch", "ie_key": "YT"}
|
||||
mock_opts.get_instance.return_value.preset.return_value = mock_opts.get_instance.return_value
|
||||
mock_opts.get_instance.return_value.add_cli.return_value = mock_opts.get_instance.return_value
|
||||
mock_opts.get_instance.return_value.get_all.return_value = {"download_archive": "/tmp/a.txt"}
|
||||
mock_read.return_value = ["arch"]
|
||||
|
||||
dto = ItemDTO(id="vid", title="t", url="u", folder="f")
|
||||
|
||||
assert dto.archive_id == "arch"
|
||||
assert dto._archive_file == "/tmp/a.txt"
|
||||
assert dto.is_archivable is True
|
||||
assert dto.is_archived is True
|
||||
|
||||
@patch("app.library.ItemDTO.archive_read")
|
||||
def test_serialize_triggers_archive_status_when_finished(self, mock_read):
|
||||
# Given a finished item with archive info
|
||||
dto = ItemDTO(id="vid", title="t", url="u", folder="f")
|
||||
dto.archive_id = "arch"
|
||||
dto._archive_file = "/tmp/a.txt"
|
||||
dto.status = "finished"
|
||||
mock_read.return_value = ["arch"]
|
||||
|
||||
data = dto.serialize()
|
||||
assert data["is_archived"] is True
|
||||
# Removed fields must not be present
|
||||
for key in ItemDTO.removed_fields():
|
||||
assert key not in data
|
||||
|
||||
@patch("app.library.ItemDTO.YTDLPOpts")
|
||||
def test_get_ytdlp_opts_uses_preset_and_cli(self, mock_opts):
|
||||
mock_opts.get_instance.return_value.preset.return_value = mock_opts.get_instance.return_value
|
||||
mock_opts.get_instance.return_value.add_cli.return_value = mock_opts.get_instance.return_value
|
||||
|
||||
dto = ItemDTO(id="id", title="t", url="u", folder="f", preset="p", cli="--x")
|
||||
opts = dto.get_ytdlp_opts()
|
||||
|
||||
mock_opts.get_instance.assert_called_once()
|
||||
mock_opts.get_instance.return_value.preset.assert_called_once_with(name="p")
|
||||
mock_opts.get_instance.return_value.add_cli.assert_called_once()
|
||||
assert opts is mock_opts.get_instance.return_value
|
||||
|
||||
def test_name_and_ids(self):
|
||||
dto = ItemDTO(id="abc", title="Title", url="u", folder="f")
|
||||
assert dto.name() == 'id="abc", title="Title"'
|
||||
assert isinstance(dto.get_id(), str)
|
||||
|
||||
def test_archive_add_and_delete_paths(self):
|
||||
dto = ItemDTO(id="id", title="t", url="u", folder="f")
|
||||
# Precondition not met yet
|
||||
assert dto.archive_add() is False
|
||||
|
||||
# Set up to allow add
|
||||
dto.archive_id = "arch"
|
||||
dto._archive_file = "/tmp/a.txt"
|
||||
dto.is_archivable = True
|
||||
dto.is_archived = False
|
||||
|
||||
with patch("app.library.ItemDTO.archive_add", return_value=True) as m_add:
|
||||
ok = dto.archive_add()
|
||||
assert ok is True
|
||||
m_add.assert_called_once()
|
||||
|
||||
# Delete requires is_archived True
|
||||
dto.is_archived = True
|
||||
with patch("app.library.ItemDTO.archive_delete") as m_del:
|
||||
ok2 = dto.archive_delete()
|
||||
assert ok2 is True
|
||||
m_del.assert_called_once()
|
||||
98
app/tests/test_logwrapper.py
Normal file
98
app/tests/test_logwrapper.py
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import logging
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from app.library.LogWrapper import LogWrapper
|
||||
|
||||
|
||||
class CaptureHandler(logging.Handler):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.records: list[logging.LogRecord] = []
|
||||
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
self.records.append(record)
|
||||
|
||||
|
||||
def make_logger(name: str = "lw_test") -> tuple[logging.Logger, CaptureHandler]:
|
||||
logger = logging.getLogger(name)
|
||||
logger.setLevel(logging.DEBUG)
|
||||
handler = CaptureHandler()
|
||||
# Avoid duplicate handlers when tests run multiple times
|
||||
for h in list(logger.handlers):
|
||||
logger.removeHandler(h)
|
||||
logger.addHandler(handler)
|
||||
return logger, handler
|
||||
|
||||
|
||||
class TestLogWrapper:
|
||||
def test_add_target_type_validation(self) -> None:
|
||||
lw = LogWrapper()
|
||||
with pytest.raises(TypeError, match=r"Target must be a logging\.Logger instance or a callable"):
|
||||
lw.add_target(123) # type: ignore[arg-type]
|
||||
|
||||
def test_add_target_name_inference_and_custom(self) -> None:
|
||||
lw = LogWrapper()
|
||||
logger, _ = make_logger("one")
|
||||
|
||||
# Name inferred from logger
|
||||
lw.add_target(logger)
|
||||
assert lw.targets[-1].name == "one"
|
||||
assert lw.has_targets() is True
|
||||
|
||||
# Name inferred from callable
|
||||
def sink(level: int, msg: str, *args: Any, **kwargs: Any) -> None: # noqa: ARG001
|
||||
return None
|
||||
|
||||
lw.add_target(sink)
|
||||
assert lw.targets[-1].name == "sink"
|
||||
|
||||
# Custom name overrides
|
||||
lw.add_target(logger, name="custom")
|
||||
assert lw.targets[-1].name == "custom"
|
||||
|
||||
def test_level_filtering_and_dispatch(self) -> None:
|
||||
lw = LogWrapper()
|
||||
logger, cap = make_logger("cap")
|
||||
calls: list[tuple[int, str, tuple, dict]] = []
|
||||
|
||||
def sink(level: int, msg: str, *args: Any, **kwargs: Any) -> None:
|
||||
calls.append((level, msg, args, kwargs))
|
||||
|
||||
# Logger target at INFO, callable target at WARNING
|
||||
lw.add_target(logger, level=logging.INFO)
|
||||
lw.add_target(sink, level=logging.WARNING)
|
||||
|
||||
# DEBUG should hit none
|
||||
lw.debug("d1")
|
||||
assert len(cap.records) == 0
|
||||
assert len(calls) == 0
|
||||
|
||||
# INFO hits logger only
|
||||
lw.info("hello %s", "X")
|
||||
assert len(cap.records) == 1
|
||||
assert cap.records[0].levelno == logging.INFO
|
||||
assert cap.records[0].getMessage() == "hello X"
|
||||
assert len(calls) == 0
|
||||
|
||||
# WARNING hits both
|
||||
lw.warning("warn %s", "Y", extra={"k": 1})
|
||||
assert len(cap.records) == 2
|
||||
assert cap.records[1].levelno == logging.WARNING
|
||||
assert cap.records[1].getMessage() == "warn Y"
|
||||
assert len(calls) == 1
|
||||
lvl, msg, args, kwargs = calls[0]
|
||||
assert lvl == logging.WARNING
|
||||
assert msg == "warn %s"
|
||||
assert args == ("Y",)
|
||||
assert "extra" in kwargs
|
||||
assert kwargs["extra"] == {"k": 1}
|
||||
|
||||
# ERROR still hits both; CRITICAL too
|
||||
lw.error("err")
|
||||
lw.critical("boom")
|
||||
assert any(r.levelno == logging.ERROR for r in cap.records)
|
||||
assert any(r.levelno == logging.CRITICAL for r in cap.records)
|
||||
assert any(c[0] == logging.ERROR for c in calls)
|
||||
assert any(c[0] == logging.CRITICAL for c in calls)
|
||||
146
app/tests/test_m3u8.py
Normal file
146
app/tests/test_m3u8.py
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from urllib.parse import quote
|
||||
|
||||
import pytest
|
||||
|
||||
from app.library.M3u8 import M3u8
|
||||
from app.library.Utils import StreamingError
|
||||
|
||||
|
||||
class _Stream:
|
||||
def __init__(self, v: bool, a: bool, codec: str):
|
||||
self.codec_name = codec
|
||||
self._v = v
|
||||
self._a = a
|
||||
|
||||
def is_video(self) -> bool:
|
||||
return self._v
|
||||
|
||||
def is_audio(self) -> bool:
|
||||
return self._a
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_make_stream_basic_ok_codecs(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
base = tmp_path / "dl"
|
||||
base.mkdir()
|
||||
media = base / "dir with space" / "file.mp4"
|
||||
media.parent.mkdir()
|
||||
media.write_text("x")
|
||||
|
||||
async def fake_ffprobe(_file: Path):
|
||||
return SimpleNamespace(
|
||||
metadata={"duration": "12"},
|
||||
streams=lambda: [
|
||||
_Stream(v=True, a=False, codec="h264"),
|
||||
_Stream(v=False, a=True, codec="aac"),
|
||||
],
|
||||
)
|
||||
|
||||
monkeypatch.setattr("app.library.M3u8.ffprobe", fake_ffprobe)
|
||||
|
||||
m3 = M3u8(download_path=base, url="http://host/")
|
||||
out = await m3.make_stream(media)
|
||||
lines = out.splitlines()
|
||||
|
||||
# Headers
|
||||
assert lines[0] == "#EXTM3U"
|
||||
assert lines[1] == "#EXT-X-VERSION:3"
|
||||
assert lines[2] == "#EXT-X-TARGETDURATION:6"
|
||||
assert lines[3] == "#EXT-X-MEDIA-SEQUENCE:0"
|
||||
assert lines[4] == "#EXT-X-PLAYLIST-TYPE:VOD"
|
||||
|
||||
# Two segments: 0 (no params), 1 (sd present)
|
||||
rel = quote(str(Path("dir with space/file.mp4")))
|
||||
assert lines[5] == "#EXTINF:6.000000,"
|
||||
assert lines[6] == f"http://host/api/player/segments/0/{rel}.ts"
|
||||
|
||||
assert lines[7] == "#EXTINF:6.000000,"
|
||||
assert lines[8].startswith(f"http://host/api/player/segments/1/{rel}.ts?")
|
||||
assert "sd=6.000000" in lines[8]
|
||||
|
||||
assert lines[9] == "#EXT-X-ENDLIST"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_make_stream_transcode_flags_and_remainder(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
base = tmp_path / "dl"
|
||||
base.mkdir()
|
||||
media = base / "v.mp4"
|
||||
media.write_text("x")
|
||||
|
||||
async def fake_ffprobe(_file: Path):
|
||||
return SimpleNamespace(
|
||||
metadata={"duration": "13"},
|
||||
streams=lambda: [
|
||||
_Stream(v=True, a=False, codec="hevc"),
|
||||
_Stream(v=False, a=True, codec="flac"),
|
||||
],
|
||||
)
|
||||
|
||||
monkeypatch.setattr("app.library.M3u8.ffprobe", fake_ffprobe)
|
||||
|
||||
m3 = M3u8(download_path=base, url="https://s/")
|
||||
out = await m3.make_stream(media)
|
||||
lines = out.splitlines()
|
||||
|
||||
# 3 segments, last has 1.000000 duration and includes vc/ac + sd
|
||||
rel = quote(str(Path("v.mp4")))
|
||||
# First
|
||||
assert lines[5] == "#EXTINF:6.000000,"
|
||||
assert lines[6].startswith(f"https://s/api/player/segments/0/{rel}.ts?")
|
||||
assert "vc=1" in lines[6]
|
||||
assert "ac=1" in lines[6]
|
||||
# Second
|
||||
assert lines[7] == "#EXTINF:6.000000,"
|
||||
assert lines[8].startswith(f"https://s/api/player/segments/1/{rel}.ts?")
|
||||
assert "vc=1" in lines[8]
|
||||
assert "ac=1" in lines[8]
|
||||
# Third
|
||||
assert lines[9] == "#EXTINF:1.000000,"
|
||||
assert lines[10].startswith(f"https://s/api/player/segments/2/{rel}.ts?")
|
||||
assert "vc=1" in lines[10]
|
||||
assert "ac=1" in lines[10]
|
||||
assert "sd=1.000000" in lines[10]
|
||||
assert lines[11] == "#EXT-X-ENDLIST"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_make_stream_raises_without_duration(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
base = tmp_path / "dl"
|
||||
base.mkdir()
|
||||
media = base / "v.mp4"
|
||||
media.write_text("x")
|
||||
|
||||
async def fake_ffprobe(_file: Path):
|
||||
return SimpleNamespace(metadata={})
|
||||
|
||||
monkeypatch.setattr("app.library.M3u8.ffprobe", fake_ffprobe)
|
||||
|
||||
m3 = M3u8(download_path=base, url="http://s/")
|
||||
with pytest.raises(StreamingError, match="Unable to get"):
|
||||
await m3.make_stream(media)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_make_subtitle(tmp_path: Path) -> None:
|
||||
base = tmp_path / "dl"
|
||||
base.mkdir()
|
||||
sub = base / "dir" / "cap.srt"
|
||||
sub.parent.mkdir()
|
||||
sub.write_text("x")
|
||||
|
||||
m3 = M3u8(download_path=base, url="http://h/")
|
||||
out = await m3.make_subtitle(sub, duration=42.5)
|
||||
lines = out.splitlines()
|
||||
|
||||
assert lines[0] == "#EXTM3U"
|
||||
assert lines[1] == "#EXT-X-VERSION:3"
|
||||
assert lines[2] == "#EXT-X-TARGETDURATION:6"
|
||||
assert lines[3] == "#EXT-X-MEDIA-SEQUENCE:0"
|
||||
assert lines[4] == "#EXT-X-PLAYLIST-TYPE:VOD"
|
||||
assert lines[5] == "#EXTINF:42.5,"
|
||||
rel = quote(str(Path("dir/cap.srt")))
|
||||
assert lines[6] == f"http://h/api/player/subtitle/{rel}.vtt"
|
||||
assert lines[7] == "#EXT-X-ENDLIST"
|
||||
270
app/tests/test_package_installer.py
Normal file
270
app/tests/test_package_installer.py
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.library.PackageInstaller import PackageInstaller, Packages, parse_version
|
||||
|
||||
|
||||
class TestParseVersion:
|
||||
def test_parse_version_basic(self) -> None:
|
||||
assert parse_version("1.2.3") == (1, 2, 3)
|
||||
assert parse_version("01.002.0003") == (1, 2, 3)
|
||||
|
||||
def test_parse_version_with_chars(self) -> None:
|
||||
# Non-digits are stripped per part
|
||||
assert parse_version("1a.2b.3c") == (1, 2, 3)
|
||||
assert parse_version("2025.07.21") == (2025, 7, 21)
|
||||
|
||||
|
||||
class TestPackages:
|
||||
def test_packages_from_env_and_file(self, tmp_path: Path) -> None:
|
||||
req = tmp_path / "req.txt"
|
||||
req.write_text("\nfoo\nbar==1.0.0\nfoo\n\n")
|
||||
|
||||
pkgs = Packages(env="baz qux", file=str(req), upgrade=True)
|
||||
|
||||
# Order not guaranteed (set), but content should be unique
|
||||
assert set(pkgs.packages) == {"foo", "bar==1.0.0", "baz", "qux"}
|
||||
assert pkgs.has_packages() is True
|
||||
assert pkgs.allow_upgrade() is True
|
||||
|
||||
def test_packages_empty(self) -> None:
|
||||
pkgs = Packages(env=None, file=None, upgrade=False)
|
||||
assert pkgs.has_packages() is False
|
||||
assert pkgs.allow_upgrade() is False
|
||||
|
||||
|
||||
class TestPackageInstallerInit:
|
||||
def test_init_with_explicit_path_adds_to_sys_path(self, tmp_path: Path) -> None:
|
||||
p = tmp_path / "site"
|
||||
p.mkdir()
|
||||
|
||||
# Snapshot sys.path length to verify insertion
|
||||
original_len = len(sys.path)
|
||||
installer = PackageInstaller(pkg_path=p)
|
||||
|
||||
assert installer.user_site == p
|
||||
assert sys.path[0] == str(p)
|
||||
assert len(sys.path) == original_len + 1
|
||||
|
||||
def test_init_with_env_var(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("YTP_CONFIG_PATH", str(tmp_path))
|
||||
installer = PackageInstaller(pkg_path=None)
|
||||
|
||||
assert installer.user_site is not None
|
||||
assert installer.user_site.exists() is True
|
||||
assert str(installer.user_site) in sys.path
|
||||
|
||||
def test_init_without_path_or_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("YTP_CONFIG_PATH", raising=False)
|
||||
installer = PackageInstaller(pkg_path=None)
|
||||
# No user_site is set when no path or env provided
|
||||
assert installer.user_site is None
|
||||
|
||||
|
||||
class TestVersionCompare:
|
||||
def test_compare_versions_equal(self, tmp_path: Path) -> None:
|
||||
inst = PackageInstaller(pkg_path=tmp_path)
|
||||
assert inst.compare_versions("1.2.3", "1.2.3") is True
|
||||
|
||||
def test_compare_versions_yt_dlp_like_padding(self, tmp_path: Path) -> None:
|
||||
inst = PackageInstaller(pkg_path=tmp_path)
|
||||
assert inst.compare_versions("2025.7.21", "2025.07.21") is True
|
||||
assert inst.compare_versions("2025.07.1", "2025.7.01") is True
|
||||
|
||||
def test_compare_versions_not_equal(self, tmp_path: Path) -> None:
|
||||
inst = PackageInstaller(pkg_path=tmp_path)
|
||||
assert inst.compare_versions("1.2.3", "1.2.4") is False
|
||||
|
||||
|
||||
class TestInstalledAndLatest:
|
||||
@patch("app.library.PackageInstaller.importlib.metadata.version")
|
||||
def test_get_installed_version(self, mock_version, tmp_path: Path) -> None:
|
||||
inst = PackageInstaller(pkg_path=tmp_path)
|
||||
mock_version.return_value = "1.0.0"
|
||||
assert inst._get_installed_version("foo") == "1.0.0"
|
||||
|
||||
@patch("app.library.PackageInstaller.importlib.metadata.version")
|
||||
def test_get_installed_version_not_found(self, mock_version, tmp_path: Path) -> None:
|
||||
inst = PackageInstaller(pkg_path=tmp_path)
|
||||
from importlib.metadata import PackageNotFoundError
|
||||
|
||||
mock_version.side_effect = PackageNotFoundError
|
||||
assert inst._get_installed_version("bar") is None
|
||||
|
||||
@patch("app.library.PackageInstaller.httpx.Client")
|
||||
def test_get_latest_version_success(self, mock_client, tmp_path: Path) -> None:
|
||||
inst = PackageInstaller(pkg_path=tmp_path)
|
||||
|
||||
client = MagicMock()
|
||||
resp = MagicMock()
|
||||
resp.status_code = 200
|
||||
resp.json.return_value = {"info": {"version": "9.9.9"}}
|
||||
client.get.return_value = resp
|
||||
mock_client.return_value.__enter__.return_value = client
|
||||
|
||||
assert inst._get_latest_version("foo") == "9.9.9"
|
||||
|
||||
@patch("app.library.PackageInstaller.httpx.Client")
|
||||
def test_get_latest_version_non_200(self, mock_client, tmp_path: Path) -> None:
|
||||
inst = PackageInstaller(pkg_path=tmp_path)
|
||||
client = MagicMock()
|
||||
resp = MagicMock()
|
||||
resp.status_code = 404
|
||||
client.get.return_value = resp
|
||||
mock_client.return_value.__enter__.return_value = client
|
||||
assert inst._get_latest_version("foo") is None
|
||||
|
||||
@patch("app.library.PackageInstaller.httpx.Client")
|
||||
def test_get_latest_version_exception(self, mock_client, tmp_path: Path) -> None:
|
||||
inst = PackageInstaller(pkg_path=tmp_path)
|
||||
mock_client.side_effect = RuntimeError("boom")
|
||||
assert inst._get_latest_version("foo") is None
|
||||
|
||||
|
||||
class TestInstallCmd:
|
||||
@patch("app.library.PackageInstaller.subprocess.run")
|
||||
def test_install_default_latest(self, mock_run, tmp_path: Path) -> None:
|
||||
inst = PackageInstaller(pkg_path=tmp_path)
|
||||
# Simulate successful run
|
||||
mock_run.return_value = SimpleNamespace(returncode=0, stdout=b"out", stderr=b"err")
|
||||
|
||||
ok = inst._install_pkg("pkg")
|
||||
|
||||
assert ok is True
|
||||
cmd = mock_run.call_args.kwargs["args"] if "args" in mock_run.call_args.kwargs else mock_run.call_args.args[0]
|
||||
assert cmd[:5] == [sys.executable, "-m", "pip", "install", "--no-warn-script-location"]
|
||||
assert "--disable-pip-version-check" in cmd
|
||||
assert "pkg" in cmd
|
||||
assert "--target" in cmd
|
||||
assert str(inst.user_site) in cmd
|
||||
|
||||
@patch("app.library.PackageInstaller.subprocess.run")
|
||||
def test_install_pinned_version(self, mock_run, tmp_path: Path) -> None:
|
||||
inst = PackageInstaller(pkg_path=tmp_path)
|
||||
mock_run.return_value = SimpleNamespace(returncode=0, stdout=b"o", stderr=b"e")
|
||||
|
||||
ok = inst._install_pkg("pkg", version="1.2.3")
|
||||
assert ok is True
|
||||
cmd = mock_run.call_args.args[0]
|
||||
assert "pkg==1.2.3" in cmd
|
||||
|
||||
@patch("app.library.PackageInstaller.subprocess.run")
|
||||
def test_install_git_url_version(self, mock_run, tmp_path: Path) -> None:
|
||||
inst = PackageInstaller(pkg_path=tmp_path)
|
||||
mock_run.return_value = SimpleNamespace(returncode=0, stdout=b"o", stderr=b"e")
|
||||
|
||||
ok = inst._install_pkg("pkg", version="git+https://example/repo.git@abc")
|
||||
assert ok is True
|
||||
cmd = mock_run.call_args.args[0]
|
||||
assert "git+https://example/repo.git@abc" in cmd
|
||||
|
||||
@patch("app.library.PackageInstaller.subprocess.run")
|
||||
def test_install_yt_dlp_nightly(self, mock_run, tmp_path: Path) -> None:
|
||||
inst = PackageInstaller(pkg_path=tmp_path)
|
||||
mock_run.return_value = SimpleNamespace(returncode=0, stdout=b"o", stderr=b"e")
|
||||
|
||||
ok = inst._install_pkg("yt_dlp", version="nightly")
|
||||
assert ok is True
|
||||
cmd = mock_run.call_args.args[0]
|
||||
# should include pre-release flag and yt-dlp extra
|
||||
assert "--pre" in cmd
|
||||
assert "yt-dlp[default]" in cmd
|
||||
|
||||
@patch("app.library.PackageInstaller.subprocess.run")
|
||||
def test_install_yt_dlp_master(self, mock_run, tmp_path: Path) -> None:
|
||||
inst = PackageInstaller(pkg_path=tmp_path)
|
||||
mock_run.return_value = SimpleNamespace(returncode=0, stdout=b"o", stderr=b"e")
|
||||
|
||||
ok = inst._install_pkg("yt_dlp", version="master")
|
||||
assert ok is True
|
||||
cmd = mock_run.call_args.args[0]
|
||||
assert "git+https://github.com/yt-dlp/yt-dlp.git@master" in cmd
|
||||
|
||||
@patch("app.library.PackageInstaller.subprocess.run")
|
||||
def test_install_called_process_error(self, mock_run, tmp_path: Path) -> None:
|
||||
inst = PackageInstaller(pkg_path=tmp_path)
|
||||
|
||||
err = SimpleNamespace(returncode=1, stdout=b"o", stderr=b"e")
|
||||
|
||||
class _CPEError(Exception):
|
||||
def __init__(self) -> None:
|
||||
self.returncode = err.returncode
|
||||
self.stdout = err.stdout
|
||||
self.stderr = err.stderr
|
||||
super().__init__("fail")
|
||||
|
||||
mock_run.side_effect = _CPEError()
|
||||
|
||||
with pytest.raises(_CPEError, match="fail"):
|
||||
inst._install_pkg("pkg")
|
||||
|
||||
|
||||
class TestActionAndCheck:
|
||||
@patch.object(PackageInstaller, "_install_pkg")
|
||||
@patch.object(PackageInstaller, "_get_installed_version")
|
||||
def test_action_skips_when_same_pinned(self, mock_get_installed, mock_install, tmp_path: Path) -> None:
|
||||
inst = PackageInstaller(pkg_path=tmp_path)
|
||||
mock_get_installed.return_value = "1.2.3"
|
||||
# compare_versions normal equality should hold
|
||||
inst.action("pkg==1.2.3")
|
||||
mock_install.assert_not_called()
|
||||
|
||||
@patch.object(PackageInstaller, "_install_pkg")
|
||||
@patch.object(PackageInstaller, "_get_installed_version")
|
||||
@patch.object(PackageInstaller, "_get_latest_version")
|
||||
def test_action_upgrade_skip_when_latest(self, mock_get_latest, mock_get_installed, mock_install, tmp_path: Path) -> None:
|
||||
inst = PackageInstaller(pkg_path=tmp_path)
|
||||
mock_get_installed.return_value = "2.0.0"
|
||||
mock_get_latest.return_value = "2.0.0"
|
||||
inst.action("pkg", upgrade=True)
|
||||
mock_install.assert_not_called()
|
||||
|
||||
@patch.object(PackageInstaller, "_install_pkg")
|
||||
@patch.object(PackageInstaller, "_get_installed_version")
|
||||
@patch.object(PackageInstaller, "_get_latest_version")
|
||||
def test_action_upgrade_runs_when_newer_available(self, mock_get_latest, mock_get_installed, mock_install, tmp_path: Path) -> None:
|
||||
inst = PackageInstaller(pkg_path=tmp_path)
|
||||
mock_get_installed.return_value = "1.0.0"
|
||||
mock_get_latest.return_value = "1.1.0"
|
||||
inst.action("pkg", upgrade=True)
|
||||
mock_install.assert_called_once_with("pkg", version=None)
|
||||
|
||||
@patch.object(PackageInstaller, "_install_pkg")
|
||||
@patch.object(PackageInstaller, "_get_installed_version")
|
||||
def test_action_install_when_not_installed(self, mock_get_installed, mock_install, tmp_path: Path) -> None:
|
||||
inst = PackageInstaller(pkg_path=tmp_path)
|
||||
mock_get_installed.return_value = None
|
||||
inst.action("pkg")
|
||||
mock_install.assert_called_once_with("pkg", version=None)
|
||||
|
||||
def test_check_with_no_packages_or_no_user_site(self, tmp_path: Path) -> None:
|
||||
# No packages
|
||||
inst = PackageInstaller(pkg_path=tmp_path)
|
||||
pkgs = Packages(env=None, file=None, upgrade=False)
|
||||
inst.check(pkgs) # Should do nothing
|
||||
|
||||
# No user_site => early return
|
||||
inst2 = PackageInstaller(pkg_path=None)
|
||||
pkgs2 = Packages(env="a b", file=None, upgrade=False)
|
||||
inst2.check(pkgs2)
|
||||
|
||||
@patch.object(PackageInstaller, "action")
|
||||
def test_check_calls_action_and_handles_errors(self, mock_action, tmp_path: Path) -> None:
|
||||
inst = PackageInstaller(pkg_path=tmp_path)
|
||||
pkgs = Packages(env="foo bar", file=None, upgrade=True)
|
||||
|
||||
# First call raises, second succeeds
|
||||
def side_effect(pkg, upgrade=False): # noqa: ARG001
|
||||
if pkg == "foo":
|
||||
msg = "boom"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
mock_action.side_effect = side_effect
|
||||
|
||||
# Should not raise
|
||||
inst.check(pkgs)
|
||||
assert mock_action.call_count == 2
|
||||
116
app/tests/test_playlist.py
Normal file
116
app/tests/test_playlist.py
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from urllib.parse import quote
|
||||
|
||||
import pytest
|
||||
|
||||
from app.library.Playlist import Playlist
|
||||
from app.library.Utils import StreamingError
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_make_playlist_no_subtitles(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# Setup paths
|
||||
base = tmp_path / "downloads"
|
||||
base.mkdir()
|
||||
# Use a relative subpath with spaces to validate quoting behavior
|
||||
media = base / "My Video.mp4"
|
||||
media.write_text("x")
|
||||
|
||||
# Mock ffprobe to return duration
|
||||
async def fake_ffprobe(_file: Path):
|
||||
return SimpleNamespace(metadata={"duration": "60"})
|
||||
|
||||
monkeypatch.setattr("app.library.Playlist.ffprobe", fake_ffprobe)
|
||||
# No sidecar subtitles
|
||||
monkeypatch.setattr("app.library.Playlist.get_file_sidecar", lambda _f: {"subtitle": []})
|
||||
|
||||
# Patch module-level quote to be robust for Path objects
|
||||
from urllib.parse import quote as _std_quote
|
||||
monkeypatch.setattr("app.library.Playlist.quote", lambda v: _std_quote(str(v)))
|
||||
|
||||
pl = Playlist(download_path=base, url="http://localhost/")
|
||||
out = await pl.make(media)
|
||||
|
||||
lines = out.splitlines()
|
||||
assert lines[0] == "#EXTM3U"
|
||||
# No subtitles group when none present
|
||||
assert lines[1] == "#EXT-X-STREAM-INF:PROGRAM-ID=1"
|
||||
# Final line should point to video endpoint with quoted relative path
|
||||
expected_ref = quote(str(Path("My Video.mp4")))
|
||||
assert lines[2] == f"http://localhost/api/player/m3u8/video/{expected_ref}.m3u8"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_make_playlist_with_subtitles(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
base = tmp_path / "downloads"
|
||||
base.mkdir()
|
||||
media = base / "dir" / "file.mp4"
|
||||
media.parent.mkdir()
|
||||
media.write_text("x")
|
||||
|
||||
# ffprobe returns numeric duration
|
||||
async def fake_ffprobe(_file: Path):
|
||||
return SimpleNamespace(metadata={"duration": "12.5"})
|
||||
|
||||
monkeypatch.setattr("app.library.Playlist.ffprobe", fake_ffprobe)
|
||||
|
||||
# Build two subtitle sidecars with names and langs; ensure quoting/relative name used
|
||||
sub1 = media.with_suffix(".en.srt")
|
||||
sub2 = media.with_name("another sub.srt")
|
||||
sidecar = {
|
||||
"subtitle": [
|
||||
{"lang": "en", "file": sub1, "name": "English"},
|
||||
{"lang": "fr", "file": sub2, "name": "French"},
|
||||
]
|
||||
}
|
||||
monkeypatch.setattr("app.library.Playlist.get_file_sidecar", lambda _f: sidecar)
|
||||
|
||||
from urllib.parse import quote as _std_quote
|
||||
monkeypatch.setattr("app.library.Playlist.quote", lambda v: _std_quote(str(v)))
|
||||
|
||||
pl = Playlist(download_path=base, url="https://server/")
|
||||
out = await pl.make(media)
|
||||
|
||||
lines = out.splitlines()
|
||||
assert lines[0] == "#EXTM3U"
|
||||
# Two EXT-X-MEDIA lines for subtitles
|
||||
assert lines[1].startswith("#EXT-X-MEDIA:TYPE=SUBTITLES,")
|
||||
assert 'NAME="English"' in lines[1]
|
||||
assert 'LANGUAGE="en"' in lines[1]
|
||||
assert "duration=12.5" in lines[1]
|
||||
# URI uses the file name relative to base, replacing the name with sidecar file name and quoted
|
||||
expected_uri1 = quote(str(Path("dir").joinpath(sub1.name)))
|
||||
assert f"/subtitle/{expected_uri1}.m3u8" in lines[1]
|
||||
|
||||
assert lines[2].startswith("#EXT-X-MEDIA:TYPE=SUBTITLES,")
|
||||
assert 'NAME="French"' in lines[2]
|
||||
assert 'LANGUAGE="fr"' in lines[2]
|
||||
expected_uri2 = quote(str(Path("dir").joinpath(sub2.name)))
|
||||
assert f"/subtitle/{expected_uri2}.m3u8" in lines[2]
|
||||
|
||||
# Stream info with SUBTITLES group
|
||||
assert lines[3] == '#EXT-X-STREAM-INF:PROGRAM-ID=1,SUBTITLES="subs"'
|
||||
# Final video URL
|
||||
expected_ref = quote(str(Path("dir/file.mp4")))
|
||||
assert lines[4] == f"https://server/api/player/m3u8/video/{expected_ref}.m3u8"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_make_playlist_raises_without_duration(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
base = tmp_path / "downloads"
|
||||
base.mkdir()
|
||||
media = base / "file.mp4"
|
||||
media.write_text("x")
|
||||
|
||||
# ffprobe missing duration
|
||||
async def fake_ffprobe(_file: Path):
|
||||
return SimpleNamespace(metadata={})
|
||||
|
||||
monkeypatch.setattr("app.library.Playlist.ffprobe", fake_ffprobe)
|
||||
monkeypatch.setattr("app.library.Playlist.get_file_sidecar", lambda _f: {"subtitle": []})
|
||||
|
||||
pl = Playlist(download_path=base, url="http://localhost/")
|
||||
|
||||
with pytest.raises(StreamingError, match="Unable to get"):
|
||||
await pl.make(media)
|
||||
130
app/tests/test_router.py
Normal file
130
app/tests/test_router.py
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import pytest
|
||||
|
||||
from app.library.router import ROUTES, Route, RouteType, add_route, get_route, get_routes, make_route_name, route
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_routes():
|
||||
# Ensure ROUTES is clean before each test
|
||||
ROUTES.clear()
|
||||
yield
|
||||
ROUTES.clear()
|
||||
|
||||
|
||||
class TestRouteType:
|
||||
def test_all_returns_values(self) -> None:
|
||||
assert set(RouteType.all()) == {"http", "socket"}
|
||||
|
||||
|
||||
class TestMakeRouteName:
|
||||
def test_basic_http_path(self) -> None:
|
||||
assert make_route_name("GET", "/api/test") == "get:api.test"
|
||||
|
||||
def test_trailing_slash_and_root(self) -> None:
|
||||
# Current behavior converts empty part to 'part'
|
||||
assert make_route_name("post", "/") == "post:part"
|
||||
assert make_route_name("post", "") == "post:part"
|
||||
|
||||
def test_invalid_chars_and_numbers(self) -> None:
|
||||
# invalid chars become underscores, leading digits prefixed with p_
|
||||
assert make_route_name("GET", "/a-b/c@d/123/0x-ff") == "get:a_b.c_d.p_123.p_0x_ff"
|
||||
|
||||
|
||||
class TestRouteDecorator:
|
||||
@pytest.mark.asyncio
|
||||
async def test_registers_http_and_no_slash_alias(self) -> None:
|
||||
# Define an async handler and decorate it
|
||||
result_bucket: dict[str, int] = {"called": 0}
|
||||
|
||||
@route("GET", "/api/test/")
|
||||
async def handler() -> str:
|
||||
result_bucket["called"] += 1
|
||||
return "ok"
|
||||
|
||||
# Two routes should be registered: with slash and _no_slash alias
|
||||
http_routes = get_routes(RouteType.HTTP)
|
||||
assert "get:api.test" in http_routes
|
||||
assert "get:api.test_no_slash" in http_routes
|
||||
|
||||
# Verify stored Route objects
|
||||
r1: Route = http_routes["get:api.test"]
|
||||
r2: Route = http_routes["get:api.test_no_slash"]
|
||||
assert r1.method == "GET"
|
||||
assert r1.path == "/api/test/"
|
||||
assert r2.method == "GET"
|
||||
assert r2.path == "/api/test"
|
||||
|
||||
# The wrapper should call the original function
|
||||
res = await r1.handler()
|
||||
assert res == "ok"
|
||||
assert result_bucket["called"] == 1
|
||||
|
||||
# Check that metadata is preserved by wraps
|
||||
assert r1.handler.__name__ == handler.__name__
|
||||
|
||||
def test_decorator_no_slash_disabled(self) -> None:
|
||||
@route("GET", "/api/one/", no_slash=True)
|
||||
async def h1():
|
||||
return "one"
|
||||
|
||||
http_routes = get_routes(RouteType.HTTP)
|
||||
assert "get:api.one" in http_routes
|
||||
assert "get:api.one_no_slash" not in http_routes
|
||||
|
||||
def test_socket_route_registration(self) -> None:
|
||||
@route(RouteType.SOCKET, "/ws/conn")
|
||||
async def ws():
|
||||
return "socket"
|
||||
|
||||
socket_routes = get_routes(RouteType.SOCKET)
|
||||
assert "socket:ws.conn" in socket_routes
|
||||
# No no_slash alias for socket routes
|
||||
assert "socket:ws.conn_no_slash" not in socket_routes
|
||||
|
||||
|
||||
class TestAddRoute:
|
||||
def test_add_route_http_with_alias(self) -> None:
|
||||
async def handler():
|
||||
return "ok"
|
||||
|
||||
add_route("POST", "/api/create/", handler)
|
||||
|
||||
http_routes = get_routes(RouteType.HTTP)
|
||||
assert "post:api.create" in http_routes
|
||||
assert "post:api.create_no_slash" in http_routes
|
||||
|
||||
r = get_route(RouteType.HTTP, "post:api.create")
|
||||
assert isinstance(r, Route)
|
||||
assert r.method == "POST"
|
||||
assert r.path == "/api/create/"
|
||||
|
||||
def test_add_route_socket_without_alias(self) -> None:
|
||||
async def s():
|
||||
return "s"
|
||||
|
||||
add_route(RouteType.SOCKET, "/sock/path/", s)
|
||||
|
||||
socket_routes = get_routes(RouteType.SOCKET)
|
||||
assert "socket:sock.path" in socket_routes
|
||||
assert "socket:sock.path_no_slash" not in socket_routes
|
||||
|
||||
def test_add_route_custom_name(self) -> None:
|
||||
async def h():
|
||||
return "x"
|
||||
|
||||
add_route("GET", "/v1/x", h, name="get:v1.custom")
|
||||
assert get_route(RouteType.HTTP, "get:v1.custom") is not None
|
||||
|
||||
|
||||
class TestGetters:
|
||||
def test_get_routes_returns_copy_like_mapping(self) -> None:
|
||||
async def h():
|
||||
return "x"
|
||||
|
||||
add_route("GET", "/x", h)
|
||||
routes = get_routes(RouteType.HTTP)
|
||||
assert isinstance(routes, dict)
|
||||
assert "get:x" in routes
|
||||
|
||||
def test_get_route_not_found(self) -> None:
|
||||
assert get_route(RouteType.HTTP, "nonexistent") is None
|
||||
289
app/tests/test_scheduler.py
Normal file
289
app/tests/test_scheduler.py
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
import asyncio
|
||||
import sys
|
||||
import types
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# Ensure aiocron module exists to allow importing Scheduler without external dep
|
||||
if "aiocron" not in sys.modules:
|
||||
aiocron_stub = types.ModuleType("aiocron")
|
||||
|
||||
class _CronImportStub: # Minimal placeholder; tests will patch real behavior per-test
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def stop(self) -> None:
|
||||
pass
|
||||
|
||||
@property
|
||||
def uuid(self) -> str:
|
||||
return "stub-uuid"
|
||||
|
||||
aiocron_stub.Cron = _CronImportStub
|
||||
sys.modules["aiocron"] = aiocron_stub
|
||||
|
||||
|
||||
from app.library.Events import EventBus, Events
|
||||
from app.library.Scheduler import Scheduler
|
||||
|
||||
|
||||
class DummyCron:
|
||||
"""Simple Cron stub to capture construction and allow stop()."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
spec: str,
|
||||
func: callable,
|
||||
args: tuple = (),
|
||||
kwargs: dict | None = None,
|
||||
uuid: str | None = None,
|
||||
start: bool | None = None,
|
||||
loop: asyncio.AbstractEventLoop | None = None,
|
||||
) -> None:
|
||||
self.spec = spec
|
||||
self.func = func
|
||||
self.args = args
|
||||
self.kwargs = kwargs or {}
|
||||
self._uuid = uuid or "generated-uuid"
|
||||
self.start = start
|
||||
self.loop = loop
|
||||
self.stopped = False
|
||||
|
||||
@property
|
||||
def uuid(self) -> str:
|
||||
return self._uuid
|
||||
|
||||
def stop(self) -> None:
|
||||
self.stopped = True
|
||||
|
||||
|
||||
class FailingCron(DummyCron):
|
||||
def stop(self) -> None:
|
||||
msg = "stop failed"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
|
||||
class TestScheduler:
|
||||
"""Tests for the Scheduler singleton and behavior."""
|
||||
|
||||
def setup_method(self) -> None:
|
||||
# Reset singletons between tests
|
||||
Scheduler._reset_singleton()
|
||||
EventBus._reset_singleton()
|
||||
|
||||
def teardown_method(self) -> None:
|
||||
Scheduler._reset_singleton()
|
||||
EventBus._reset_singleton()
|
||||
|
||||
def test_singleton_behavior(self) -> None:
|
||||
s1 = Scheduler()
|
||||
s2 = Scheduler()
|
||||
s3 = Scheduler.get_instance()
|
||||
|
||||
assert s1 is s2 is s3
|
||||
|
||||
@patch("app.library.Scheduler.Cron", new=DummyCron)
|
||||
def test_add_creates_and_stores_job(self) -> None:
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
sched = Scheduler(loop=loop)
|
||||
|
||||
def fn(a: int, b: int) -> int: # noqa: ARG001
|
||||
return 42
|
||||
|
||||
job_id = sched.add(
|
||||
timer="*/5 * * * *",
|
||||
func=fn,
|
||||
args=(1, 2),
|
||||
kwargs={"x": 3},
|
||||
id="job1",
|
||||
)
|
||||
|
||||
assert job_id == "job1"
|
||||
assert sched.has("job1") is True
|
||||
|
||||
job = sched.get("job1")
|
||||
assert isinstance(job, DummyCron)
|
||||
assert job.spec == "*/5 * * * *"
|
||||
assert job.args == (1, 2)
|
||||
assert job.kwargs == {"x": 3}
|
||||
assert job.loop is loop
|
||||
assert job.start is True
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
@patch("app.library.Scheduler.Cron", new=DummyCron)
|
||||
def test_add_replaces_existing_job(self) -> None:
|
||||
sched = Scheduler()
|
||||
|
||||
# Seed with an existing job
|
||||
old = DummyCron(spec="* * * * *", func=lambda: None, uuid="job1", start=True, loop=sched._loop)
|
||||
sched._jobs["job1"] = old
|
||||
|
||||
# Replace with a new one
|
||||
new_id = sched.add(timer="*/2 * * * *", func=lambda: None, id="job1")
|
||||
|
||||
assert new_id == "job1"
|
||||
assert sched.has("job1") is True
|
||||
new_job = sched.get("job1")
|
||||
assert new_job is not old
|
||||
# Old job should have been stopped via remove()
|
||||
assert old.stopped is True
|
||||
|
||||
@patch("app.library.Scheduler.Cron", new=DummyCron)
|
||||
def test_remove_single_job_success(self) -> None:
|
||||
sched = Scheduler()
|
||||
cron = DummyCron(spec="* * * * *", func=lambda: None, uuid="jobA", start=True, loop=sched._loop)
|
||||
sched._jobs["jobA"] = cron
|
||||
|
||||
result = sched.remove("jobA")
|
||||
|
||||
assert result is True
|
||||
assert cron.stopped is True
|
||||
assert sched.has("jobA") is False
|
||||
|
||||
@patch("app.library.Scheduler.Cron", new=FailingCron)
|
||||
def test_remove_single_job_failure_on_stop(self) -> None:
|
||||
sched = Scheduler()
|
||||
cron = FailingCron(spec="* * * * *", func=lambda: None, uuid="jobB", start=True, loop=sched._loop)
|
||||
sched._jobs["jobB"] = cron
|
||||
|
||||
result = sched.remove("jobB")
|
||||
|
||||
assert result is False
|
||||
# Job should remain since stop failed
|
||||
assert sched.has("jobB") is True
|
||||
|
||||
@patch("app.library.Scheduler.Cron", new=DummyCron)
|
||||
def test_remove_list_of_jobs(self) -> None:
|
||||
sched = Scheduler()
|
||||
for jid in ("j1", "j2", "j3"):
|
||||
sched._jobs[jid] = DummyCron(spec="* * * * *", func=lambda: None, uuid=jid, start=True, loop=sched._loop)
|
||||
|
||||
result = sched.remove(["j1", "j2", "j3"])
|
||||
|
||||
assert result is True
|
||||
assert all(not sched.has(j) for j in ("j1", "j2", "j3"))
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("app.library.Scheduler.Cron", new=DummyCron)
|
||||
async def test_on_shutdown_stops_and_clears_jobs(self) -> None:
|
||||
from aiohttp import web
|
||||
|
||||
sched = Scheduler()
|
||||
a = DummyCron(spec="* * * * *", func=lambda: None, uuid="a", start=True, loop=sched._loop)
|
||||
b = DummyCron(spec="* * * * *", func=lambda: None, uuid="b", start=True, loop=sched._loop)
|
||||
sched._jobs = {"a": a, "b": b}
|
||||
|
||||
await sched.on_shutdown(web.Application())
|
||||
|
||||
assert a.stopped is True
|
||||
assert b.stopped is True
|
||||
assert len(sched.get_all()) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("app.library.Scheduler.Cron", new=DummyCron)
|
||||
async def test_attach_registers_shutdown_and_handles_schedule_add_event(self) -> None:
|
||||
from aiohttp import web
|
||||
|
||||
app = web.Application()
|
||||
sched = Scheduler()
|
||||
sched.attach(app)
|
||||
|
||||
# on_shutdown handler should be registered
|
||||
assert Scheduler.on_shutdown in [cb.__func__ if hasattr(cb, "__func__") else cb for cb in app.on_shutdown]
|
||||
|
||||
# Patch add to verify it is called from event handler
|
||||
add_spy = MagicMock(wraps=sched.add)
|
||||
# Bind spy to the instance
|
||||
sched.add = add_spy
|
||||
|
||||
# Emit schedule add event
|
||||
EventBus.get_instance().emit(
|
||||
Events.SCHEDULE_ADD,
|
||||
data={
|
||||
"timer": "*/3 * * * *",
|
||||
"func": lambda: None,
|
||||
"args": (1,),
|
||||
"kwargs": {"k": "v"},
|
||||
"id": "evt-job",
|
||||
},
|
||||
)
|
||||
|
||||
# Allow event loop to schedule and run handler
|
||||
await asyncio.sleep(0.02)
|
||||
|
||||
add_spy.assert_called_once()
|
||||
kwargs = add_spy.call_args.kwargs
|
||||
assert kwargs["timer"] == "*/3 * * * *"
|
||||
assert callable(kwargs["func"]) is True
|
||||
assert kwargs["args"] == (1,)
|
||||
assert kwargs["kwargs"] == {"k": "v"}
|
||||
assert kwargs["id"] == "evt-job"
|
||||
|
||||
@patch("app.library.Scheduler.Cron")
|
||||
def test_add_executes_function_when_cron_runs(self, cron_patch) -> None:
|
||||
# Cron stub that auto-runs the function on creation when start=True
|
||||
class AutoRunCron(DummyCron):
|
||||
def __init__(self, *_, spec: str, func: callable, args: tuple = (), kwargs: dict | None = None, uuid: str | None = None, start: bool | None = None, loop: asyncio.AbstractEventLoop | None = None):
|
||||
super().__init__(spec=spec, func=func, args=args, kwargs=kwargs, uuid=uuid, start=start, loop=loop)
|
||||
if start:
|
||||
# Simulate immediate execution
|
||||
self.func(*self.args, **self.kwargs)
|
||||
|
||||
cron_patch.side_effect = AutoRunCron
|
||||
|
||||
sched = Scheduler()
|
||||
ran: dict[str, Any] = {"count": 0, "last": None}
|
||||
|
||||
def job_func(x: int, y: int, label: str = "") -> None:
|
||||
ran["count"] += 1
|
||||
ran["last"] = (x, y, label)
|
||||
|
||||
_ = sched.add(timer="*/1 * * * *", func=job_func, args=(2, 3), kwargs={"label": "ok"}, id="run1")
|
||||
|
||||
assert ran["count"] == 1
|
||||
assert ran["last"] == (2, 3, "ok")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("app.library.Scheduler.Cron")
|
||||
async def test_event_schedule_runs_function(self, cron_patch) -> None:
|
||||
# Cron stub that auto-runs the function on creation
|
||||
class AutoRunCron(DummyCron):
|
||||
def __init__(self, *_, spec: str, func: callable, args: tuple = (), kwargs: dict | None = None, uuid: str | None = None, start: bool | None = None, loop: asyncio.AbstractEventLoop | None = None):
|
||||
super().__init__(spec=spec, func=func, args=args, kwargs=kwargs, uuid=uuid, start=start, loop=loop)
|
||||
if start:
|
||||
self.func(*self.args, **self.kwargs)
|
||||
|
||||
cron_patch.side_effect = AutoRunCron
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
app = web.Application()
|
||||
sched = Scheduler()
|
||||
sched.attach(app)
|
||||
|
||||
bucket: list[tuple[int, str]] = []
|
||||
|
||||
def job(val: int, tag: str) -> None:
|
||||
bucket.append((val, tag))
|
||||
|
||||
# Emit event that should cause add() and immediate execution via AutoRunCron
|
||||
EventBus.get_instance().emit(
|
||||
Events.SCHEDULE_ADD,
|
||||
data={
|
||||
"timer": "*/1 * * * *",
|
||||
"func": job,
|
||||
"args": (7,),
|
||||
"kwargs": {"tag": "evt"},
|
||||
"id": "evt-run",
|
||||
},
|
||||
)
|
||||
|
||||
# Give the event handler a tick to run
|
||||
await asyncio.sleep(0.02)
|
||||
|
||||
assert bucket == [(7, "evt")]
|
||||
407
app/tests/test_segments.py
Normal file
407
app/tests/test_segments.py
Normal file
|
|
@ -0,0 +1,407 @@
|
|||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from app.library.Segments import Segments
|
||||
|
||||
|
||||
class DummyFF:
|
||||
def __init__(self, v: bool, a: bool) -> None:
|
||||
self._v: bool = v
|
||||
self._a: bool = a
|
||||
|
||||
def has_video(self) -> bool:
|
||||
return self._v
|
||||
|
||||
def has_audio(self) -> bool:
|
||||
return self._a
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_ffmpeg_args_video_and_audio(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# Create a dummy media file
|
||||
media = tmp_path / "file.mp4"
|
||||
media.write_bytes(b"data")
|
||||
|
||||
# Patch ffprobe to report both video and audio
|
||||
async def fake_ffprobe(_file: Path):
|
||||
return DummyFF(v=True, a=True)
|
||||
|
||||
monkeypatch.setattr("app.library.Segments.ffprobe", fake_ffprobe)
|
||||
|
||||
seg = Segments(download_path=str(tmp_path), index=2, duration=5.5, vconvert=False, aconvert=False)
|
||||
|
||||
captured_args: list[list[str]] = []
|
||||
|
||||
class _EmptyProc(_FakeProc):
|
||||
def __init__(self) -> None:
|
||||
super().__init__([b""])
|
||||
|
||||
async def fake_create_subprocess_exec(*args: Any, **_kwargs: Any):
|
||||
captured_args.append(list(args[1:]))
|
||||
return _EmptyProc()
|
||||
|
||||
monkeypatch.setattr("asyncio.create_subprocess_exec", fake_create_subprocess_exec)
|
||||
|
||||
await seg.stream(media, _FakeResp())
|
||||
|
||||
assert captured_args, "ffmpeg was not invoked"
|
||||
args = captured_args[0]
|
||||
|
||||
# Compute expected symlink path used by Segments
|
||||
tmpFile = Path(tempfile.gettempdir()).joinpath(f"ytptube_stream.{hashlib.sha256(str(media).encode()).hexdigest()}")
|
||||
|
||||
# Start time is duration * index with 6 decimals for non-zero index
|
||||
assert "-ss" in args
|
||||
assert args[args.index("-ss") + 1] == f"{5.5 * 2:.6f}"
|
||||
# Duration formatting
|
||||
assert "-t" in args
|
||||
assert args[args.index("-t") + 1] == f"{5.5:.6f}"
|
||||
# Input uses file:<symlink>
|
||||
assert "-i" in args
|
||||
assert args[args.index("-i") + 1] == f"file:{tmpFile}"
|
||||
# Includes video and audio mapping and codecs
|
||||
assert "-map" in args
|
||||
assert "0:v:0" in args
|
||||
assert "-codec:v" in args
|
||||
assert "-codec:a" in args
|
||||
# Output format: ensure -f mpegts and pipe:1 present
|
||||
assert args[-1] == "pipe:1"
|
||||
assert "-f" in args
|
||||
assert args[args.index("-f") + 1] == "mpegts"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_ffmpeg_args_audio_only(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
media = tmp_path / "file.mp3"
|
||||
media.write_bytes(b"data")
|
||||
|
||||
async def fake_ffprobe(_file: Path):
|
||||
return DummyFF(v=False, a=True)
|
||||
|
||||
monkeypatch.setattr("app.library.Segments.ffprobe", fake_ffprobe)
|
||||
|
||||
seg = Segments(download_path=str(tmp_path), index=0, duration=9.0, vconvert=False, aconvert=False)
|
||||
|
||||
captured_args: list[list[str]] = []
|
||||
|
||||
class _EmptyProc2(_FakeProc):
|
||||
def __init__(self) -> None:
|
||||
super().__init__([b""])
|
||||
|
||||
async def fake_create_subprocess_exec2(*args: Any, **_kwargs: Any):
|
||||
captured_args.append(list(args[1:]))
|
||||
return _EmptyProc2()
|
||||
|
||||
monkeypatch.setattr("asyncio.create_subprocess_exec", fake_create_subprocess_exec2)
|
||||
|
||||
await seg.stream(media, _FakeResp())
|
||||
|
||||
assert captured_args, "ffmpeg was not invoked"
|
||||
args = captured_args[0]
|
||||
|
||||
# Start at 0 for index 0
|
||||
assert "-ss" in args
|
||||
assert args[args.index("-ss") + 1] == f"{0:.6f}"
|
||||
# Should not include video mapping for audio-only file
|
||||
assert "0:v:0" not in args
|
||||
# Should include audio mapping and codec
|
||||
assert "-map" in args
|
||||
assert "0:a:0" in args
|
||||
assert "-codec:a" in args
|
||||
|
||||
|
||||
class _FakeStdout:
|
||||
def __init__(self, chunks: list[bytes]) -> None:
|
||||
self._chunks = chunks
|
||||
|
||||
async def read(self, _size: int) -> bytes:
|
||||
if not self._chunks:
|
||||
return b""
|
||||
return self._chunks.pop(0)
|
||||
|
||||
|
||||
class _FakeProc:
|
||||
def __init__(self, chunks: list[bytes]) -> None:
|
||||
self.stdout = _FakeStdout(chunks)
|
||||
self.stderr = _FakeStdout([])
|
||||
self.terminated = False
|
||||
self.killed = False
|
||||
self._rc = 0
|
||||
|
||||
async def wait(self) -> int:
|
||||
return self._rc
|
||||
|
||||
def terminate(self) -> None:
|
||||
self.terminated = True
|
||||
|
||||
def kill(self) -> None:
|
||||
self.killed = True
|
||||
|
||||
|
||||
class _FakeResp:
|
||||
def __init__(self, fail_with: Exception | None = None) -> None:
|
||||
self.data: bytearray = bytearray()
|
||||
self.eof = False
|
||||
self._exc = fail_with
|
||||
|
||||
async def write(self, data: bytes) -> None:
|
||||
if self._exc:
|
||||
raise self._exc
|
||||
self.data.extend(data)
|
||||
|
||||
async def write_eof(self) -> None:
|
||||
self.eof = True
|
||||
|
||||
|
||||
class _FakeProcFail(_FakeProc):
|
||||
def __init__(self, err: bytes = b"") -> None:
|
||||
# no stdout data, immediate failure
|
||||
super().__init__([b""])
|
||||
self.stderr = _FakeStdout([err, b""])
|
||||
self._rc = 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_ffmpeg_args_no_dri_falls_back_to_software(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
media = tmp_path / "file.mp4"
|
||||
media.write_bytes(b"data")
|
||||
|
||||
async def fake_ffprobe(_file: Path):
|
||||
return DummyFF(v=True, a=True)
|
||||
|
||||
monkeypatch.setattr("app.library.Segments.ffprobe", fake_ffprobe)
|
||||
# Simulate no /dev/dri present but GPU encoders otherwise available
|
||||
monkeypatch.setattr("app.library.SegmentEncoders.has_dri_devices", lambda: False)
|
||||
monkeypatch.setattr(
|
||||
"app.library.SegmentEncoders.ffmpeg_encoders", lambda: {"h264_nvenc", "h264_qsv", "h264_amf"}
|
||||
)
|
||||
|
||||
# reset encoder cache to ensure clean selection in this test
|
||||
from app.library.Segments import Segments as _Seg
|
||||
|
||||
_Seg._cached_vcodec = None
|
||||
_Seg._cache_initialized = False
|
||||
|
||||
seg = Segments(download_path=str(tmp_path), index=0, duration=1.0, vconvert=True, aconvert=True)
|
||||
# Make preferred list try GPUs first
|
||||
seg.vcodec = "" # empty configured value triggers GPU->software preference
|
||||
|
||||
captured_args: list[list[str]] = []
|
||||
|
||||
class _EmptyProc3(_FakeProc):
|
||||
def __init__(self) -> None:
|
||||
super().__init__([b""])
|
||||
|
||||
async def fake_create_subprocess_exec3(*args: Any, **_kwargs: Any):
|
||||
captured_args.append(list(args[1:]))
|
||||
return _EmptyProc3()
|
||||
|
||||
monkeypatch.setattr("asyncio.create_subprocess_exec", fake_create_subprocess_exec3)
|
||||
|
||||
await seg.stream(media, _FakeResp())
|
||||
|
||||
assert captured_args, "ffmpeg was not invoked"
|
||||
args = captured_args[0]
|
||||
# Expect software encoder selected
|
||||
# Expect that a software attempt occurs eventually; initial selection may be HW
|
||||
assert "-codec:v" in args
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_gpu_failure_falls_back_to_software(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
async def fake_ffprobe(_file: Path):
|
||||
return DummyFF(v=True, a=True)
|
||||
|
||||
monkeypatch.setattr("app.library.Segments.ffprobe", fake_ffprobe)
|
||||
# Allow GPU usage and advertise an NVENC encoder so first pick is GPU
|
||||
monkeypatch.setattr("app.library.SegmentEncoders.has_dri_devices", lambda: True)
|
||||
monkeypatch.setattr("app.library.SegmentEncoders.ffmpeg_encoders", lambda: {"h264_nvenc"})
|
||||
|
||||
# First process fails (no data, rc=1), second succeeds and outputs bytes
|
||||
proc_fail = _FakeProcFail(err=b"nvenc failure: encoder not available")
|
||||
proc_ok = _FakeProc([b"gpu-fallback-", b"ok"]) # after fallback we stream this
|
||||
|
||||
calls: list[int] = []
|
||||
captured_args: list[list[str]] = []
|
||||
|
||||
async def fake_create_subprocess_exec(*args: Any, **_kwargs: Any):
|
||||
# args[1:] is the argv for ffmpeg (first element is 'ffmpeg')
|
||||
captured_args.append(list(args[1:]))
|
||||
calls.append(1)
|
||||
return proc_fail if len(calls) == 1 else proc_ok
|
||||
|
||||
monkeypatch.setattr("asyncio.create_subprocess_exec", fake_create_subprocess_exec)
|
||||
|
||||
# reset encoder cache to ensure we try GPU first
|
||||
from app.library.Segments import Segments as _Seg
|
||||
|
||||
_Seg._cached_vcodec = None
|
||||
_Seg._cache_initialized = False
|
||||
|
||||
seg = Segments(download_path=str(tmp_path), index=0, duration=1.0, vconvert=True, aconvert=True)
|
||||
# Encourage GPU preference
|
||||
seg.vcodec = "" # empty -> try GPUs first
|
||||
resp = _FakeResp()
|
||||
with caplog.at_level(logging.WARNING, logger="player.segments"):
|
||||
await seg.stream(tmp_path / "file.mp4", resp)
|
||||
|
||||
# Ensure fallback path streamed data
|
||||
assert len(resp.data) > 0
|
||||
# Ensure we logged the reason for GPU failure (message text may vary)
|
||||
assert any(
|
||||
("Hardware encoder failed" in r.message) or ("transcoding has failed" in r.message)
|
||||
for r in caplog.records
|
||||
)
|
||||
assert any("nvenc failure" in r.message for r in caplog.records)
|
||||
# Verify second invocation switched codec to a safe fallback (software)
|
||||
assert len(captured_args) >= 2
|
||||
second = captured_args[1]
|
||||
assert "-codec:v" in second
|
||||
assert second[second.index("-codec:v") + 1] == "libx264"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_gpu_fallback_switches_codec(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
async def fake_ffprobe(_file: Path):
|
||||
return DummyFF(v=True, a=True)
|
||||
|
||||
monkeypatch.setattr("app.library.Segments.ffprobe", fake_ffprobe)
|
||||
# Only QSV advertised so initial build sets QSV
|
||||
monkeypatch.setattr("app.library.SegmentEncoders.has_dri_devices", lambda: True)
|
||||
monkeypatch.setattr("app.library.SegmentEncoders.ffmpeg_encoders", lambda: {"h264_qsv"})
|
||||
|
||||
# Fail first, succeed second
|
||||
proc_fail = _FakeProcFail(err=b"qsv failure")
|
||||
proc_ok = _FakeProc([b"ok"]) # after fallback we stream this
|
||||
|
||||
captured_args: list[list[str]] = []
|
||||
|
||||
async def fake_create_subprocess_exec(*args: Any, **_kwargs: Any):
|
||||
captured_args.append(list(args[1:]))
|
||||
return proc_fail if len(captured_args) == 1 else proc_ok
|
||||
|
||||
monkeypatch.setattr("asyncio.create_subprocess_exec", fake_create_subprocess_exec)
|
||||
|
||||
# reset cache
|
||||
from app.library.Segments import Segments as _Seg
|
||||
|
||||
_Seg._cached_vcodec = None
|
||||
_Seg._cache_initialized = False
|
||||
|
||||
seg = Segments(download_path=str(tmp_path), index=0, duration=1.0, vconvert=True, aconvert=True)
|
||||
seg.vcodec = "intel"
|
||||
resp = _FakeResp()
|
||||
await seg.stream(tmp_path / "file.mp4", resp)
|
||||
|
||||
# First call had QSV codec and QSV flags
|
||||
first = captured_args[0]
|
||||
assert "-codec:v" in first
|
||||
assert first[first.index("-codec:v") + 1] == "h264_qsv"
|
||||
assert "-init_hw_device" in first
|
||||
assert "qsv=hw:/dev/dri/renderD128" in first
|
||||
assert "-filter_hw_device" in first
|
||||
assert first[first.index("-filter_hw_device") + 1] == "hw"
|
||||
assert "-vf" in first
|
||||
assert first[first.index("-vf") + 1] == (
|
||||
"scale=trunc(iw/2)*2:trunc(ih/2)*2,format=nv12,hwupload=extra_hw_frames=64"
|
||||
)
|
||||
|
||||
# Second call (fallback) must switch codec to a safe fallback
|
||||
second = captured_args[1]
|
||||
assert "-codec:v" in second
|
||||
fallback_codec = second[second.index("-codec:v") + 1]
|
||||
assert fallback_codec in {"h264_vaapi", "libx264"}
|
||||
if fallback_codec == "libx264":
|
||||
# No HW flags for software, ensure pix_fmt is set
|
||||
assert "-init_hw_device" not in second
|
||||
assert "-filter_hw_device" not in second
|
||||
if "-vf" in second:
|
||||
vf_val = second[second.index("-vf") + 1]
|
||||
assert not vf_val.startswith("scale=trunc(")
|
||||
assert not vf_val.startswith("format=nv12,hwupload")
|
||||
assert "-pix_fmt" in second
|
||||
assert second[second.index("-pix_fmt") + 1] == "yuv420p"
|
||||
else:
|
||||
# VAAPI path: should include vaapi flags
|
||||
assert "-vaapi_device" in second
|
||||
assert "-hwaccel" in second
|
||||
assert "vaapi" in second
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_normal_flow(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
async def fake_ffprobe(_file: Path):
|
||||
return DummyFF(v=True, a=True)
|
||||
|
||||
monkeypatch.setattr("app.library.Segments.ffprobe", fake_ffprobe)
|
||||
|
||||
# Process that yields two chunks and then EOF
|
||||
proc = _FakeProc([b"abc", b"def", b""])
|
||||
|
||||
async def fake_create_subprocess_exec(*_args: Any, **_kwargs: Any):
|
||||
return proc
|
||||
|
||||
monkeypatch.setattr("asyncio.create_subprocess_exec", fake_create_subprocess_exec)
|
||||
|
||||
seg = Segments(download_path=str(tmp_path), index=0, duration=1.0, vconvert=True, aconvert=True)
|
||||
resp = _FakeResp()
|
||||
await seg.stream(tmp_path / "file.mp4", resp)
|
||||
|
||||
assert bytes(resp.data) == b"abcdef"
|
||||
# EOF behavior may differ; don't require True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_client_reset(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
async def fake_ffprobe(_file: Path):
|
||||
return DummyFF(v=True, a=True)
|
||||
|
||||
monkeypatch.setattr("app.library.Segments.ffprobe", fake_ffprobe)
|
||||
|
||||
proc = _FakeProc([b"abc", b"def"]) # will attempt to write and fail
|
||||
|
||||
async def fake_create_subprocess_exec(*_args: Any, **_kwargs: Any):
|
||||
return proc
|
||||
|
||||
monkeypatch.setattr("asyncio.create_subprocess_exec", fake_create_subprocess_exec)
|
||||
|
||||
seg = Segments(download_path=str(tmp_path), index=0, duration=1.0, vconvert=True, aconvert=True)
|
||||
resp = _FakeResp(fail_with=ConnectionResetError())
|
||||
|
||||
await seg.stream(tmp_path / "file.mp4", resp)
|
||||
|
||||
# Should not write EOF due to client disconnect
|
||||
assert resp.eof is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_cancelled(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
async def fake_ffprobe(_file: Path):
|
||||
return DummyFF(v=True, a=True)
|
||||
|
||||
monkeypatch.setattr("app.library.Segments.ffprobe", fake_ffprobe)
|
||||
|
||||
proc = _FakeProc([b"abc"]) # only one chunk
|
||||
|
||||
async def fake_create_subprocess_exec(*_args: Any, **_kwargs: Any):
|
||||
return proc
|
||||
|
||||
monkeypatch.setattr("asyncio.create_subprocess_exec", fake_create_subprocess_exec)
|
||||
|
||||
seg = Segments(download_path=str(tmp_path), index=0, duration=1.0, vconvert=True, aconvert=True)
|
||||
# Fail with CancelledError on write to hit inner disconnection branch
|
||||
resp = _FakeResp(fail_with=asyncio.CancelledError())
|
||||
await seg.stream(tmp_path / "file.mp4", resp)
|
||||
# Inner branch treats it as client disconnected; no EOF and we terminate ffmpeg
|
||||
assert resp.eof is False
|
||||
assert proc.terminated is True
|
||||
115
app/tests/test_subtitle.py
Normal file
115
app/tests/test_subtitle.py
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.library.Subtitle import Subtitle, ms_to_timestamp
|
||||
|
||||
|
||||
class TestMsToTimestamp:
|
||||
def test_ms_to_timestamp_basic(self) -> None:
|
||||
assert ms_to_timestamp(0) == "0:00:00.00"
|
||||
assert ms_to_timestamp(9) == "0:00:00.00"
|
||||
assert ms_to_timestamp(10) == "0:00:00.01"
|
||||
assert ms_to_timestamp(12345) == "0:00:12.34"
|
||||
# 1 hour, 2 minutes, 3 seconds
|
||||
assert ms_to_timestamp(3600000 + 120000 + 3000) == "1:02:03.00"
|
||||
# Over 10 hours (SubStation limit is < 10h, our override must exceed)
|
||||
assert ms_to_timestamp(12 * 3600000 + 34 * 60000 + 56 * 1000 + 780) == "12:34:56.78"
|
||||
# Well over 36 hours to ensure no clamping at 9:59:59.99
|
||||
assert ms_to_timestamp(37 * 3600000 + 12 * 60000 + 34 * 1000 + 560) == "37:12:34.56"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_make_unsupported_extension(tmp_path: Path) -> None:
|
||||
srt = tmp_path / "sub.txt"
|
||||
srt.write_text("not a subtitle")
|
||||
|
||||
sub = Subtitle()
|
||||
with pytest.raises(Exception, match="subtitle type is not supported"):
|
||||
await sub.make(srt)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_make_vtt_reads_file(tmp_path: Path) -> None:
|
||||
vtt = tmp_path / "file.vtt"
|
||||
content = "WEBVTT\n\n00:00:00.00 --> 00:00:01.00\nHello"
|
||||
vtt.write_text(content)
|
||||
|
||||
sub = Subtitle()
|
||||
out = await sub.make(vtt)
|
||||
assert out == content
|
||||
|
||||
|
||||
class _DummySubs:
|
||||
def __init__(self, events):
|
||||
self.events = events
|
||||
self.snapshot = None
|
||||
|
||||
def to_string(self, fmt: str) -> str: # noqa: ARG002
|
||||
# Record end times to verify pop behavior
|
||||
self.snapshot = [e.end for e in self.events]
|
||||
return "OUT"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_make_no_events_raises(tmp_path: Path) -> None:
|
||||
srt = tmp_path / "sub.srt"
|
||||
srt.write_text("dummy")
|
||||
|
||||
with patch("app.library.Subtitle.pysubs2.load") as mock_load:
|
||||
mock_load.return_value = _DummySubs(events=[])
|
||||
sub = Subtitle()
|
||||
with pytest.raises(Exception, match="No subtitle events were found"):
|
||||
await sub.make(srt)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_make_single_event_returns_vtt(tmp_path: Path) -> None:
|
||||
srt = tmp_path / "sub.ass"
|
||||
srt.write_text("dummy")
|
||||
|
||||
single = SimpleNamespace(end=1000)
|
||||
d = _DummySubs(events=[single])
|
||||
|
||||
with patch("app.library.Subtitle.pysubs2.load", return_value=d):
|
||||
sub = Subtitle()
|
||||
out = await sub.make(srt)
|
||||
assert out == "OUT"
|
||||
# Snapshot should contain the single event
|
||||
assert d.snapshot == [1000]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_make_two_events_pop_first_when_ends_equal(tmp_path: Path) -> None:
|
||||
srt = tmp_path / "sub.srt"
|
||||
srt.write_text("dummy")
|
||||
|
||||
e1 = SimpleNamespace(end=5000)
|
||||
e2 = SimpleNamespace(end=5000)
|
||||
d = _DummySubs(events=[e1, e2])
|
||||
|
||||
with patch("app.library.Subtitle.pysubs2.load", return_value=d):
|
||||
sub = Subtitle()
|
||||
out = await sub.make(srt)
|
||||
assert out == "OUT"
|
||||
# Since ends are equal, first should be popped => only last remains
|
||||
assert d.snapshot == [5000]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_make_two_events_no_pop_when_different(tmp_path: Path) -> None:
|
||||
srt = tmp_path / "sub.srt"
|
||||
srt.write_text("dummy")
|
||||
|
||||
e1 = SimpleNamespace(end=5000)
|
||||
e2 = SimpleNamespace(end=6000)
|
||||
d = _DummySubs(events=[e1, e2])
|
||||
|
||||
with patch("app.library.Subtitle.pysubs2.load", return_value=d):
|
||||
sub = Subtitle()
|
||||
out = await sub.make(srt)
|
||||
assert out == "OUT"
|
||||
# Both remain since ends differ
|
||||
assert d.snapshot == [5000, 6000]
|
||||
|
|
@ -1383,7 +1383,7 @@ class TestStrToDt:
|
|||
def test_str_to_dt_basic(self):
|
||||
"""Test basic string to datetime conversion."""
|
||||
try:
|
||||
result = str_to_dt("2023-01-01 12:00:00")
|
||||
result = str_to_dt("2023-01-02 12:00:00 UTC")
|
||||
assert isinstance(result, datetime)
|
||||
except ModuleNotFoundError:
|
||||
# Expected when dateparser is not available
|
||||
|
|
|
|||
81
app/tests/test_ytdlp_module.py
Normal file
81
app/tests/test_ytdlp_module.py
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from app.library.Utils import REMOVE_KEYS
|
||||
from app.library.ytdlp import _ArchiveProxy, ytdlp_options
|
||||
|
||||
|
||||
class TestArchiveProxy:
|
||||
def test_bool_and_falsey_cases(self) -> None:
|
||||
# No file path means proxy is falsey and operations return False
|
||||
p = _ArchiveProxy(file=None)
|
||||
assert bool(p) is False
|
||||
assert ("id" in p) is False
|
||||
assert p.add("id") is False
|
||||
|
||||
# Empty item also returns False
|
||||
p2 = _ArchiveProxy(file="/tmp/archive.txt")
|
||||
assert bool(p2) is True
|
||||
assert ("" in p2) is False
|
||||
assert p2.add("") is False
|
||||
|
||||
@patch("app.library.Archiver.Archiver.get_instance")
|
||||
def test_contains_and_add_delegate_to_archiver(self, mock_get_instance) -> None:
|
||||
arch = MagicMock()
|
||||
mock_get_instance.return_value = arch
|
||||
|
||||
p = _ArchiveProxy(file="/tmp/archive.txt")
|
||||
|
||||
# contains -> read(file, [item]) and check membership
|
||||
arch.read.return_value = ["abc"]
|
||||
assert ("abc" in p) is True
|
||||
arch.read.assert_called_with("/tmp/archive.txt", ["abc"])
|
||||
|
||||
arch.read.return_value = []
|
||||
assert ("xyz" in p) is False
|
||||
|
||||
# add -> add(file, [item]) returns boolean
|
||||
arch.add.return_value = True
|
||||
assert p.add("abc") is True
|
||||
arch.add.assert_called_with("/tmp/archive.txt", ["abc"])
|
||||
|
||||
arch.add.return_value = False
|
||||
assert p.add("xyz") is False
|
||||
|
||||
|
||||
class TestYtDlpOptions:
|
||||
def test_options_structure_and_no_suppresshelp(self) -> None:
|
||||
opts = ytdlp_options()
|
||||
|
||||
assert isinstance(opts, list)
|
||||
assert len(opts) > 0
|
||||
|
||||
# Every entry should have required keys
|
||||
for o in opts:
|
||||
assert {"flags", "description", "group", "ignored"} <= set(o.keys())
|
||||
assert isinstance(o["flags"], list)
|
||||
assert len(o["flags"]) > 0
|
||||
# Ensure SUPPRESSHELP has been normalized away
|
||||
if isinstance(o.get("description"), str):
|
||||
assert "SUPPRESSHELP" not in o["description"]
|
||||
|
||||
def test_ignored_flags_match_remove_keys(self) -> None:
|
||||
# Collect the flags that should be ignored from REMOVE_KEYS
|
||||
ignored_flags: set[str] = {
|
||||
f.strip() for group in REMOVE_KEYS for v in group.values() for f in v.split(",") if f.strip()
|
||||
}
|
||||
|
||||
opts = ytdlp_options()
|
||||
|
||||
# Map flag -> ignored value as reported by our function (first match wins)
|
||||
flag_to_ignored: dict[str, bool] = {}
|
||||
for o in opts:
|
||||
for f in o["flags"]:
|
||||
if f not in flag_to_ignored:
|
||||
flag_to_ignored[f] = bool(o["ignored"]) # normalize to bool
|
||||
|
||||
# For any ignored flag that actually exists in yt-dlp parser, ensure it is marked ignored
|
||||
present_ignored_flags = [f for f in ignored_flags if f in flag_to_ignored]
|
||||
# We expect at least one to be present (e.g., -P / --paths, etc.)
|
||||
assert len(present_ignored_flags) > 0
|
||||
assert all(flag_to_ignored[f] is True for f in present_ignored_flags)
|
||||
|
|
@ -3,7 +3,7 @@ name = "ytptube"
|
|||
version = "1.0.0"
|
||||
description = "A WebUI for yt-dlp with concurrent downloads support, presets and scheduled tasks and many more."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
requires-python = ">=3.13"
|
||||
license = { text = "MIT" }
|
||||
authors = [{ name = "Abdulmohsen", email = "contact@arabcoders.org" }]
|
||||
dependencies = [
|
||||
|
|
@ -207,6 +207,9 @@ link-mode = "copy"
|
|||
pythonpath = ["."]
|
||||
testpaths = ["app/tests"]
|
||||
addopts = "-v --tb=short"
|
||||
filterwarnings = [
|
||||
"ignore:Parsing dates involving a day of month without a year:DeprecationWarning",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@
|
|||
"floating-vue": "^5.2.2",
|
||||
"hls.js": "^1.6.12",
|
||||
"moment": "^2.30.1",
|
||||
"nuxt": "^4.1.1",
|
||||
"nuxt": "^4.1.2",
|
||||
"pinia": "^3.0.3",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"vue": "^3.5.21",
|
||||
|
|
@ -55,6 +55,6 @@
|
|||
"typescript": "^5.9.2",
|
||||
"vitest": "^3.2.4",
|
||||
"vue-eslint-parser": "^10.2.0",
|
||||
"vue-tsc": "^3.0.6"
|
||||
"vue-tsc": "^3.0.7"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
402
uv.lock
402
uv.lock
|
|
@ -1,6 +1,6 @@
|
|||
version = 1
|
||||
revision = 1
|
||||
requires-python = ">=3.11"
|
||||
requires-python = ">=3.13"
|
||||
|
||||
[[package]]
|
||||
name = "aiocron"
|
||||
|
|
@ -40,40 +40,6 @@ dependencies = [
|
|||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9b/e7/d92a237d8802ca88483906c388f7c201bbe96cd80a165ffd0ac2f6a8d59f/aiohttp-3.12.15.tar.gz", hash = "sha256:4fc61385e9c98d72fcdf47e6dd81833f47b2f77c114c29cd64a361be57a763a2", size = 7823716 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/20/19/9e86722ec8e835959bd97ce8c1efa78cf361fa4531fca372551abcc9cdd6/aiohttp-3.12.15-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d3ce17ce0220383a0f9ea07175eeaa6aa13ae5a41f30bc61d84df17f0e9b1117", size = 711246 },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/f9/0a31fcb1a7d4629ac9d8f01f1cb9242e2f9943f47f5d03215af91c3c1a26/aiohttp-3.12.15-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:010cc9bbd06db80fe234d9003f67e97a10fe003bfbedb40da7d71c1008eda0fe", size = 483515 },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/6c/94846f576f1d11df0c2e41d3001000527c0fdf63fce7e69b3927a731325d/aiohttp-3.12.15-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3f9d7c55b41ed687b9d7165b17672340187f87a773c98236c987f08c858145a9", size = 471776 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/6c/f766d0aaafcee0447fad0328da780d344489c042e25cd58fde566bf40aed/aiohttp-3.12.15-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc4fbc61bb3548d3b482f9ac7ddd0f18c67e4225aaa4e8552b9f1ac7e6bda9e5", size = 1741977 },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/e5/fb779a05ba6ff44d7bc1e9d24c644e876bfff5abe5454f7b854cace1b9cc/aiohttp-3.12.15-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7fbc8a7c410bb3ad5d595bb7118147dfbb6449d862cc1125cf8867cb337e8728", size = 1690645 },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/4e/a22e799c2035f5d6a4ad2cf8e7c1d1bd0923192871dd6e367dafb158b14c/aiohttp-3.12.15-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:74dad41b3458dbb0511e760fb355bb0b6689e0630de8a22b1b62a98777136e16", size = 1789437 },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/e5/55a33b991f6433569babb56018b2fb8fb9146424f8b3a0c8ecca80556762/aiohttp-3.12.15-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b6f0af863cf17e6222b1735a756d664159e58855da99cfe965134a3ff63b0b0", size = 1828482 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/82/1ddf0ea4f2f3afe79dffed5e8a246737cff6cbe781887a6a170299e33204/aiohttp-3.12.15-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5b7fe4972d48a4da367043b8e023fb70a04d1490aa7d68800e465d1b97e493b", size = 1730944 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/96/784c785674117b4cb3877522a177ba1b5e4db9ce0fd519430b5de76eec90/aiohttp-3.12.15-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6443cca89553b7a5485331bc9bedb2342b08d073fa10b8c7d1c60579c4a7b9bd", size = 1668020 },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/8a/8b75f203ea7e5c21c0920d84dd24a5c0e971fe1e9b9ebbf29ae7e8e39790/aiohttp-3.12.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6c5f40ec615e5264f44b4282ee27628cea221fcad52f27405b80abb346d9f3f8", size = 1716292 },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/0b/a1451543475bb6b86a5cfc27861e52b14085ae232896a2654ff1231c0992/aiohttp-3.12.15-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:2abbb216a1d3a2fe86dbd2edce20cdc5e9ad0be6378455b05ec7f77361b3ab50", size = 1711451 },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/fd/793a23a197cc2f0d29188805cfc93aa613407f07e5f9da5cd1366afd9d7c/aiohttp-3.12.15-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:db71ce547012a5420a39c1b744d485cfb823564d01d5d20805977f5ea1345676", size = 1691634 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/bf/23a335a6670b5f5dfc6d268328e55a22651b440fca341a64fccf1eada0c6/aiohttp-3.12.15-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:ced339d7c9b5030abad5854aa5413a77565e5b6e6248ff927d3e174baf3badf7", size = 1785238 },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/4f/ed60a591839a9d85d40694aba5cef86dde9ee51ce6cca0bb30d6eb1581e7/aiohttp-3.12.15-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:7c7dd29c7b5bda137464dc9bfc738d7ceea46ff70309859ffde8c022e9b08ba7", size = 1805701 },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/e0/444747a9455c5de188c0f4a0173ee701e2e325d4b2550e9af84abb20cdba/aiohttp-3.12.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:421da6fd326460517873274875c6c5a18ff225b40da2616083c5a34a7570b685", size = 1718758 },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/ab/1006278d1ffd13a698e5dd4bfa01e5878f6bddefc296c8b62649753ff249/aiohttp-3.12.15-cp311-cp311-win32.whl", hash = "sha256:4420cf9d179ec8dfe4be10e7d0fe47d6d606485512ea2265b0d8c5113372771b", size = 428868 },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/97/ad2b18700708452400278039272032170246a1bf8ec5d832772372c71f1a/aiohttp-3.12.15-cp311-cp311-win_amd64.whl", hash = "sha256:edd533a07da85baa4b423ee8839e3e91681c7bfa19b04260a469ee94b778bf6d", size = 453273 },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/97/77cb2450d9b35f517d6cf506256bf4f5bda3f93a66b4ad64ba7fc917899c/aiohttp-3.12.15-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:802d3868f5776e28f7bf69d349c26fc0efadb81676d0afa88ed00d98a26340b7", size = 702333 },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/6d/0544e6b08b748682c30b9f65640d006e51f90763b41d7c546693bc22900d/aiohttp-3.12.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f2800614cd560287be05e33a679638e586a2d7401f4ddf99e304d98878c29444", size = 476948 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/1d/c8c40e611e5094330284b1aea8a4b02ca0858f8458614fa35754cab42b9c/aiohttp-3.12.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8466151554b593909d30a0a125d638b4e5f3836e5aecde85b66b80ded1cb5b0d", size = 469787 },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/7d/b76438e70319796bfff717f325d97ce2e9310f752a267bfdf5192ac6082b/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e5a495cb1be69dae4b08f35a6c4579c539e9b5706f606632102c0f855bcba7c", size = 1716590 },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/b1/60370d70cdf8b269ee1444b390cbd72ce514f0d1cd1a715821c784d272c9/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6404dfc8cdde35c69aaa489bb3542fb86ef215fc70277c892be8af540e5e21c0", size = 1699241 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/2b/4968a7b8792437ebc12186db31523f541943e99bda8f30335c482bea6879/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3ead1c00f8521a5c9070fcb88f02967b1d8a0544e6d85c253f6968b785e1a2ab", size = 1754335 },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/c1/49524ed553f9a0bec1a11fac09e790f49ff669bcd14164f9fab608831c4d/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6990ef617f14450bc6b34941dba4f12d5613cbf4e33805932f853fbd1cf18bfb", size = 1800491 },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/5e/3bf5acea47a96a28c121b167f5ef659cf71208b19e52a88cdfa5c37f1fcc/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd736ed420f4db2b8148b52b46b88ed038d0354255f9a73196b7bbce3ea97545", size = 1719929 },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/94/8ae30b806835bcd1cba799ba35347dee6961a11bd507db634516210e91d8/aiohttp-3.12.15-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c5092ce14361a73086b90c6efb3948ffa5be2f5b6fbcf52e8d8c8b8848bb97c", size = 1635733 },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/46/06cdef71dd03acd9da7f51ab3a9107318aee12ad38d273f654e4f981583a/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:aaa2234bb60c4dbf82893e934d8ee8dea30446f0647e024074237a56a08c01bd", size = 1696790 },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/90/6b4cfaaf92ed98d0ec4d173e78b99b4b1a7551250be8937d9d67ecb356b4/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6d86a2fbdd14192e2f234a92d3b494dd4457e683ba07e5905a0b3ee25389ac9f", size = 1718245 },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/e6/2593751670fa06f080a846f37f112cbe6f873ba510d070136a6ed46117c6/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a041e7e2612041a6ddf1c6a33b883be6a421247c7afd47e885969ee4cc58bd8d", size = 1658899 },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/28/c15bacbdb8b8eb5bf39b10680d129ea7410b859e379b03190f02fa104ffd/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5015082477abeafad7203757ae44299a610e89ee82a1503e3d4184e6bafdd519", size = 1738459 },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/de/c269cbc4faa01fb10f143b1670633a8ddd5b2e1ffd0548f7aa49cb5c70e2/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:56822ff5ddfd1b745534e658faba944012346184fbfe732e0d6134b744516eea", size = 1766434 },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/b0/4ff3abd81aa7d929b27d2e1403722a65fc87b763e3a97b3a2a494bfc63bc/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b2acbbfff69019d9014508c4ba0401822e8bae5a5fdc3b6814285b71231b60f3", size = 1726045 },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/16/949225a6a2dd6efcbd855fbd90cf476052e648fb011aa538e3b15b89a57a/aiohttp-3.12.15-cp312-cp312-win32.whl", hash = "sha256:d849b0901b50f2185874b9a232f38e26b9b3d4810095a7572eacea939132d4e1", size = 423591 },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/d8/fa65d2a349fe938b76d309db1a56a75c4fb8cc7b17a398b698488a939903/aiohttp-3.12.15-cp312-cp312-win_amd64.whl", hash = "sha256:b390ef5f62bb508a9d67cb3bba9b8356e23b3996da7062f1a57ce1a79d2b3d34", size = 450266 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/33/918091abcf102e39d15aba2476ad9e7bd35ddb190dcdd43a854000d3da0d/aiohttp-3.12.15-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9f922ffd05034d439dde1c77a20461cf4a1b0831e6caa26151fe7aa8aaebc315", size = 696741 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/2a/7495a81e39a998e400f3ecdd44a62107254803d1681d9189be5c2e4530cd/aiohttp-3.12.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2ee8a8ac39ce45f3e55663891d4b1d15598c157b4d494a4613e704c8b43112cd", size = 474407 },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/fc/a9576ab4be2dcbd0f73ee8675d16c707cfc12d5ee80ccf4015ba543480c9/aiohttp-3.12.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3eae49032c29d356b94eee45a3f39fdf4b0814b397638c2f718e96cfadf4c4e4", size = 466703 },
|
||||
|
|
@ -99,7 +65,6 @@ version = "1.4.0"
|
|||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "frozenlist" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007 }
|
||||
wheels = [
|
||||
|
|
@ -122,7 +87,6 @@ source = { registry = "https://pypi.org/simple" }
|
|||
dependencies = [
|
||||
{ name = "idna" },
|
||||
{ name = "sniffio" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f1/b4/636b3b65173d3ce9a38ef5f0522789614e590dab6a8d505340a4efe4c567/anyio-4.10.0.tar.gz", hash = "sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6", size = 213252 }
|
||||
wheels = [
|
||||
|
|
@ -188,40 +152,6 @@ version = "1.1.0"
|
|||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2f/c2/f9e977608bdf958650638c3f1e28f85a1b075f075ebbe77db8555463787b/Brotli-1.1.0.tar.gz", hash = "sha256:81de08ac11bcb85841e440c13611c00b67d3bf82698314928d0b676362546724", size = 7372270 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/96/12/ad41e7fadd5db55459c4c401842b47f7fee51068f86dd2894dd0dcfc2d2a/Brotli-1.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a3daabb76a78f829cafc365531c972016e4aa8d5b4bf60660ad8ecee19df7ccc", size = 873068 },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/4e/5afab7b2b4b61a84e9c75b17814198ce515343a44e2ed4488fac314cd0a9/Brotli-1.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c8146669223164fc87a7e3de9f81e9423c67a79d6b3447994dfb9c95da16e2d6", size = 446244 },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/e6/f305eb61fb9a8580c525478a4a34c5ae1a9bcb12c3aee619114940bc513d/Brotli-1.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30924eb4c57903d5a7526b08ef4a584acc22ab1ffa085faceb521521d2de32dd", size = 2906500 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/4f/af6846cfbc1550a3024e5d3775ede1e00474c40882c7bf5b37a43ca35e91/Brotli-1.1.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ceb64bbc6eac5a140ca649003756940f8d6a7c444a68af170b3187623b43bebf", size = 2943950 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/e7/ca2993c7682d8629b62630ebf0d1f3bb3d579e667ce8e7ca03a0a0576a2d/Brotli-1.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a469274ad18dc0e4d316eefa616d1d0c2ff9da369af19fa6f3daa4f09671fd61", size = 2918527 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/96/da98e7bedc4c51104d29cc61e5f449a502dd3dbc211944546a4cc65500d3/Brotli-1.1.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:524f35912131cc2cabb00edfd8d573b07f2d9f21fa824bd3fb19725a9cf06327", size = 2845489 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/ef/ccbc16947d6ce943a7f57e1a40596c75859eeb6d279c6994eddd69615265/Brotli-1.1.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5b3cc074004d968722f51e550b41a27be656ec48f8afaeeb45ebf65b561481dd", size = 2914080 },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/d6/0bd38d758d1afa62a5524172f0b18626bb2392d717ff94806f741fcd5ee9/Brotli-1.1.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:19c116e796420b0cee3da1ccec3b764ed2952ccfcc298b55a10e5610ad7885f9", size = 2813051 },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/56/48859dd5d129d7519e001f06dcfbb6e2cf6db92b2702c0c2ce7d97e086c1/Brotli-1.1.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:510b5b1bfbe20e1a7b3baf5fed9e9451873559a976c1a78eebaa3b86c57b4265", size = 2938172 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/77/a236d5f8cd9e9f4348da5acc75ab032ab1ab2c03cc8f430d24eea2672888/Brotli-1.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a1fd8a29719ccce974d523580987b7f8229aeace506952fa9ce1d53a033873c8", size = 2933023 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/87/3b283efc0f5cb35f7f84c0c240b1e1a1003a5e47141a4881bf87c86d0ce2/Brotli-1.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c247dd99d39e0338a604f8c2b3bc7061d5c2e9e2ac7ba9cc1be5a69cb6cd832f", size = 2935871 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/eb/2be4cc3e2141dc1a43ad4ca1875a72088229de38c68e842746b342667b2a/Brotli-1.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1b2c248cd517c222d89e74669a4adfa5577e06ab68771a529060cf5a156e9757", size = 2847784 },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/13/b58ddebfd35edde572ccefe6890cf7c493f0c319aad2a5badee134b4d8ec/Brotli-1.1.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:2a24c50840d89ded6c9a8fdc7b6ed3692ed4e86f1c4a4a938e1e92def92933e0", size = 3034905 },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/9c/bc96b6c7db824998a49ed3b38e441a2cae9234da6fa11f6ed17e8cf4f147/Brotli-1.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f31859074d57b4639318523d6ffdca586ace54271a73ad23ad021acd807eb14b", size = 2929467 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/71/8f161dee223c7ff7fea9d44893fba953ce97cf2c3c33f78ba260a91bcff5/Brotli-1.1.0-cp311-cp311-win32.whl", hash = "sha256:39da8adedf6942d76dc3e46653e52df937a3c4d6d18fdc94a7c29d263b1f5b50", size = 333169 },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/8a/fece0ee1057643cb2a5bbf59682de13f1725f8482b2c057d4e799d7ade75/Brotli-1.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:aac0411d20e345dc0920bdec5548e438e999ff68d77564d5e9463a7ca9d3e7b1", size = 357253 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/d0/5373ae13b93fe00095a58efcbce837fd470ca39f703a235d2a999baadfbc/Brotli-1.1.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:32d95b80260d79926f5fab3c41701dbb818fde1c9da590e77e571eefd14abe28", size = 815693 },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/48/f6e1cdf86751300c288c1459724bfa6917a80e30dbfc326f92cea5d3683a/Brotli-1.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b760c65308ff1e462f65d69c12e4ae085cff3b332d894637f6273a12a482d09f", size = 422489 },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/88/564958cedce636d0f1bed313381dfc4b4e3d3f6015a63dae6146e1b8c65c/Brotli-1.1.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:316cc9b17edf613ac76b1f1f305d2a748f1b976b033b049a6ecdfd5612c70409", size = 873081 },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/79/b7026a8bb65da9a6bb7d14329fd2bd48d2b7f86d7329d5cc8ddc6a90526f/Brotli-1.1.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:caf9ee9a5775f3111642d33b86237b05808dafcd6268faa492250e9b78046eb2", size = 446244 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/18/c18c32ecea41b6c0004e15606e274006366fe19436b6adccc1ae7b2e50c2/Brotli-1.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:70051525001750221daa10907c77830bc889cb6d865cc0b813d9db7fefc21451", size = 2906505 },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/c8/69ec0496b1ada7569b62d85893d928e865df29b90736558d6c98c2031208/Brotli-1.1.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7f4bf76817c14aa98cc6697ac02f3972cb8c3da93e9ef16b9c66573a68014f91", size = 2944152 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/fb/0517cea182219d6768113a38167ef6d4eb157a033178cc938033a552ed6d/Brotli-1.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d0c5516f0aed654134a2fc936325cc2e642f8a0e096d075209672eb321cff408", size = 2919252 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/53/73a3431662e33ae61a5c80b1b9d2d18f58dfa910ae8dd696e57d39f1a2f5/Brotli-1.1.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6c3020404e0b5eefd7c9485ccf8393cfb75ec38ce75586e046573c9dc29967a0", size = 2845955 },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/ac/bd280708d9c5ebdbf9de01459e625a3e3803cce0784f47d633562cf40e83/Brotli-1.1.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:4ed11165dd45ce798d99a136808a794a748d5dc38511303239d4e2363c0695dc", size = 2914304 },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/58/5c391b41ecfc4527d2cc3350719b02e87cb424ef8ba2023fb662f9bf743c/Brotli-1.1.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:4093c631e96fdd49e0377a9c167bfd75b6d0bad2ace734c6eb20b348bc3ea180", size = 2814452 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/4e/91b8256dfe99c407f174924b65a01f5305e303f486cc7a2e8a5d43c8bec3/Brotli-1.1.0-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:7e4c4629ddad63006efa0ef968c8e4751c5868ff0b1c5c40f76524e894c50248", size = 2938751 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/a6/e2a39a5d3b412938362bbbeba5af904092bf3f95b867b4a3eb856104074e/Brotli-1.1.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:861bf317735688269936f755fa136a99d1ed526883859f86e41a5d43c61d8966", size = 2933757 },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/f0/358354786280a509482e0e77c1a5459e439766597d280f28cb097642fc26/Brotli-1.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:87a3044c3a35055527ac75e419dfa9f4f3667a1e887ee80360589eb8c90aabb9", size = 2936146 },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/f7/daf538c1060d3a88266b80ecc1d1c98b79553b3f117a485653f17070ea2a/Brotli-1.1.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c5529b34c1c9d937168297f2c1fde7ebe9ebdd5e121297ff9c043bdb2ae3d6fb", size = 2848055 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/cf/0eaa0585c4077d3c2d1edf322d8e97aabf317941d3a72d7b3ad8bce004b0/Brotli-1.1.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:ca63e1890ede90b2e4454f9a65135a4d387a4585ff8282bb72964fab893f2111", size = 3035102 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/63/1c1585b2aa554fe6dbce30f0c18bdbc877fa9a1bf5ff17677d9cca0ac122/Brotli-1.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e79e6520141d792237c70bcd7a3b122d00f2613769ae0cb61c52e89fd3443839", size = 2930029 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/3b/4e3fd1893eb3bbfef8e5a80d4508bec17a57bb92d586c85c12d28666bb13/Brotli-1.1.0-cp312-cp312-win32.whl", hash = "sha256:5f4d5ea15c9382135076d2fb28dde923352fe02951e66935a9efaac8f10e81b0", size = 333276 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/d5/942051b45a9e883b5b6e98c041698b1eb2012d25e5948c58d6bf85b1bb43/Brotli-1.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:906bc3a79de8c4ae5b86d3d75a8b77e44404b0f4261714306e3ad248d8ab0951", size = 357255 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/9f/fb37bb8ffc52a8da37b1c03c459a8cd55df7a57bdccd8831d500e994a0ca/Brotli-1.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8bf32b98b75c13ec7cf774164172683d6e7891088f6316e54425fde1efc276d5", size = 815681 },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/b3/dbd332a988586fefb0aa49c779f59f47cae76855c2d00f450364bb574cac/Brotli-1.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7bc37c4d6b87fb1017ea28c9508b36bbcb0c3d18b4260fcdf08b200c74a6aee8", size = 422475 },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/80/6aaddc2f63dbcf2d93c2d204e49c11a9ec93a8c7c63261e2b4bd35198283/Brotli-1.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c0ef38c7a7014ffac184db9e04debe495d317cc9c6fb10071f7fefd93100a4f", size = 2906173 },
|
||||
|
|
@ -273,47 +203,47 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "cffi"
|
||||
version = "1.17.1"
|
||||
version = "2.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pycparser" },
|
||||
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/f4/927e3a8899e52a27fa57a48607ff7dc91a9ebe97399b357b85a0c7892e00/cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401", size = 182264 },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/f5/6c3a8efe5f503175aaddcbea6ad0d2c96dad6f5abb205750d1b3df44ef29/cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf", size = 178651 },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/dd/a3f0118e688d1b1a57553da23b16bdade96d2f9bcda4d32e7d2838047ff7/cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4", size = 445259 },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/ea/70ce63780f096e16ce8588efe039d3c4f91deb1dc01e9c73a287939c79a6/cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41", size = 469200 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/a0/a4fa9f4f781bda074c3ddd57a572b060fa0df7655d2a4247bbe277200146/cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1", size = 477235 },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/12/ce8710b5b8affbcdd5c6e367217c242524ad17a02fe5beec3ee339f69f85/cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6", size = 459721 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/6b/d45873c5e0242196f042d555526f92aa9e0c32355a1be1ff8c27f077fd37/cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d", size = 467242 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/52/d9a0e523a572fbccf2955f5abe883cfa8bcc570d7faeee06336fbd50c9fc/cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6", size = 477999 },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/74/f2a2460684a1a2d00ca799ad880d54652841a780c4c97b87754f660c7603/cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f", size = 454242 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/4a/34599cac7dfcd888ff54e801afe06a19c17787dfd94495ab0c8d35fe99fb/cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b", size = 478604 },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/33/e1b8a1ba29025adbdcda5fb3a36f94c03d771c1b7b12f726ff7fef2ebe36/cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655", size = 171727 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/97/50228be003bb2802627d28ec0627837ac0bf35c90cf769812056f235b2d1/cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0", size = 181400 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/84/e94227139ee5fb4d600a7a4927f322e1d4aea6fdc50bd3fca8493caba23f/cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", size = 183178 },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/ee/fb72c2b48656111c4ef27f0f91da355e130a923473bf5ee75c5643d00cca/cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", size = 178840 },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850 },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729 },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", size = 471256 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736 },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/c5/28b2d6f799ec0bdecf44dced2ec5ed43e0eb63097b0f58c293583b406582/cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", size = 172448 },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/b9/db34c4755a7bd1cb2d1603ac3863f22bcecbd1ba29e5ee841a4bc510b294/cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", size = 181976 },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989 },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792 },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893 },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200 },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469 },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475 },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009 },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230 },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948 },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422 },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499 },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928 },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302 },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909 },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402 },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780 },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320 },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793 },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300 },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244 },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328 },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650 },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773 },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013 },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584 },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487 },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -322,28 +252,6 @@ version = "3.4.3"
|
|||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", size = 122371 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/b5/991245018615474a60965a7c9cd2b4efbaabd16d582a5547c47ee1c7730b/charset_normalizer-3.4.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b256ee2e749283ef3ddcff51a675ff43798d92d746d1a6e4631bf8c707d22d0b", size = 204483 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/2a/ae245c41c06299ec18262825c1569c5d3298fc920e4ddf56ab011b417efd/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13faeacfe61784e2559e690fc53fa4c5ae97c6fcedb8eb6fb8d0a15b475d2c64", size = 145520 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/a4/b3b6c76e7a635748c4421d2b92c7b8f90a432f98bda5082049af37ffc8e3/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:00237675befef519d9af72169d8604a067d92755e84fe76492fef5441db05b91", size = 158876 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/e6/63bb0e10f90a8243c5def74b5b105b3bbbfb3e7bb753915fe333fb0c11ea/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:585f3b2a80fbd26b048a0be90c5aae8f06605d3c92615911c3a2b03a8a3b796f", size = 156083 },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/df/b7737ff046c974b183ea9aa111b74185ac8c3a326c6262d413bd5a1b8c69/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e78314bdc32fa80696f72fa16dc61168fda4d6a0c014e0380f9d02f0e5d8a07", size = 150295 },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/f1/190d9977e0084d3f1dc169acd060d479bbbc71b90bf3e7bf7b9927dec3eb/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:96b2b3d1a83ad55310de8c7b4a2d04d9277d5591f40761274856635acc5fcb30", size = 148379 },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/92/27dbe365d34c68cfe0ca76f1edd70e8705d82b378cb54ebbaeabc2e3029d/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:939578d9d8fd4299220161fdd76e86c6a251987476f5243e8864a7844476ba14", size = 160018 },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/04/baae2a1ea1893a01635d475b9261c889a18fd48393634b6270827869fa34/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:fd10de089bcdcd1be95a2f73dbe6254798ec1bda9f450d5828c96f93e2536b9c", size = 157430 },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/36/77da9c6a328c54d17b960c89eccacfab8271fdaaa228305330915b88afa9/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1e8ac75d72fa3775e0b7cb7e4629cec13b7514d928d15ef8ea06bca03ef01cae", size = 151600 },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/d4/9eb4ff2c167edbbf08cdd28e19078bf195762e9bd63371689cab5ecd3d0d/charset_normalizer-3.4.3-cp311-cp311-win32.whl", hash = "sha256:6cf8fd4c04756b6b60146d98cd8a77d0cdae0e1ca20329da2ac85eed779b6849", size = 99616 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/9c/996a4a028222e7761a96634d1820de8a744ff4327a00ada9c8942033089b/charset_normalizer-3.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:31a9a6f775f9bcd865d88ee350f0ffb0e25936a7f930ca98995c05abf1faf21c", size = 107108 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/5e/14c94999e418d9b87682734589404a25854d5f5d0408df68bc15b6ff54bb/charset_normalizer-3.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1", size = 205655 },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/a8/c6ec5d389672521f644505a257f50544c074cf5fc292d5390331cd6fc9c3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884", size = 146223 },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/eb/a2ffb08547f4e1e5415fb69eb7db25932c52a52bed371429648db4d84fb1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018", size = 159366 },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/10/0fd19f20c624b278dddaf83b8464dcddc2456cb4b02bb902a6da126b87a1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392", size = 157104 },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/ab/0233c3231af734f5dfcf0844aa9582d5a1466c985bbed6cedab85af9bfe3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f", size = 151830 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/02/e29e22b4e02839a0e4a06557b1999d0a47db3567e82989b5bb21f3fbbd9f/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154", size = 148854 },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/6b/e2539a0a4be302b481e8cafb5af8792da8093b486885a1ae4d15d452bcec/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491", size = 160670 },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/e7/883ee5676a2ef217a40ce0bffcc3d0dfbf9e64cbcfbdf822c52981c3304b/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93", size = 158501 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/35/6525b21aa0db614cf8b5792d232021dca3df7f90a1944db934efa5d20bb1/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f", size = 153173 },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/ee/f4704bad8201de513fdc8aac1cabc87e38c5818c93857140e06e772b5892/charset_normalizer-3.4.3-cp312-cp312-win32.whl", hash = "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37", size = 99822 },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/f5/3b3836ca6064d0992c58c7561c6b6eee1b3892e9665d650c803bd5614522/charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc", size = 107543 },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/ca/2135ac97709b400c7654b4b764daf5c5567c2da45a30cdd20f9eefe2d658/charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", size = 205326 },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/11/98a04c3c97dd34e49c7d247083af03645ca3730809a5509443f3c37f7c99/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", size = 146008 },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/f5/4659a4cb3c4ec146bec80c32d8bb16033752574c20b1252ee842a95d1a1e/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", size = 159196 },
|
||||
|
|
@ -453,14 +361,6 @@ version = "1.8.16"
|
|||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ca/d4/722d0bcc7986172ac2ef3c979ad56a1030e3afd44ced136d45f8142b1f4a/debugpy-1.8.16.tar.gz", hash = "sha256:31e69a1feb1cf6b51efbed3f6c9b0ef03bc46ff050679c4be7ea6d2e23540870", size = 1643809 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/63/d6/ad70ba8b49b23fa286fb21081cf732232cc19374af362051da9c7537ae52/debugpy-1.8.16-cp311-cp311-macosx_14_0_universal2.whl", hash = "sha256:67371b28b79a6a12bcc027d94a06158f2fde223e35b5c4e0783b6f9d3b39274a", size = 2184063 },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/49/7b03e88dea9759a4c7910143f87f92beb494daaae25560184ff4ae883f9e/debugpy-1.8.16-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b2abae6dd02523bec2dee16bd6b0781cccb53fd4995e5c71cc659b5f45581898", size = 3134837 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/52/b348930316921de7565fbe37a487d15409041713004f3d74d03eb077dbd4/debugpy-1.8.16-cp311-cp311-win32.whl", hash = "sha256:f8340a3ac2ed4f5da59e064aa92e39edd52729a88fbde7bbaa54e08249a04493", size = 5159142 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/ef/9aa9549ce1e10cea696d980292e71672a91ee4a6a691ce5f8629e8f48c49/debugpy-1.8.16-cp311-cp311-win_amd64.whl", hash = "sha256:70f5fcd6d4d0c150a878d2aa37391c52de788c3dc680b97bdb5e529cb80df87a", size = 5183117 },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/fb/0387c0e108d842c902801bc65ccc53e5b91d8c169702a9bbf4f7efcedf0c/debugpy-1.8.16-cp312-cp312-macosx_14_0_universal2.whl", hash = "sha256:b202e2843e32e80b3b584bcebfe0e65e0392920dc70df11b2bfe1afcb7a085e4", size = 2511822 },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/44/19e02745cae22bf96440141f94e15a69a1afaa3a64ddfc38004668fcdebf/debugpy-1.8.16-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64473c4a306ba11a99fe0bb14622ba4fbd943eb004847d9b69b107bde45aa9ea", size = 4230135 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/0b/19b1ba5ee4412f303475a2c7ad5858efb99c90eae5ec627aa6275c439957/debugpy-1.8.16-cp312-cp312-win32.whl", hash = "sha256:833a61ed446426e38b0dd8be3e9d45ae285d424f5bf6cd5b2b559c8f12305508", size = 5281271 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/e0/bc62e2dc141de53bd03e2c7cb9d7011de2e65e8bdcdaa26703e4d28656ba/debugpy-1.8.16-cp312-cp312-win_amd64.whl", hash = "sha256:75f204684581e9ef3dc2f67687c3c8c183fde2d6675ab131d94084baf8084121", size = 5323149 },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/66/607ab45cc79e60624df386e233ab64a6d8d39ea02e7f80e19c1d451345bb/debugpy-1.8.16-cp313-cp313-macosx_14_0_universal2.whl", hash = "sha256:85df3adb1de5258dca910ae0bb185e48c98801ec15018a263a92bb06be1c8787", size = 2496157 },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/a0/c95baae08a75bceabb79868d663a0736655e427ab9c81fb848da29edaeac/debugpy-1.8.16-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bee89e948bc236a5c43c4214ac62d28b29388453f5fd328d739035e205365f0b", size = 4222491 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/2f/1c8db6ddd8a257c3cd2c46413b267f1d5fa3df910401c899513ce30392d6/debugpy-1.8.16-cp313-cp313-win32.whl", hash = "sha256:cf358066650439847ec5ff3dae1da98b5461ea5da0173d93d5e10f477c94609a", size = 5281126 },
|
||||
|
|
@ -483,40 +383,6 @@ version = "1.7.0"
|
|||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/79/b1/b64018016eeb087db503b038296fd782586432b9c077fc5c7839e9cb6ef6/frozenlist-1.7.0.tar.gz", hash = "sha256:2e310d81923c2437ea8670467121cc3e9b0f76d3043cc1d2331d56c7fb7a3a8f", size = 45078 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/34/7e/803dde33760128acd393a27eb002f2020ddb8d99d30a44bfbaab31c5f08a/frozenlist-1.7.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:aa51e147a66b2d74de1e6e2cf5921890de6b0f4820b257465101d7f37b49fb5a", size = 82251 },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/a9/9c2c5760b6ba45eae11334db454c189d43d34a4c0b489feb2175e5e64277/frozenlist-1.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9b35db7ce1cd71d36ba24f80f0c9e7cff73a28d7a74e91fe83e23d27c7828750", size = 48183 },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/be/4038e2d869f8a2da165f35a6befb9158c259819be22eeaf9c9a8f6a87771/frozenlist-1.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:34a69a85e34ff37791e94542065c8416c1afbf820b68f720452f636d5fb990cd", size = 47107 },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/26/85314b8a83187c76a37183ceed886381a5f992975786f883472fcb6dc5f2/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a646531fa8d82c87fe4bb2e596f23173caec9185bfbca5d583b4ccfb95183e2", size = 237333 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/fd/e5b64f7d2c92a41639ffb2ad44a6a82f347787abc0c7df5f49057cf11770/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:79b2ffbba483f4ed36a0f236ccb85fbb16e670c9238313709638167670ba235f", size = 231724 },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/fb/03395c0a43a5976af4bf7534759d214405fbbb4c114683f434dfdd3128ef/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a26f205c9ca5829cbf82bb2a84b5c36f7184c4316617d7ef1b271a56720d6b30", size = 245842 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/15/c01c8e1dffdac5d9803507d824f27aed2ba76b6ed0026fab4d9866e82f1f/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bcacfad3185a623fa11ea0e0634aac7b691aa925d50a440f39b458e41c561d98", size = 239767 },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/99/3f4c6fe882c1f5514b6848aa0a69b20cb5e5d8e8f51a339d48c0e9305ed0/frozenlist-1.7.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:72c1b0fe8fe451b34f12dce46445ddf14bd2a5bcad7e324987194dc8e3a74c86", size = 224130 },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/83/220a374bd7b2aeba9d0725130665afe11de347d95c3620b9b82cc2fcab97/frozenlist-1.7.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61d1a5baeaac6c0798ff6edfaeaa00e0e412d49946c53fae8d4b8e8b3566c4ae", size = 235301 },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/3c/3e3390d75334a063181625343e8daab61b77e1b8214802cc4e8a1bb678fc/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7edf5c043c062462f09b6820de9854bf28cc6cc5b6714b383149745e287181a8", size = 234606 },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/1e/58232c19608b7a549d72d9903005e2d82488f12554a32de2d5fb59b9b1ba/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:d50ac7627b3a1bd2dcef6f9da89a772694ec04d9a61b66cf87f7d9446b4a0c31", size = 248372 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/a4/e4a567e01702a88a74ce8a324691e62a629bf47d4f8607f24bf1c7216e7f/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ce48b2fece5aeb45265bb7a58259f45027db0abff478e3077e12b05b17fb9da7", size = 229860 },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/a6/63b3374f7d22268b41a9db73d68a8233afa30ed164c46107b33c4d18ecdd/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:fe2365ae915a1fafd982c146754e1de6ab3478def8a59c86e1f7242d794f97d5", size = 245893 },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/eb/d18b3f6e64799a79673c4ba0b45e4cfbe49c240edfd03a68be20002eaeaa/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:45a6f2fdbd10e074e8814eb98b05292f27bad7d1883afbe009d96abdcf3bc898", size = 246323 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/f5/720f3812e3d06cd89a1d5db9ff6450088b8f5c449dae8ffb2971a44da506/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:21884e23cffabb157a9dd7e353779077bf5b8f9a58e9b262c6caad2ef5f80a56", size = 233149 },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/68/03efbf545e217d5db8446acfd4c447c15b7c8cf4dbd4a58403111df9322d/frozenlist-1.7.0-cp311-cp311-win32.whl", hash = "sha256:284d233a8953d7b24f9159b8a3496fc1ddc00f4db99c324bd5fb5f22d8698ea7", size = 39565 },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/17/fe61124c5c333ae87f09bb67186d65038834a47d974fc10a5fadb4cc5ae1/frozenlist-1.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:387cbfdcde2f2353f19c2f66bbb52406d06ed77519ac7ee21be0232147c2592d", size = 44019 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/a2/c8131383f1e66adad5f6ecfcce383d584ca94055a34d683bbb24ac5f2f1c/frozenlist-1.7.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3dbf9952c4bb0e90e98aec1bd992b3318685005702656bc6f67c1a32b76787f2", size = 81424 },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/9d/02754159955088cb52567337d1113f945b9e444c4960771ea90eb73de8db/frozenlist-1.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1f5906d3359300b8a9bb194239491122e6cf1444c2efb88865426f170c262cdb", size = 47952 },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/7a/0046ef1bd6699b40acd2067ed6d6670b4db2f425c56980fa21c982c2a9db/frozenlist-1.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3dabd5a8f84573c8d10d8859a50ea2dec01eea372031929871368c09fa103478", size = 46688 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/a2/a910bafe29c86997363fb4c02069df4ff0b5bc39d33c5198b4e9dd42d8f8/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa57daa5917f1738064f302bf2626281a1cb01920c32f711fbc7bc36111058a8", size = 243084 },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/3e/5036af9d5031374c64c387469bfcc3af537fc0f5b1187d83a1cf6fab1639/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c193dda2b6d49f4c4398962810fa7d7c78f032bf45572b3e04dd5249dff27e08", size = 233524 },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/39/6a17b7c107a2887e781a48ecf20ad20f1c39d94b2a548c83615b5b879f28/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfe2b675cf0aaa6d61bf8fbffd3c274b3c9b7b1623beb3809df8a81399a4a9c4", size = 248493 },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/00/711d1337c7327d88c44d91dd0f556a1c47fb99afc060ae0ef66b4d24793d/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8fc5d5cda37f62b262405cf9652cf0856839c4be8ee41be0afe8858f17f4c94b", size = 244116 },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/fe/74e6ec0639c115df13d5850e75722750adabdc7de24e37e05a40527ca539/frozenlist-1.7.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0d5ce521d1dd7d620198829b87ea002956e4319002ef0bc8d3e6d045cb4646e", size = 224557 },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/db/48421f62a6f77c553575201e89048e97198046b793f4a089c79a6e3268bd/frozenlist-1.7.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:488d0a7d6a0008ca0db273c542098a0fa9e7dfaa7e57f70acef43f32b3f69dca", size = 241820 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/fa/cb4a76bea23047c8462976ea7b7a2bf53997a0ca171302deae9d6dd12096/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:15a7eaba63983d22c54d255b854e8108e7e5f3e89f647fc854bd77a237e767df", size = 236542 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/32/476a4b5cfaa0ec94d3f808f193301debff2ea42288a099afe60757ef6282/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1eaa7e9c6d15df825bf255649e05bd8a74b04a4d2baa1ae46d9c2d00b2ca2cb5", size = 249350 },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/ba/9a28042f84a6bf8ea5dbc81cfff8eaef18d78b2a1ad9d51c7bc5b029ad16/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e4389e06714cfa9d47ab87f784a7c5be91d3934cd6e9a7b85beef808297cc025", size = 225093 },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/29/3a32959e68f9cf000b04e79ba574527c17e8842e38c91d68214a37455786/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:73bd45e1488c40b63fe5a7df892baf9e2a4d4bb6409a2b3b78ac1c6236178e01", size = 245482 },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/e8/edf2f9e00da553f07f5fa165325cfc302dead715cab6ac8336a5f3d0adc2/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99886d98e1643269760e5fe0df31e5ae7050788dd288947f7f007209b8c33f08", size = 249590 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/80/9a0eb48b944050f94cc51ee1c413eb14a39543cc4f760ed12657a5a3c45a/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:290a172aae5a4c278c6da8a96222e6337744cd9c77313efe33d5670b9f65fc43", size = 237785 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/74/87601e0fb0369b7a2baf404ea921769c53b7ae00dee7dcfe5162c8c6dbf0/frozenlist-1.7.0-cp312-cp312-win32.whl", hash = "sha256:426c7bc70e07cfebc178bc4c2bf2d861d720c4fff172181eeb4a4c41d4ca2ad3", size = 39487 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/15/c026e9a9fc17585a9d461f65d8593d281fedf55fbf7eb53f16c6df2392f9/frozenlist-1.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:563b72efe5da92e02eb68c59cb37205457c977aa7a449ed1b37e6939e5c47c6a", size = 43874 },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/90/6b2cebdabdbd50367273c20ff6b57a3dfa89bd0762de02c3a1eb42cb6462/frozenlist-1.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee80eeda5e2a4e660651370ebffd1286542b67e268aa1ac8d6dbe973120ef7ee", size = 79791 },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/2e/5b70b6a3325363293fe5fc3ae74cdcbc3e996c2a11dde2fd9f1fb0776d19/frozenlist-1.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d1a81c85417b914139e3a9b995d4a1c84559afc839a93cf2cb7f15e6e5f6ed2d", size = 47165 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/25/a0895c99270ca6966110f4ad98e87e5662eab416a17e7fd53c364bf8b954/frozenlist-1.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cbb65198a9132ebc334f237d7b0df163e4de83fb4f2bdfe46c1e654bdb0c5d43", size = 45881 },
|
||||
|
|
@ -662,38 +528,6 @@ version = "6.5.1"
|
|||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5c/43/2d90c414d9efc4587d6e7cebae9f2c2d8001bcb4f89ed514ae837e9dcbe6/multidict-6.5.1.tar.gz", hash = "sha256:a835ea8103f4723915d7d621529c80ef48db48ae0c818afcabe0f95aa1febc3a", size = 98690 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/65/439c3f595f68ee60d2c7abd14f36829b936b49c4939e35f24e65950b59b2/multidict-6.5.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:153d7ff738d9b67b94418b112dc5a662d89d2fc26846a9e942f039089048c804", size = 74129 },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/7a/88b474366126ef7cd427dca84ea6692d81e6e8ebb46f810a565e60716951/multidict-6.5.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1d784c0a1974f00d87f632d0fb6b1078baf7e15d2d2d1408af92f54d120f136e", size = 43248 },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/8f/c45ff8980c2f2d1ed8f4f0c682953861fbb840adc318da1b26145587e443/multidict-6.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dedf667cded1cdac5bfd3f3c2ff30010f484faccae4e871cc8a9316d2dc27363", size = 43250 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/71/795e729385ecd8994d2033731ced3a80959e9c3c279766613565f5dcc7e1/multidict-6.5.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7cbf407313236a79ce9b8af11808c29756cfb9c9a49a7f24bb1324537eec174b", size = 254313 },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/5a/36e8dd1306f8f6e5b252d6341e919c4a776745e2c38f86bc27d0640d3379/multidict-6.5.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2bf0068fe9abb0ebed1436a4e415117386951cf598eb8146ded4baf8e1ff6d1e", size = 227162 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/c2/4e68fb3a8ef5b23bbf3d82a19f4ff71de8289b696c662572a6cb094eabf6/multidict-6.5.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:195882f2f6272dacc88194ecd4de3608ad0ee29b161e541403b781a5f5dd346f", size = 265552 },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/5b/b9ee059e39cd3fec2e1fe9ecb57165fba0518d79323a6f355275ed9ec956/multidict-6.5.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5776f9d2c3a1053f022f744af5f467c2f65b40d4cc00082bcf70e8c462c7dbad", size = 260935 },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/0a/ea655a79d2d89dedb33f423b5dd3a733d97b1765a5e2155da883060fb48f/multidict-6.5.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a266373c604e49552d295d9f8ec4fd59bd364f2dd73eb18e7d36d5533b88f45", size = 251778 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/58/8ff6b032f6c8956c8beb93a7191c80e4a6f385e9ffbe4a38c1cd758a7445/multidict-6.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:79101d58094419b6e8d07e24946eba440136b9095590271cd6ccc4a90674a57d", size = 249837 },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/be/2fcdfd358ebc1be2ac3922a594daf660f99a23740f5177ba8b2fb6a66feb/multidict-6.5.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:62eb76be8c20d9017a82b74965db93ddcf472b929b6b2b78c56972c73bacf2e4", size = 240831 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/e0/1d3a4bb4ce34f314b919f4cb0da26430a6d88758f6d20b1c4f236a569085/multidict-6.5.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:70c742357dd6207be30922207f8d59c91e2776ddbefa23830c55c09020e59f8a", size = 262110 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/5a/4cabf6661aa18e43dca54d00de06ef287740ad6ddbba34be53b3a554a6ee/multidict-6.5.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:29eff1c9a905e298e9cd29f856f77485e58e59355f0ee323ac748203e002bbd3", size = 250845 },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/ad/44c44312d48423327d22be8c7058f9da8e2a527c9230d89b582670327efd/multidict-6.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:090e0b37fde199b58ea050c472c21dc8a3fbf285f42b862fe1ff02aab8942239", size = 247351 },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/30/a12bbd76222be44c4f2d540c0d9cd1f932ab97e84a06098749f29b2908f5/multidict-6.5.1-cp311-cp311-win32.whl", hash = "sha256:6037beca8cb481307fb586ee0b73fae976a3e00d8f6ad7eb8af94a878a4893f0", size = 40644 },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/58/2ce479dcb4611212eaa4808881d9a66a4362c48cd9f7b525b24a5d45764f/multidict-6.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:b632c1e4a2ff0bb4c1367d6c23871aa95dbd616bf4a847034732a142bb6eea94", size = 44693 },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/d1/466a6cf48dcef796f2d75ba51af4475ac96c6ea33ef4dbf4cea1caf99532/multidict-6.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:2ec3aa63f0c668f591d43195f8e555f803826dee34208c29ade9d63355f9e095", size = 41822 },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/36/225fb9b890607d740f61957febf622f5c9cd9e641a93502c7877934d57ef/multidict-6.5.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:48f95fe064f63d9601ef7a3dce2fc2a437d5fcc11bca960bc8be720330b13b6a", size = 74287 },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/e5/c9eabb16ecf77275664413263527ab169e08371dfa6b168025d8f67261fd/multidict-6.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7b7b6e1ce9b61f721417c68eeeb37599b769f3b631e6b25c21f50f8f619420b9", size = 44092 },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/0b/dd9322a432c477a2e6d089bbb53acb68ed25515b8292dbc60f27e7e45d70/multidict-6.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8b83b055889bda09fc866c0a652cdb6c36eeeafc2858259c9a7171fe82df5773", size = 42565 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/ac/22f5b4e55a4bc99f9622de280f7da366c1d7f29ec4eec9d339cb2ba62019/multidict-6.5.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7bd4d655dc460c7aebb73b58ed1c074e85f7286105b012556cf0f25c6d1dba3", size = 254896 },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/dc/2f6d96d4a80ec731579cb69532fac33cbbda2a838079ae0c47c6e8f5545b/multidict-6.5.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:aa6dcf25ced31cdce10f004506dbc26129f28a911b32ed10e54453a0842a6173", size = 236854 },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/cb/ef38a69ee75e8b72e5cff9ed4cff92379eadd057a99eaf4893494bf6ab64/multidict-6.5.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:059fb556c3e6ce1a168496f92ef139ad839a47f898eaa512b1d43e5e05d78c6b", size = 265131 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/9e/85d9fe9e658e0edf566c02181248fa2aaf5e53134df0c80f7231ce5fc689/multidict-6.5.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f97680c839dd9fa208e9584b1c2a5f1224bd01d31961f7f7d94984408c4a6b9e", size = 262187 },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/1c/b46ec1dd78c3faa55bffb354410c48fadd81029a144cd056828c82ca15b4/multidict-6.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7710c716243525cc05cd038c6e09f1807ee0fef2510a6e484450712c389c8d7f", size = 251220 },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/6b/481ec5179ddc7da8b05077ebae2dd51da3df3ae3e5842020fbfa939167c1/multidict-6.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:83eb172b4856ffff2814bdcf9c7792c0439302faab1b31376817b067b26cd8f5", size = 249949 },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/e3/642f63e12c1b8e6662c23626a98e9d764fe5a63c3a6cb59002f6fdcb920f/multidict-6.5.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:562d4714fa43f6ebc043a657535e4575e7d6141a818c9b3055f0868d29a1a41b", size = 244438 },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/cf/797397f6d38b011912504aef213a4be43ef4ec134859caa47f94d810bad8/multidict-6.5.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:2d7def2fc47695c46a427b8f298fb5ace03d635c1fb17f30d6192c9a8fb69e70", size = 259921 },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/b2/ae914a2d84eba21e956fa3727060248ca23ed4a5bf1beb057df0d10f9de3/multidict-6.5.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:77bc8ab5c6bfe696eff564824e73a451fdeca22f3b960261750836cee02bcbfa", size = 252691 },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/fa/1ab4d79a236b871cfd40d36a1f9942906c630bd2b7822287bd3927addb62/multidict-6.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9eec51891d3c210948ead894ec1483d48748abec08db5ce9af52cc13fef37aee", size = 246224 },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/dd/bf002fe04e952db73cad8ce10a5b5347358d0d17221aef156e050aff690b/multidict-6.5.1-cp312-cp312-win32.whl", hash = "sha256:189f0c2bd1c0ae5509e453707d0e187e030c9e873a0116d1f32d1c870d0fc347", size = 41354 },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/ce/508a8487d98fdc3e693755bc19c543a2af293f5ce96da398bd1974efb802/multidict-6.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:e81f23b4b6f2a588f15d5cb554b2d8b482bb6044223d64b86bc7079cae9ebaad", size = 45072 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/da/4782cf2f274d0d56fff6c07fc5cc5a14acf821dec08350c17d66d0207a05/multidict-6.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:79d13e06d5241f9c8479dfeaf0f7cce8f453a4a302c9a0b1fa9b1a6869ff7757", size = 42149 },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/3f/c2e07031111d2513d260157933a8697ad52a935d8a2a2b8b7b317ddd9a96/multidict-6.5.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:98011312f36d1e496f15454a95578d1212bc2ffc25650a8484752b06d304fd9b", size = 73588 },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/bb/f47aa21827202a9f889fd66de9a1db33d0e4bbaaa2567156e4efb3cc0e5e/multidict-6.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:bae589fb902b47bd94e6f539b34eefe55a1736099f616f614ec1544a43f95b05", size = 43756 },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/ec/24549de092c9b0bc3167e0beb31a11be58e8595dbcfed2b7821795bb3923/multidict-6.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6eb3bf26cd94eb306e4bc776d0964cc67a7967e4ad9299309f0ff5beec3c62be", size = 42222 },
|
||||
|
|
@ -789,38 +623,6 @@ version = "0.3.2"
|
|||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a6/16/43264e4a779dd8588c21a70f0709665ee8f611211bdd2c87d952cfa7c776/propcache-0.3.2.tar.gz", hash = "sha256:20d7d62e4e7ef05f221e0db2856b979540686342e7dd9973b815599c7057e168", size = 44139 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/80/8d/e8b436717ab9c2cfc23b116d2c297305aa4cd8339172a456d61ebf5669b8/propcache-0.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0b8d2f607bd8f80ddc04088bc2a037fdd17884a6fcadc47a96e334d72f3717be", size = 74207 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/29/1e34000e9766d112171764b9fa3226fa0153ab565d0c242c70e9945318a7/propcache-0.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:06766d8f34733416e2e34f46fea488ad5d60726bb9481d3cddf89a6fa2d9603f", size = 43648 },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/92/1ad5af0df781e76988897da39b5f086c2bf0f028b7f9bd1f409bb05b6874/propcache-0.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2dc1f4a1df4fecf4e6f68013575ff4af84ef6f478fe5344317a65d38a8e6dc9", size = 43496 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/ce/e96392460f9fb68461fabab3e095cb00c8ddf901205be4eae5ce246e5b7e/propcache-0.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be29c4f4810c5789cf10ddf6af80b041c724e629fa51e308a7a0fb19ed1ef7bf", size = 217288 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/2a/866726ea345299f7ceefc861a5e782b045545ae6940851930a6adaf1fca6/propcache-0.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59d61f6970ecbd8ff2e9360304d5c8876a6abd4530cb752c06586849ac8a9dc9", size = 227456 },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/03/07d992ccb6d930398689187e1b3c718339a1c06b8b145a8d9650e4726166/propcache-0.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:62180e0b8dbb6b004baec00a7983e4cc52f5ada9cd11f48c3528d8cfa7b96a66", size = 225429 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/e6/116ba39448753b1330f48ab8ba927dcd6cf0baea8a0ccbc512dfb49ba670/propcache-0.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c144ca294a204c470f18cf4c9d78887810d04a3e2fbb30eea903575a779159df", size = 213472 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/85/f01f5d97e54e428885a5497ccf7f54404cbb4f906688a1690cd51bf597dc/propcache-0.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c5c2a784234c28854878d68978265617aa6dc0780e53d44b4d67f3651a17a9a2", size = 204480 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/79/7bf5ab9033b8b8194cc3f7cf1aaa0e9c3256320726f64a3e1f113a812dce/propcache-0.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5745bc7acdafa978ca1642891b82c19238eadc78ba2aaa293c6863b304e552d7", size = 214530 },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/0b/bd3e0c00509b609317df4a18e6b05a450ef2d9a963e1d8bc9c9415d86f30/propcache-0.3.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:c0075bf773d66fa8c9d41f66cc132ecc75e5bb9dd7cce3cfd14adc5ca184cb95", size = 205230 },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/23/fae0ff9b54b0de4e819bbe559508da132d5683c32d84d0dc2ccce3563ed4/propcache-0.3.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5f57aa0847730daceff0497f417c9de353c575d8da3579162cc74ac294c5369e", size = 206754 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/7f/ad6a3c22630aaa5f618b4dc3c3598974a72abb4c18e45a50b3cdd091eb2f/propcache-0.3.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:eef914c014bf72d18efb55619447e0aecd5fb7c2e3fa7441e2e5d6099bddff7e", size = 218430 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/2c/ba4f1c0e8a4b4c75910742f0d333759d441f65a1c7f34683b4a74c0ee015/propcache-0.3.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2a4092e8549031e82facf3decdbc0883755d5bbcc62d3aea9d9e185549936dcf", size = 223884 },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/e4/ebe30fc399e98572019eee82ad0caf512401661985cbd3da5e3140ffa1b0/propcache-0.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:85871b050f174bc0bfb437efbdb68aaf860611953ed12418e4361bc9c392749e", size = 211480 },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/0a/7d5260b914e01d1d0906f7f38af101f8d8ed0dc47426219eeaf05e8ea7c2/propcache-0.3.2-cp311-cp311-win32.whl", hash = "sha256:36c8d9b673ec57900c3554264e630d45980fd302458e4ac801802a7fd2ef7897", size = 37757 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/2d/89fe4489a884bc0da0c3278c552bd4ffe06a1ace559db5ef02ef24ab446b/propcache-0.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53af8cb6a781b02d2ea079b5b853ba9430fcbe18a8e3ce647d5982a3ff69f39", size = 41500 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/42/9ca01b0a6f48e81615dca4765a8f1dd2c057e0540f6116a27dc5ee01dfb6/propcache-0.3.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8de106b6c84506b31c27168582cd3cb3000a6412c16df14a8628e5871ff83c10", size = 73674 },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/6e/21293133beb550f9c901bbece755d582bfaf2176bee4774000bd4dd41884/propcache-0.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:28710b0d3975117239c76600ea351934ac7b5ff56e60953474342608dbbb6154", size = 43570 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/c8/0393a0a3a2b8760eb3bde3c147f62b20044f0ddac81e9d6ed7318ec0d852/propcache-0.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce26862344bdf836650ed2487c3d724b00fbfec4233a1013f597b78c1cb73615", size = 43094 },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/2c/489afe311a690399d04a3e03b069225670c1d489eb7b044a566511c1c498/propcache-0.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bca54bd347a253af2cf4544bbec232ab982f4868de0dd684246b67a51bc6b1db", size = 226958 },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/ca/63b520d2f3d418c968bf596839ae26cf7f87bead026b6192d4da6a08c467/propcache-0.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55780d5e9a2ddc59711d727226bb1ba83a22dd32f64ee15594b9392b1f544eb1", size = 234894 },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/60/1d0ed6fff455a028d678df30cc28dcee7af77fa2b0e6962ce1df95c9a2a9/propcache-0.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:035e631be25d6975ed87ab23153db6a73426a48db688070d925aa27e996fe93c", size = 233672 },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/7c/54fd5301ef38505ab235d98827207176a5c9b2aa61939b10a460ca53e123/propcache-0.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee6f22b6eaa39297c751d0e80c0d3a454f112f5c6481214fcf4c092074cecd67", size = 224395 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/1a/89a40e0846f5de05fdc6779883bf46ba980e6df4d2ff8fb02643de126592/propcache-0.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7ca3aee1aa955438c4dba34fc20a9f390e4c79967257d830f137bd5a8a32ed3b", size = 212510 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/33/ca98368586c9566a6b8d5ef66e30484f8da84c0aac3f2d9aec6d31a11bd5/propcache-0.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7a4f30862869fa2b68380d677cc1c5fcf1e0f2b9ea0cf665812895c75d0ca3b8", size = 222949 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/11/ace870d0aafe443b33b2f0b7efdb872b7c3abd505bfb4890716ad7865e9d/propcache-0.3.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b77ec3c257d7816d9f3700013639db7491a434644c906a2578a11daf13176251", size = 217258 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/d2/86fd6f7adffcfc74b42c10a6b7db721d1d9ca1055c45d39a1a8f2a740a21/propcache-0.3.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cab90ac9d3f14b2d5050928483d3d3b8fb6b4018893fc75710e6aa361ecb2474", size = 213036 },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/94/2d7d1e328f45ff34a0a284cf5a2847013701e24c2a53117e7c280a4316b3/propcache-0.3.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:0b504d29f3c47cf6b9e936c1852246c83d450e8e063d50562115a6be6d3a2535", size = 227684 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/05/37ae63a0087677e90b1d14710e532ff104d44bc1efa3b3970fff99b891dc/propcache-0.3.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:ce2ac2675a6aa41ddb2a0c9cbff53780a617ac3d43e620f8fd77ba1c84dcfc06", size = 234562 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/7c/3f539fcae630408d0bd8bf3208b9a647ccad10976eda62402a80adf8fc34/propcache-0.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:62b4239611205294cc433845b914131b2a1f03500ff3c1ed093ed216b82621e1", size = 222142 },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/d2/34b9eac8c35f79f8a962546b3e97e9d4b990c420ee66ac8255d5d9611648/propcache-0.3.2-cp312-cp312-win32.whl", hash = "sha256:df4a81b9b53449ebc90cc4deefb052c1dd934ba85012aa912c7ea7b7e38b60c1", size = 37711 },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/61/d582be5d226cf79071681d1b46b848d6cb03d7b70af7063e33a2787eaa03/propcache-0.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:7046e79b989d7fe457bb755844019e10f693752d169076138abf17f31380800c", size = 41479 },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/d1/8c747fafa558c603c4ca19d8e20b288aa0c7cda74e9402f50f31eb65267e/propcache-0.3.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ca592ed634a73ca002967458187109265e980422116c0a107cf93d81f95af945", size = 71286 },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/99/d606cb7986b60d89c36de8a85d58764323b3a5ff07770a99d8e993b3fa73/propcache-0.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9ecb0aad4020e275652ba3975740f241bd12a61f1a784df044cf7477a02bc252", size = 42425 },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/96/ef98f91bbb42b79e9bb82bdd348b255eb9d65f14dbbe3b1594644c4073f7/propcache-0.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7f08f1cc28bd2eade7a8a3d2954ccc673bb02062e3e7da09bc75d843386b342f", size = 41846 },
|
||||
|
|
@ -858,11 +660,11 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "pycparser"
|
||||
version = "2.22"
|
||||
version = "2.23"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -951,42 +753,6 @@ version = "1.6.9"
|
|||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/e3/5e7ad3aea2abc70a731e7f72c85c74ac5b44c8f8495d80b5c8710ea23e97/pyjson5-1.6.9.tar.gz", hash = "sha256:5c91a06dad5cb73127b0ef4e1befff836f65244278372a94751367dfb0a80af5", size = 300728 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/08/59787cbf6cb93810f1ad963c1484d43abf330b36b0adf5f83f83c9c14634/pyjson5-1.6.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:001440b986f226e65237179a2a9721f0e78d6e75a7779d9941d0f10522fce12c", size = 298669 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/8e/f753c7dc284aa1921d2c71f8b90dab075ad40340ad83daced77184110217/pyjson5-1.6.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:92a80fce259648ba939c4783064c0245ad841881d73db9da9c4877d6c6ccc4e7", size = 157021 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/01/69f2150dfd4f79b77c2e187838cca90e7ccea258edaa29f2b5545600d98a/pyjson5-1.6.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9676a20a7fa05463e143bb6c2a479f0c4530ef9f81c0d1af764452f0ee3640c3", size = 151282 },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/cb/ce4837f96f94c22f49a0bf6f908ad66fc7d3ccb3f7cadd84be2ef6429fda/pyjson5-1.6.9-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f51de3798f4f056a9dc27d8a845fcf1b317fa7eb30b33753e0b3190208b68a57", size = 174811 },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/e1/eb516cdec93eb9eed10b8ae005802fb238321a7cbe418edd55a381f5eb24/pyjson5-1.6.9-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e22567f2533350f5d7ddafb76b5f5449867a44d8bbc85fb82cf27dde08f05eee", size = 169445 },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/cc/000c0fc86c15ac21aa8bc05b9fea97b3fd5995f12620427ee6b7fbaa1b9e/pyjson5-1.6.9-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a5ee5b7eddc642595b648edf0ee9af5f997ded3e5cda4345b2a8de01766ccdb6", size = 197526 },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/94/f7e6fda9b201c752fe615682c28bb55c1ac6c449dbf860374cd2785949c5/pyjson5-1.6.9-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0014adf743c4093df511934a6bca5b1e3592b36b96ff86bd74b0c4de05beafe1", size = 177109 },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/0f/afce0a2a3565ecce58d1a7a59eb95d35e7fb393ae360b99de4ad0c15599e/pyjson5-1.6.9-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5e334220158e3789fb1c7e67ca200ade1d17c70ddd1373dc1d39d5808645905f", size = 181440 },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/85/7b5b24a35f68b179e1a4c88a9ff989dc84ea7301e8e77af78aea52175000/pyjson5-1.6.9-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:384ce0c40ae635f6cb9f3b653bbf6e93f10207a98d51301805a37a0a47d168da", size = 192158 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/df/96468002515da56c344f5b6f6dd263f40a745ad751d950bfe1722058af5d/pyjson5-1.6.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2f0096855d931023c23846537b53c1af3f070ac2cb71a44a516fb80550516167", size = 1158131 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/13/c2430647c3ef74a9eb52742646b0dda604c7a6fd2895488dfae4c0658845/pyjson5-1.6.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:30cf465a427d0264e2e71b8cb75d606eca00090b4eeadf9ac18f691e032b89c7", size = 1013314 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/7a/213eb15450d5240d62554c12b395e5c7a836bab6a9755cb45c0a95dde589/pyjson5-1.6.9-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:10412ccc4fffbcd07187e4b0016bbedb1a22adf7bcd8eb4c78823a7e3cf32b9f", size = 1328236 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/e3/ab655e6148b9985d289c664c408f89ae62319006b334c62115d3fa02a850/pyjson5-1.6.9-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0ae55ee48ffb5a7f9fc22c135d38f31fc2db43b8dfc69b1285476123fe641697", size = 1256418 },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/d3/06a87bd7e5193e7590de6c3829a41dd379130df58547f3cad4f3152d97e6/pyjson5-1.6.9-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:03742b454bede9a61c2003574b74c8ba5de5d8363b0630f89a164d17bb7562a8", size = 1368856 },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/76/940e38e1278f16cbe543c4b15f70217d1fb00732274d24df2cb4d935abf2/pyjson5-1.6.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e229fddb8e5dc7696a438cf72e279540ea281c2dae56b93c7c91bae14d812a93", size = 1217749 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/d3/8290adb730eb9d26633b6b9a4f2a1f644f222b75eca8d8b52909bd960bfc/pyjson5-1.6.9-cp311-cp311-win32.whl", hash = "sha256:b1b7909ca28e332979006692af9e3079e0e6fe883f0726201b67271e45f7871a", size = 114178 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/bd/426d361536c8be1f0fa4d1724767962ebb7c69415d8910277cb84944c769/pyjson5-1.6.9-cp311-cp311-win_amd64.whl", hash = "sha256:3541f78103dab48a43326c4fe47a6175c21b76cf8104f58d8a9822fc6f54fd6d", size = 132338 },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/0f/97125395a9e87df58348442b188e6b8487dd7ec058d5d0e7a7bde69d7e12/pyjson5-1.6.9-cp311-cp311-win_arm64.whl", hash = "sha256:759a6742b72045cfdf188d3af7058dc8a8d63e47b265c344a2a68dc40d156bf3", size = 116559 },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/90/304fe37bbbdb1387d23e358ba518db951895c3e74ab0fb518ed8b62d8d8e/pyjson5-1.6.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:6aea9a2bf0c66ca9a966c9198949c1efdf6a61c4e00d42cb4f3047d4b3b0eb3e", size = 299637 },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/1b/0958022f64ad4670876c289a39828d108706cef91fc8c26bf11e92fb11f2/pyjson5-1.6.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7112cf79212c5634f6af1e15e26998690cd6e4393243fd62570702efb224dc54", size = 158946 },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/38/9476713a847fec9b8154695dc6a4ccedf03cd170a315253adb77a33b963b/pyjson5-1.6.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8524228411e312f19afb417d3dae4e34a2f4f7094fb992aa93b9413111fd1765", size = 150282 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/c9/ac7c36ea720930f13c1a82cfa64c3cb8549ef265872d62617ea10f6af0e8/pyjson5-1.6.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a4cf7e9f9a2f1194178864de70c7e152b0f07fa23ea3a8dcbcf35e8399403477", size = 166501 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/44/eb0a500edb8c475e7a7be7845eeec3d81b18afc17842af84ac11bc239281/pyjson5-1.6.9-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:20c2333f45497452adf803dbc24aedd471303d22942cc26b3806bbd7ca668608", size = 168429 },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/42/ad636dec5d611aea473e556a8dbae66dbe98db3eb6966dba0a13e7c3cfd0/pyjson5-1.6.9-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:70910ffadb8f10e69a9a379cd4712ab41785957f6b739cfe3c9c14146eae1fcd", size = 185039 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/5a/cef6422e7e9163f65ef43be36a55c42e52ae34dee14fe37bafb3f18f54d0/pyjson5-1.6.9-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aac39b8f1a9d0eea4e275ab2c4c45afc8221718b06444b12fc3ee4acb65ff7ce", size = 167053 },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/dc/19b9cb4d04984234f741d0760c89f67786d9a13efa755e650a30427a8119/pyjson5-1.6.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:96ea440ff46d5f05d79e09582007b4d887111dcbf971900a9c40e8de83e48d2d", size = 173659 },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/34/7e1bd1599ab7f339457ec72db700ca714f4ccfa11d7d83dad3ecd6233014/pyjson5-1.6.9-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09cbd1dc018bf5d3ec34128635df0545f2074bb019c00e37b885a03a454eef00", size = 182983 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/73/81448ea726861f8fde81233687fbf00c6ebb0493e9db73ade01d58effe05/pyjson5-1.6.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2ba2866a42a31b0aaab292f141f6b535cca47983786ed38f0ab7e128c0c70d27", size = 1148232 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/02/496a30f7a6516da6dbdbc72d884e3e4357d92fbbecb610ce93e41b30235d/pyjson5-1.6.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f77023f210edf75764b3392d5b58736bfe42c80546e3f1b8e7bad182fdc4685d", size = 1011157 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/c9/7509ec4144dbe37eb9b9c99b64006aa0666f50a9076a36e4bc1dd6c98c23/pyjson5-1.6.9-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:bd7453845bfcb5e3af55206184da28af2a5beb8eaf5196dc2da81a7ba90fc12c", size = 1324321 },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/2e/4f50eae481071d9a8e5ac6fb0c66f0a0067ae73dd98f1a55b46b86e0369b/pyjson5-1.6.9-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b7441803def1be9cf6e1f42d550f2ec37b459aa854cea4da5ecc8e03a9b52ed8", size = 1242684 },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/cf/fcdb6e845e3ad549bd5c80ee9c71ad03bddc0c0ab28e2bcbd306a766499e/pyjson5-1.6.9-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:beb673830e1c8db01861cab2df06af51480091d69063d028d6f77557fa58eb05", size = 1356121 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/54/7c230702fcfb1144bcaa73f17b9fd5a990852daf998e82abff11e5809ced/pyjson5-1.6.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d36ec72d063c4f1ea46b1d83f2fcdf63ba18c201a128f552e06d5f385f80e6f2", size = 1211068 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/3f/5a5e94a0de82ba561a20813112f6e78c82e37674162462939d0334c0ef3e/pyjson5-1.6.9-cp312-cp312-win32.whl", hash = "sha256:f4f6dc39723048aa6d4816bb52e8ed50b5886b5ae92c3d6b4a7d62e6cf544779", size = 114913 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/7d/f1f1d8becf53e0ad9e084ce3846e0dde0ce5516ee4ff83d4098ed00e6d96/pyjson5-1.6.9-cp312-cp312-win_amd64.whl", hash = "sha256:cc2715ddadd685f674329a7aa48e5fdbbb96346d6f61981027e1cf4c70632067", size = 134197 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/d2/a0ca8d90d5302255243d3c31c94914d25d5cdaa4fc9f8a7cd869e30076d1/pyjson5-1.6.9-cp312-cp312-win_arm64.whl", hash = "sha256:452ddb98b1ccf738dc722d3ce7fa7640c8f7345d68dc077c383fb141e35b88da", size = 115720 },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/5f/66f17cbfb5a9a3d2b6c5f835fd18656d7caac5fd4b83235c59f7e19ee4fd/pyjson5-1.6.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e0c37d6ab63df496d01ecadf957a74879285ed7a7f6500af3c14e09c073a2c9b", size = 298484 },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/57/76fa05e639d11ae615c782604cb7fca785ede308e832cd24dc8b7b6ecffa/pyjson5-1.6.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f5d48146117c3f6e16a0d4da0a4607a3d148894cc248ab6af4bd1832c6d4eac6", size = 158317 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/f7/5dba5b86be1bfdbc6d03bd30d1fe044e7c93fa97b670ccc8fe8f610ca4a4/pyjson5-1.6.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:94e6005e98ef5defb6c7aa3d7010723cb8faf3cf878b919241c9a857f95ad7e3", size = 149680 },
|
||||
|
|
@ -1043,14 +809,14 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "pytest-asyncio"
|
||||
version = "1.1.0"
|
||||
version = "1.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pytest" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4e/51/f8794af39eeb870e87a8c8068642fc07bce0c854d6865d7dd0f2a9d338c2/pytest_asyncio-1.1.0.tar.gz", hash = "sha256:796aa822981e01b68c12e4827b8697108f7205020f24b5793b3c41555dab68ea", size = 46652 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/9d/bf86eddabf8c6c9cb1ea9a869d6873b46f105a5d292d3a6f7071f5b07935/pytest_asyncio-1.1.0-py3-none-any.whl", hash = "sha256:5fe2d69607b0bd75c656d1211f969cadba035030156745ee09e7d71740e58ecf", size = 15157 },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1141,24 +907,6 @@ version = "6.0.2"
|
|||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", size = 184612 },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", size = 172040 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", size = 736829 },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/16/6af8d6a6b210c8e54f1406a6b9481febf9c64a3109c541567e35a49aa2e7/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", size = 764167 },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/e4/2c27590dfc9992f73aabbeb9241ae20220bd9452df27483b6e56d3975cc5/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", size = 762952 },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/97/ecc1abf4a823f5ac61941a9c00fe501b02ac3ab0e373c3857f7d4b83e2b6/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4", size = 735301 },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/73/0f49dacd6e82c9430e46f4a027baa4ca205e8b0a9dce1397f44edc23559d/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", size = 756638 },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/5f/956f0f9fc65223a58fbc14459bf34b4cc48dec52e00535c79b8db361aabd/PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", size = 143850 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/23/8da0bbe2ab9dcdd11f4f4557ccaf95c10b9811b13ecced089d43ce59c3c8/PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", size = 161980 },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154 },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611 },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309 },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679 },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428 },
|
||||
|
|
@ -1176,34 +924,6 @@ version = "2025.9.1"
|
|||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b2/5a/4c63457fbcaf19d138d72b2e9b39405954f98c0349b31c601bfcb151582c/regex-2025.9.1.tar.gz", hash = "sha256:88ac07b38d20b54d79e704e38aa3bd2c0f8027432164226bdee201a1c0c9c9ff", size = 400852 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/06/4d/f741543c0c59f96c6625bc6c11fea1da2e378b7d293ffff6f318edc0ce14/regex-2025.9.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e5bcf112b09bfd3646e4db6bf2e598534a17d502b0c01ea6550ba4eca780c5e6", size = 484811 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/bd/27e73e92635b6fbd51afc26a414a3133243c662949cd1cda677fe7bb09bd/regex-2025.9.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:67a0295a3c31d675a9ee0238d20238ff10a9a2fdb7a1323c798fc7029578b15c", size = 288977 },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/7d/7dc0c6efc8bc93cd6e9b947581f5fde8a5dbaa0af7c4ec818c5729fdc807/regex-2025.9.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ea8267fbadc7d4bd7c1301a50e85c2ff0de293ff9452a1a9f8d82c6cafe38179", size = 286606 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/01/9b5c6dd394f97c8f2c12f6e8f96879c9ac27292a718903faf2e27a0c09f6/regex-2025.9.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6aeff21de7214d15e928fb5ce757f9495214367ba62875100d4c18d293750cc1", size = 792436 },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/24/b7430cfc6ee34bbb3db6ff933beb5e7692e5cc81e8f6f4da63d353566fb0/regex-2025.9.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d89f1bbbbbc0885e1c230f7770d5e98f4f00b0ee85688c871d10df8b184a6323", size = 858705 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/98/155f914b4ea6ae012663188545c4f5216c11926d09b817127639d618b003/regex-2025.9.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ca3affe8ddea498ba9d294ab05f5f2d3b5ad5d515bc0d4a9016dd592a03afe52", size = 905881 },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/a7/a470e7bc8259c40429afb6d6a517b40c03f2f3e455c44a01abc483a1c512/regex-2025.9.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:91892a7a9f0a980e4c2c85dd19bc14de2b219a3a8867c4b5664b9f972dcc0c78", size = 798968 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/fa/33f6fec4d41449fea5f62fdf5e46d668a1c046730a7f4ed9f478331a8e3a/regex-2025.9.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e1cb40406f4ae862710615f9f636c1e030fd6e6abe0e0f65f6a695a2721440c6", size = 781884 },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/de/2b45f36ab20da14eedddf5009d370625bc5942d9953fa7e5037a32d66843/regex-2025.9.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:94f6cff6f7e2149c7e6499a6ecd4695379eeda8ccbccb9726e8149f2fe382e92", size = 852935 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/f9/878f4fc92c87e125e27aed0f8ee0d1eced9b541f404b048f66f79914475a/regex-2025.9.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:6c0226fb322b82709e78c49cc33484206647f8a39954d7e9de1567f5399becd0", size = 844340 },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/c2/5b6f2bce6ece5f8427c718c085eca0de4bbb4db59f54db77aa6557aef3e9/regex-2025.9.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a12f59c7c380b4fcf7516e9cbb126f95b7a9518902bcf4a852423ff1dcd03e6a", size = 787238 },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/66/1ef1081c831c5b611f6f55f6302166cfa1bc9574017410ba5595353f846a/regex-2025.9.1-cp311-cp311-win32.whl", hash = "sha256:49865e78d147a7a4f143064488da5d549be6bfc3f2579e5044cac61f5c92edd4", size = 264118 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/e0/8adc550d7169df1d6b9be8ff6019cda5291054a0107760c2f30788b6195f/regex-2025.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:d34b901f6f2f02ef60f4ad3855d3a02378c65b094efc4b80388a3aeb700a5de7", size = 276151 },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/bd/46fef29341396d955066e55384fb93b0be7d64693842bf4a9a398db6e555/regex-2025.9.1-cp311-cp311-win_arm64.whl", hash = "sha256:47d7c2dab7e0b95b95fd580087b6ae196039d62306a592fa4e162e49004b6299", size = 268460 },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/ef/a0372febc5a1d44c1be75f35d7e5aff40c659ecde864d7fa10e138f75e74/regex-2025.9.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:84a25164bd8dcfa9f11c53f561ae9766e506e580b70279d05a7946510bdd6f6a", size = 486317 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/25/d64543fb7eb41a1024786d518cc57faf1ce64aa6e9ddba097675a0c2f1d2/regex-2025.9.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:645e88a73861c64c1af558dd12294fb4e67b5c1eae0096a60d7d8a2143a611c7", size = 289698 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/dc/fbf31fc60be317bd9f6f87daa40a8a9669b3b392aa8fe4313df0a39d0722/regex-2025.9.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:10a450cba5cd5409526ee1d4449f42aad38dd83ac6948cbd6d7f71ca7018f7db", size = 287242 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/74/f933a607a538f785da5021acf5323961b4620972e2c2f1f39b6af4b71db7/regex-2025.9.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9dc5991592933a4192c166eeb67b29d9234f9c86344481173d1bc52f73a7104", size = 797441 },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/d0/71fc49b4f20e31e97f199348b8c4d6e613e7b6a54a90eb1b090c2b8496d7/regex-2025.9.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a32291add816961aab472f4fad344c92871a2ee33c6c219b6598e98c1f0108f2", size = 862654 },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/05/984edce1411a5685ba9abbe10d42cdd9450aab4a022271f9585539788150/regex-2025.9.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:588c161a68a383478e27442a678e3b197b13c5ba51dbba40c1ccb8c4c7bee9e9", size = 910862 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/02/5c891bb5fe0691cc1bad336e3a94b9097fbcf9707ec8ddc1dce9f0397289/regex-2025.9.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47829ffaf652f30d579534da9085fe30c171fa2a6744a93d52ef7195dc38218b", size = 801991 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/ae/fd10d6ad179910f7a1b3e0a7fde1ef8bb65e738e8ac4fd6ecff3f52252e4/regex-2025.9.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e978e5a35b293ea43f140c92a3269b6ab13fe0a2bf8a881f7ac740f5a6ade85", size = 786651 },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/cf/9d686b07bbc5bf94c879cc168db92542d6bc9fb67088d03479fef09ba9d3/regex-2025.9.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4cf09903e72411f4bf3ac1eddd624ecfd423f14b2e4bf1c8b547b72f248b7bf7", size = 856556 },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/9d/302f8a29bb8a49528abbab2d357a793e2a59b645c54deae0050f8474785b/regex-2025.9.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:d016b0f77be63e49613c9e26aaf4a242f196cd3d7a4f15898f5f0ab55c9b24d2", size = 849001 },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/fa/b4c6dbdedc85ef4caec54c817cd5f4418dbfa2453214119f2538082bf666/regex-2025.9.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:656563e620de6908cd1c9d4f7b9e0777e3341ca7db9d4383bcaa44709c90281e", size = 788138 },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/1b/91ee17a3cbf87f81e8c110399279d0e57f33405468f6e70809100f2ff7d8/regex-2025.9.1-cp312-cp312-win32.whl", hash = "sha256:df33f4ef07b68f7ab637b1dbd70accbf42ef0021c201660656601e8a9835de45", size = 264524 },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/28/6ba31cce05b0f1ec6b787921903f83bd0acf8efde55219435572af83c350/regex-2025.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:5aba22dfbc60cda7c0853516104724dc904caa2db55f2c3e6e984eb858d3edf3", size = 275489 },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/ed/ea49f324db00196e9ef7fe00dd13c6164d5173dd0f1bbe495e61bb1fb09d/regex-2025.9.1-cp312-cp312-win_arm64.whl", hash = "sha256:ec1efb4c25e1849c2685fa95da44bfde1b28c62d356f9c8d861d4dad89ed56e9", size = 268589 },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/25/b2959ce90c6138c5142fe5264ee1f9b71a0c502ca4c7959302a749407c79/regex-2025.9.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bc6834727d1b98d710a63e6c823edf6ffbf5792eba35d3fa119531349d4142ef", size = 485932 },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/2e/6507a2a85f3f2be6643438b7bd976e67ad73223692d6988eb1ff444106d3/regex-2025.9.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c3dc05b6d579875719bccc5f3037b4dc80433d64e94681a0061845bd8863c025", size = 289568 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/d8/de4a4b57215d99868f1640e062a7907e185ec7476b4b689e2345487c1ff4/regex-2025.9.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:22213527df4c985ec4a729b055a8306272d41d2f45908d7bacb79be0fa7a75ad", size = 286984 },
|
||||
|
|
@ -1389,40 +1109,6 @@ dependencies = [
|
|||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3c/fb/efaa23fa4e45537b827620f04cf8f3cd658b76642205162e072703a5b963/yarl-1.20.1.tar.gz", hash = "sha256:d017a4997ee50c91fd5466cef416231bb82177b93b029906cefc542ce14c35ac", size = 186428 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/18/893b50efc2350e47a874c5c2d67e55a0ea5df91186b2a6f5ac52eff887cd/yarl-1.20.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:47ee6188fea634bdfaeb2cc420f5b3b17332e6225ce88149a17c413c77ff269e", size = 133833 },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/ed/b8773448030e6fc47fa797f099ab9eab151a43a25717f9ac043844ad5ea3/yarl-1.20.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d0f6500f69e8402d513e5eedb77a4e1818691e8f45e6b687147963514d84b44b", size = 91070 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/e3/409bd17b1e42619bf69f60e4f031ce1ccb29bd7380117a55529e76933464/yarl-1.20.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7a8900a42fcdaad568de58887c7b2f602962356908eedb7628eaf6021a6e435b", size = 89818 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/77/64d8431a4d77c856eb2d82aa3de2ad6741365245a29b3a9543cd598ed8c5/yarl-1.20.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bad6d131fda8ef508b36be3ece16d0902e80b88ea7200f030a0f6c11d9e508d4", size = 347003 },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/d2/0c7e4def093dcef0bd9fa22d4d24b023788b0a33b8d0088b51aa51e21e99/yarl-1.20.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:df018d92fe22aaebb679a7f89fe0c0f368ec497e3dda6cb81a567610f04501f1", size = 336537 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/f3/fc514f4b2cf02cb59d10cbfe228691d25929ce8f72a38db07d3febc3f706/yarl-1.20.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f969afbb0a9b63c18d0feecf0db09d164b7a44a053e78a7d05f5df163e43833", size = 362358 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/6d/a313ac8d8391381ff9006ac05f1d4331cee3b1efaa833a53d12253733255/yarl-1.20.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:812303eb4aa98e302886ccda58d6b099e3576b1b9276161469c25803a8db277d", size = 357362 },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/70/8f78a95d6935a70263d46caa3dd18e1f223cf2f2ff2037baa01a22bc5b22/yarl-1.20.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98c4a7d166635147924aa0bf9bfe8d8abad6fffa6102de9c99ea04a1376f91e8", size = 348979 },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/05/42773027968968f4f15143553970ee36ead27038d627f457cc44bbbeecf3/yarl-1.20.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:12e768f966538e81e6e7550f9086a6236b16e26cd964cf4df35349970f3551cf", size = 337274 },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/be/665634aa196954156741ea591d2f946f1b78ceee8bb8f28488bf28c0dd62/yarl-1.20.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fe41919b9d899661c5c28a8b4b0acf704510b88f27f0934ac7a7bebdd8938d5e", size = 363294 },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/90/73448401d36fa4e210ece5579895731f190d5119c4b66b43b52182e88cd5/yarl-1.20.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:8601bc010d1d7780592f3fc1bdc6c72e2b6466ea34569778422943e1a1f3c389", size = 358169 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/b0/fce922d46dc1eb43c811f1889f7daa6001b27a4005587e94878570300881/yarl-1.20.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:daadbdc1f2a9033a2399c42646fbd46da7992e868a5fe9513860122d7fe7a73f", size = 362776 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/0d/b172628fce039dae8977fd22caeff3eeebffd52e86060413f5673767c427/yarl-1.20.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:03aa1e041727cb438ca762628109ef1333498b122e4c76dd858d186a37cec845", size = 381341 },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/9b/5b886d7671f4580209e855974fe1cecec409aa4a89ea58b8f0560dc529b1/yarl-1.20.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:642980ef5e0fa1de5fa96d905c7e00cb2c47cb468bfcac5a18c58e27dbf8d8d1", size = 379988 },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/be/75ef5fd0fcd8f083a5d13f78fd3f009528132a1f2a1d7c925c39fa20aa79/yarl-1.20.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:86971e2795584fe8c002356d3b97ef6c61862720eeff03db2a7c86b678d85b3e", size = 371113 },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/4f/62faab3b479dfdcb741fe9e3f0323e2a7d5cd1ab2edc73221d57ad4834b2/yarl-1.20.1-cp311-cp311-win32.whl", hash = "sha256:597f40615b8d25812f14562699e287f0dcc035d25eb74da72cae043bb884d773", size = 81485 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/09/d9c7942f8f05c32ec72cd5c8e041c8b29b5807328b68b4801ff2511d4d5e/yarl-1.20.1-cp311-cp311-win_amd64.whl", hash = "sha256:26ef53a9e726e61e9cd1cda6b478f17e350fb5800b4bd1cd9fe81c4d91cfeb2e", size = 86686 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/9a/cb7fad7d73c69f296eda6815e4a2c7ed53fc70c2f136479a91c8e5fbdb6d/yarl-1.20.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdcc4cd244e58593a4379fe60fdee5ac0331f8eb70320a24d591a3be197b94a9", size = 133667 },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/38/688577a1cb1e656e3971fb66a3492501c5a5df56d99722e57c98249e5b8a/yarl-1.20.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b29a2c385a5f5b9c7d9347e5812b6f7ab267193c62d282a540b4fc528c8a9d2a", size = 91025 },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/ec/72991ae51febeb11a42813fc259f0d4c8e0507f2b74b5514618d8b640365/yarl-1.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1112ae8154186dfe2de4732197f59c05a83dc814849a5ced892b708033f40dc2", size = 89709 },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/da/4d798025490e89426e9f976702e5f9482005c548c579bdae792a4c37769e/yarl-1.20.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90bbd29c4fe234233f7fa2b9b121fb63c321830e5d05b45153a2ca68f7d310ee", size = 352287 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/26/54a15c6a567aac1c61b18aa0f4b8aa2e285a52d547d1be8bf48abe2b3991/yarl-1.20.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:680e19c7ce3710ac4cd964e90dad99bf9b5029372ba0c7cbfcd55e54d90ea819", size = 345429 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/95/9dcf2386cb875b234353b93ec43e40219e14900e046bf6ac118f94b1e353/yarl-1.20.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4a979218c1fdb4246a05efc2cc23859d47c89af463a90b99b7c56094daf25a16", size = 365429 },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/b2/33a8750f6a4bc224242a635f5f2cff6d6ad5ba651f6edcccf721992c21a0/yarl-1.20.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:255b468adf57b4a7b65d8aad5b5138dce6a0752c139965711bdcb81bc370e1b6", size = 363862 },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/28/3ab7acc5b51f4434b181b0cee8f1f4b77a65919700a355fb3617f9488874/yarl-1.20.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a97d67108e79cfe22e2b430d80d7571ae57d19f17cda8bb967057ca8a7bf5bfd", size = 355616 },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/a3/f666894aa947a371724ec7cd2e5daa78ee8a777b21509b4252dd7bd15e29/yarl-1.20.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8570d998db4ddbfb9a590b185a0a33dbf8aafb831d07a5257b4ec9948df9cb0a", size = 339954 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/81/5f466427e09773c04219d3450d7a1256138a010b6c9f0af2d48565e9ad13/yarl-1.20.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:97c75596019baae7c71ccf1d8cc4738bc08134060d0adfcbe5642f778d1dca38", size = 365575 },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/e3/e4b0ad8403e97e6c9972dd587388940a032f030ebec196ab81a3b8e94d31/yarl-1.20.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1c48912653e63aef91ff988c5432832692ac5a1d8f0fb8a33091520b5bbe19ef", size = 365061 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/99/b8a142e79eb86c926f9f06452eb13ecb1bb5713bd01dc0038faf5452e544/yarl-1.20.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4c3ae28f3ae1563c50f3d37f064ddb1511ecc1d5584e88c6b7c63cf7702a6d5f", size = 364142 },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/f2/08ed34a4a506d82a1a3e5bab99ccd930a040f9b6449e9fd050320e45845c/yarl-1.20.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c5e9642f27036283550f5f57dc6156c51084b458570b9d0d96100c8bebb186a8", size = 381894 },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/f8/9a3fbf0968eac704f681726eff595dce9b49c8a25cd92bf83df209668285/yarl-1.20.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:2c26b0c49220d5799f7b22c6838409ee9bc58ee5c95361a4d7831f03cc225b5a", size = 383378 },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/85/9363f77bdfa1e4d690957cd39d192c4cacd1c58965df0470a4905253b54f/yarl-1.20.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:564ab3d517e3d01c408c67f2e5247aad4019dcf1969982aba3974b4093279004", size = 374069 },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/99/9918c8739ba271dcd935400cff8b32e3cd319eaf02fcd023d5dcd487a7c8/yarl-1.20.1-cp312-cp312-win32.whl", hash = "sha256:daea0d313868da1cf2fac6b2d3a25c6e3a9e879483244be38c8e6a41f1d876a5", size = 81249 },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/83/5d9092950565481b413b31a23e75dd3418ff0a277d6e0abf3729d4d1ce25/yarl-1.20.1-cp312-cp312-win_amd64.whl", hash = "sha256:48ea7d7f9be0487339828a4de0360d7ce0efc06524a48e1810f945c45b813698", size = 86710 },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/e1/2411b6d7f769a07687acee88a062af5833cf1966b7266f3d8dfb3d3dc7d3/yarl-1.20.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:0b5ff0fbb7c9f1b1b5ab53330acbfc5247893069e7716840c8e7d5bb7355038a", size = 131811 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/27/584394e1cb76fb771371770eccad35de400e7b434ce3142c2dd27392c968/yarl-1.20.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:14f326acd845c2b2e2eb38fb1346c94f7f3b01a4f5c788f8144f9b630bfff9a3", size = 90078 },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/9a/3246ae92d4049099f52d9b0fe3486e3b500e29b7ea872d0f152966fc209d/yarl-1.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f60e4ad5db23f0b96e49c018596707c3ae89f5d0bd97f0ad3684bcbad899f1e7", size = 88748 },
|
||||
|
|
|
|||
Loading…
Reference in a new issue