mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-03-17 16:47:29 +00:00
Compare commits
7 Commits
6b8a8ba8a8
...
refactor/s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c1de44ea4 | ||
|
|
7f6c81aaf2 | ||
|
|
29a697a8e7 | ||
|
|
c5b063391c | ||
|
|
5fa0b33664 | ||
|
|
5650c2b591 | ||
|
|
5f49663ea8 |
@@ -57,7 +57,7 @@ dependencies = [
|
||||
"chardet>=5.2.0,<6",
|
||||
"curl-cffi>=0.7.0b4,<0.14",
|
||||
"pyplayready>=0.6.3,<0.7",
|
||||
"httpx>=0.28.1,<0.29",
|
||||
"httpx[http2]>=0.28.1,<0.29",
|
||||
"cryptography>=45.0.0,<47",
|
||||
"subby",
|
||||
"aiohttp>=3.13.3,<4",
|
||||
|
||||
@@ -211,7 +211,7 @@ def serve(
|
||||
"devices": prd_devices,
|
||||
"users": {
|
||||
user_key: {
|
||||
"devices": user_cfg.get("playready_devices", prd_device_names),
|
||||
"devices": user_cfg.get("playready_devices", []),
|
||||
"username": user_cfg.get("username", "user"),
|
||||
}
|
||||
for user_key, user_cfg in serve_config["users"].items()
|
||||
|
||||
@@ -7,8 +7,11 @@ a WebAssembly module that runs locally via wasmtime.
|
||||
|
||||
import base64
|
||||
import ctypes
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional, Union
|
||||
@@ -17,6 +20,8 @@ import wasmtime
|
||||
|
||||
from unshackle.core import binaries
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MonaLisaCDM:
|
||||
"""
|
||||
@@ -128,10 +133,27 @@ class MonaLisaCDM:
|
||||
}
|
||||
|
||||
self.exports["___wasm_call_ctors"](self.store)
|
||||
self.ctx = self.exports["_monalisa_context_alloc"](self.store)
|
||||
ctx = self.exports["_monalisa_context_alloc"](self.store)
|
||||
self.ctx = ctx
|
||||
|
||||
# _monalisa_context_alloc is expected to return a positive pointer/handle.
|
||||
# Treat 0/negative/non-int-like values as allocation failure.
|
||||
try:
|
||||
ctx_int = int(ctx)
|
||||
except Exception:
|
||||
ctx_int = None
|
||||
|
||||
if ctx_int is None or ctx_int <= 0:
|
||||
# Ensure we don't leave a partially-initialized instance around.
|
||||
self.close()
|
||||
raise RuntimeError(f"Failed to allocate MonaLisa context (ctx={ctx!r})")
|
||||
return 1
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to initialize session: {e}")
|
||||
# Clean up partial state (e.g., store/memory/instance) before propagating failure.
|
||||
self.close()
|
||||
if isinstance(e, RuntimeError):
|
||||
raise
|
||||
raise RuntimeError(f"Failed to initialize session: {e}") from e
|
||||
|
||||
def close(self, session_id: int = 1) -> None:
|
||||
"""
|
||||
@@ -188,7 +210,9 @@ class MonaLisaCDM:
|
||||
# Extract DCID from license to generate KID
|
||||
try:
|
||||
decoded = base64.b64decode(license_b64).decode("ascii", errors="ignore")
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
# Avoid logging raw license content; log only safe metadata.
|
||||
logger.exception("Failed to base64-decode MonaLisa license (len=%s): %s", len(license_b64), e)
|
||||
decoded = ""
|
||||
|
||||
m = re.search(
|
||||
@@ -198,7 +222,14 @@ class MonaLisaCDM:
|
||||
if m:
|
||||
kid_bytes = uuid.uuid5(uuid.NAMESPACE_DNS, m.group()).bytes
|
||||
else:
|
||||
kid_bytes = uuid.UUID(int=0).bytes
|
||||
# No DCID in the license: derive a deterministic per-license KID to avoid collisions.
|
||||
try:
|
||||
license_raw = base64.b64decode(license_b64)
|
||||
except Exception:
|
||||
license_raw = license_b64.encode("utf-8", errors="replace")
|
||||
|
||||
license_hash = hashlib.sha256(license_raw).hexdigest()
|
||||
kid_bytes = uuid.uuid5(uuid.NAMESPACE_DNS, f"monalisa:license:{license_hash}").bytes
|
||||
|
||||
return {"kid": kid_bytes.hex(), "key": key_bytes.hex(), "type": "CONTENT"}
|
||||
|
||||
@@ -221,21 +252,29 @@ class MonaLisaCDM:
|
||||
stack = 0
|
||||
converted_args = []
|
||||
|
||||
for arg in args:
|
||||
if isinstance(arg, str):
|
||||
if stack == 0:
|
||||
stack = self.exports["stackSave"](self.store)
|
||||
max_length = (len(arg) << 2) + 1
|
||||
ptr = self.exports["stackAlloc"](self.store, max_length)
|
||||
self._string_to_utf8(arg, ptr, max_length)
|
||||
converted_args.append(ptr)
|
||||
else:
|
||||
converted_args.append(arg)
|
||||
try:
|
||||
for arg in args:
|
||||
if isinstance(arg, str):
|
||||
if stack == 0:
|
||||
stack = self.exports["stackSave"](self.store)
|
||||
max_length = (len(arg) << 2) + 1
|
||||
ptr = self.exports["stackAlloc"](self.store, max_length)
|
||||
self._string_to_utf8(arg, ptr, max_length)
|
||||
converted_args.append(ptr)
|
||||
else:
|
||||
converted_args.append(arg)
|
||||
|
||||
result = self.exports[func_name](self.store, *converted_args)
|
||||
|
||||
if stack != 0:
|
||||
self.exports["stackRestore"](self.store, stack)
|
||||
result = self.exports[func_name](self.store, *converted_args)
|
||||
finally:
|
||||
# stackAlloc pointers live on the WASM stack; always restore even if the call throws.
|
||||
if stack != 0:
|
||||
exc = sys.exc_info()[1]
|
||||
try:
|
||||
self.exports["stackRestore"](self.store, stack)
|
||||
except Exception:
|
||||
# If we're already failing, don't mask the original exception.
|
||||
if exc is None:
|
||||
raise
|
||||
|
||||
if return_type is bool:
|
||||
return bool(result)
|
||||
@@ -243,6 +282,13 @@ class MonaLisaCDM:
|
||||
|
||||
def _write_i32(self, addr: int, value: int) -> None:
|
||||
"""Write a 32-bit integer to WASM memory."""
|
||||
if addr % 4 != 0:
|
||||
raise ValueError(f"Unaligned i32 write: addr={addr} (must be 4-byte aligned)")
|
||||
|
||||
data_len = self.memory.data_len(self.store)
|
||||
if addr < 0 or addr + 4 > data_len:
|
||||
raise IndexError(f"i32 write out of bounds: addr={addr}, mem_len={data_len}")
|
||||
|
||||
data = self.memory.data_ptr(self.store)
|
||||
mem_ptr = ctypes.cast(data, ctypes.POINTER(ctypes.c_int32))
|
||||
mem_ptr[addr >> 2] = value
|
||||
|
||||
@@ -11,6 +11,7 @@ from Cryptodome.Util.Padding import unpad
|
||||
from curl_cffi.requests import Session as CurlSession
|
||||
from m3u8.model import Key
|
||||
from requests import Session
|
||||
import httpx
|
||||
|
||||
|
||||
class ClearKey:
|
||||
@@ -70,8 +71,8 @@ class ClearKey:
|
||||
"""
|
||||
if not isinstance(m3u_key, Key):
|
||||
raise ValueError(f"Provided M3U Key is in an unexpected type {m3u_key!r}")
|
||||
if not isinstance(session, (Session, CurlSession, type(None))):
|
||||
raise TypeError(f"Expected session to be a {Session} or {CurlSession}, not a {type(session)}")
|
||||
if not isinstance(session, (Session, CurlSession, httpx.Client, type(None))):
|
||||
raise TypeError(f"Expected session to be a {Session} or {CurlSession} or {httpx.Client}, not a {type(session)}")
|
||||
|
||||
if not m3u_key.method.startswith("AES"):
|
||||
raise ValueError(f"Provided M3U Key is not an AES Clear Key, {m3u_key.method}")
|
||||
|
||||
@@ -234,11 +234,7 @@ class MonaLisa:
|
||||
raise MonaLisa.Exceptions.DecryptionFailed(f"Segment file does not exist: {segment_path}")
|
||||
|
||||
# Stage 1: ML-Worker decryption
|
||||
# Do not pass secrets via argv (visible in process listings/logs).
|
||||
# ML-Worker supports receiving the key out-of-band; we provide it via env + stdin.
|
||||
cmd = [str(worker_path), "-", str(bbts_path), str(ents_path)]
|
||||
worker_env = os.environ.copy()
|
||||
worker_env["WORKER_KEY"] = self._key
|
||||
cmd = [str(worker_path), str(self._key), str(bbts_path), str(ents_path)]
|
||||
|
||||
startupinfo = None
|
||||
if sys.platform == "win32":
|
||||
@@ -251,8 +247,6 @@ class MonaLisa:
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
input=self._key,
|
||||
env=worker_env,
|
||||
startupinfo=startupinfo,
|
||||
timeout=worker_timeout_s,
|
||||
)
|
||||
|
||||
@@ -15,6 +15,7 @@ from urllib.parse import urljoin, urlparse
|
||||
from uuid import UUID
|
||||
from zlib import crc32
|
||||
|
||||
import httpx
|
||||
import requests
|
||||
from curl_cffi.requests import Session as CurlSession
|
||||
from langcodes import Language, tag_is_valid
|
||||
@@ -50,7 +51,7 @@ class DASH:
|
||||
self.url = url
|
||||
|
||||
@classmethod
|
||||
def from_url(cls, url: str, session: Optional[Union[Session, CurlSession]] = None, **args: Any) -> DASH:
|
||||
def from_url(cls, url: str, session: Optional[Union[Session, CurlSession, httpx.Client]] = None, **args: Any) -> DASH:
|
||||
if not url:
|
||||
raise requests.URLRequired("DASH manifest URL must be provided for relative path computations.")
|
||||
if not isinstance(url, str):
|
||||
@@ -58,16 +59,15 @@ class DASH:
|
||||
|
||||
if not session:
|
||||
session = Session()
|
||||
elif not isinstance(session, (Session, CurlSession)):
|
||||
raise TypeError(f"Expected session to be a {Session} or {CurlSession}, not {session!r}")
|
||||
|
||||
res = session.get(url, **args)
|
||||
elif not isinstance(session, (Session, CurlSession, httpx.Client)):
|
||||
raise TypeError(f"Expected session to be a {Session} or {CurlSession} or {httpx.Client}, not {session!r}")
|
||||
try:
|
||||
res = session.get(url, **args)
|
||||
res.raise_for_status()
|
||||
except Exception as e:
|
||||
raise RuntimeError("Failed to request the MPD document.") from e
|
||||
if res.url != url:
|
||||
url = res.url
|
||||
|
||||
if not res.ok:
|
||||
raise requests.ConnectionError("Failed to request the MPD document.", response=res)
|
||||
|
||||
return DASH.from_text(res.text, url)
|
||||
|
||||
@classmethod
|
||||
@@ -261,8 +261,8 @@ class DASH:
|
||||
):
|
||||
if not session:
|
||||
session = Session()
|
||||
elif not isinstance(session, (Session, CurlSession)):
|
||||
raise TypeError(f"Expected session to be a {Session} or {CurlSession}, not {session!r}")
|
||||
elif not isinstance(session, (Session, CurlSession, httpx.Client)):
|
||||
raise TypeError(f"Expected session to be a {Session} or {CurlSession} or {httpx.Client}, not {session!r}")
|
||||
|
||||
if proxy:
|
||||
session.proxies.update({"all": proxy})
|
||||
|
||||
@@ -15,6 +15,7 @@ from urllib.parse import urljoin
|
||||
from uuid import UUID
|
||||
from zlib import crc32
|
||||
|
||||
import httpx
|
||||
import m3u8
|
||||
import requests
|
||||
from curl_cffi.requests import Response as CurlResponse
|
||||
@@ -38,7 +39,7 @@ from unshackle.core.utilities import get_debug_logger, get_extension, is_close_m
|
||||
|
||||
|
||||
class HLS:
|
||||
def __init__(self, manifest: M3U8, session: Optional[Union[Session, CurlSession]] = None):
|
||||
def __init__(self, manifest: M3U8, session: Optional[Union[Session, CurlSession, httpx.Client]] = None):
|
||||
if not manifest:
|
||||
raise ValueError("HLS manifest must be provided.")
|
||||
if not isinstance(manifest, M3U8):
|
||||
@@ -50,7 +51,7 @@ class HLS:
|
||||
self.session = session or Session()
|
||||
|
||||
@classmethod
|
||||
def from_url(cls, url: str, session: Optional[Union[Session, CurlSession]] = None, **args: Any) -> HLS:
|
||||
def from_url(cls, url: str, session: Optional[Union[Session, CurlSession, httpx.Client]] = None, **args: Any) -> HLS:
|
||||
if not url:
|
||||
raise requests.URLRequired("HLS manifest URL must be provided.")
|
||||
if not isinstance(url, str):
|
||||
@@ -58,23 +59,15 @@ class HLS:
|
||||
|
||||
if not session:
|
||||
session = Session()
|
||||
elif not isinstance(session, (Session, CurlSession)):
|
||||
raise TypeError(f"Expected session to be a {Session} or {CurlSession}, not {session!r}")
|
||||
|
||||
res = session.get(url, **args)
|
||||
|
||||
# Handle requests and curl_cffi response objects
|
||||
if isinstance(res, requests.Response):
|
||||
if not res.ok:
|
||||
raise requests.ConnectionError("Failed to request the M3U(8) document.", response=res)
|
||||
content = res.text
|
||||
elif isinstance(res, CurlResponse):
|
||||
if not res.ok:
|
||||
raise requests.ConnectionError("Failed to request the M3U(8) document.", response=res)
|
||||
content = res.text
|
||||
else:
|
||||
raise TypeError(f"Expected response to be a requests.Response or curl_cffi.Response, not {type(res)}")
|
||||
elif not isinstance(session, (Session, CurlSession, httpx.Client)):
|
||||
raise TypeError(f"Expected session to be a {Session} or {CurlSession} or {httpx.Client}, not {session!r}")
|
||||
|
||||
try:
|
||||
res = session.get(url, **args)
|
||||
res.raise_for_status()
|
||||
except Exception as e:
|
||||
raise RuntimeError("Failed to request the M3U(8) document.") from e
|
||||
content = res.text
|
||||
master = m3u8.loads(content, uri=url)
|
||||
|
||||
return cls(master, session)
|
||||
@@ -265,7 +258,7 @@ class HLS:
|
||||
save_path: Path,
|
||||
save_dir: Path,
|
||||
progress: partial,
|
||||
session: Optional[Union[Session, CurlSession]] = None,
|
||||
session: Optional[Union[Session, CurlSession, httpx.Client]] = None,
|
||||
proxy: Optional[str] = None,
|
||||
max_workers: Optional[int] = None,
|
||||
license_widevine: Optional[Callable] = None,
|
||||
@@ -274,8 +267,8 @@ class HLS:
|
||||
) -> None:
|
||||
if not session:
|
||||
session = Session()
|
||||
elif not isinstance(session, (Session, CurlSession)):
|
||||
raise TypeError(f"Expected session to be a {Session} or {CurlSession}, not {session!r}")
|
||||
elif not isinstance(session, (Session, CurlSession, httpx.Client)):
|
||||
raise TypeError(f"Expected session to be a {Session} or {CurlSession} or {httpx.Client}, not {session!r}")
|
||||
|
||||
if proxy:
|
||||
# Handle proxies differently based on session type
|
||||
@@ -611,8 +604,6 @@ class HLS:
|
||||
discon_i += 1
|
||||
range_offset = 0 # TODO: Should this be reset or not?
|
||||
map_data = None
|
||||
if encryption_data:
|
||||
encryption_data = (encryption_data[0], encryption_data[1])
|
||||
|
||||
if segment.init_section and (not map_data or segment.init_section != map_data[0]):
|
||||
if segment.init_section.byterange:
|
||||
@@ -850,7 +841,7 @@ class HLS:
|
||||
|
||||
@staticmethod
|
||||
def parse_session_data_keys(
|
||||
manifest: M3U8, session: Optional[Union[Session, CurlSession]] = None
|
||||
manifest: M3U8, session: Optional[Union[Session, CurlSession, httpx.Client]] = None
|
||||
) -> list[m3u8.model.Key]:
|
||||
"""Parse `com.apple.hls.keys` session data and return Key objects."""
|
||||
keys: list[m3u8.model.Key] = []
|
||||
@@ -925,7 +916,7 @@ class HLS:
|
||||
def get_track_kid_from_init(
|
||||
master: M3U8,
|
||||
track: AnyTrack,
|
||||
session: Union[Session, CurlSession],
|
||||
session: Union[Session, CurlSession, httpx.Client],
|
||||
) -> Optional[UUID]:
|
||||
"""
|
||||
Extract the track's Key ID from its init segment (EXT-X-MAP).
|
||||
@@ -992,7 +983,7 @@ class HLS:
|
||||
@staticmethod
|
||||
def get_drm(
|
||||
key: Union[m3u8.model.SessionKey, m3u8.model.Key],
|
||||
session: Optional[Union[Session, CurlSession]] = None,
|
||||
session: Optional[Union[Session, CurlSession, httpx.Client]] = None,
|
||||
) -> DRM_T:
|
||||
"""
|
||||
Convert HLS EXT-X-KEY data to an initialized DRM object.
|
||||
@@ -1004,8 +995,8 @@ class HLS:
|
||||
|
||||
Raises a NotImplementedError if the key system is not supported.
|
||||
"""
|
||||
if not isinstance(session, (Session, CurlSession, type(None))):
|
||||
raise TypeError(f"Expected session to be a {Session} or {CurlSession}, not {type(session)}")
|
||||
if not isinstance(session, (Session, CurlSession, httpx.Client, type(None))):
|
||||
raise TypeError(f"Expected session to be a {Session} or {CurlSession} or {httpx.Client}, not {type(session)}")
|
||||
if not session:
|
||||
session = Session()
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from functools import partial
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Optional, Union
|
||||
|
||||
import httpx
|
||||
import requests
|
||||
from curl_cffi.requests import Session as CurlSession
|
||||
from langcodes import Language, tag_is_valid
|
||||
@@ -35,13 +36,13 @@ class ISM:
|
||||
self.url = url
|
||||
|
||||
@classmethod
|
||||
def from_url(cls, url: str, session: Optional[Union[Session, CurlSession]] = None, **kwargs: Any) -> "ISM":
|
||||
def from_url(cls, url: str, session: Optional[Union[Session, CurlSession, httpx.Client]] = None, **kwargs: Any) -> "ISM":
|
||||
if not url:
|
||||
raise requests.URLRequired("ISM manifest URL must be provided")
|
||||
if not session:
|
||||
session = Session()
|
||||
elif not isinstance(session, (Session, CurlSession)):
|
||||
raise TypeError(f"Expected session to be a {Session} or {CurlSession}, not {session!r}")
|
||||
elif not isinstance(session, (Session, CurlSession, httpx.Client)):
|
||||
raise TypeError(f"Expected session to be a {Session} or {CurlSession} or {httpx.Client}, not {session!r}")
|
||||
res = session.get(url, **kwargs)
|
||||
if res.url != url:
|
||||
url = res.url
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Optional, Union
|
||||
|
||||
import httpx
|
||||
import m3u8
|
||||
from curl_cffi.requests import Session as CurlSession
|
||||
from requests import Session
|
||||
@@ -16,7 +17,7 @@ def parse(
|
||||
master: m3u8.M3U8,
|
||||
language: str,
|
||||
*,
|
||||
session: Optional[Union[Session, CurlSession]] = None,
|
||||
session: Optional[Union[Session, CurlSession, httpx.Client]] = None,
|
||||
) -> Tracks:
|
||||
"""Parse a variant playlist to ``Tracks`` with basic information, defer DRM loading."""
|
||||
tracks = HLS(master, session=session).to_tracks(language)
|
||||
|
||||
@@ -65,9 +65,13 @@ class Attachment:
|
||||
path = None
|
||||
else:
|
||||
try:
|
||||
session = session or requests.Session()
|
||||
response = session.get(url, stream=True)
|
||||
response.raise_for_status()
|
||||
if session is None:
|
||||
with requests.Session() as session:
|
||||
response = session.get(url, stream=True)
|
||||
response.raise_for_status()
|
||||
else:
|
||||
response = session.get(url, stream=True)
|
||||
response.raise_for_status()
|
||||
config.directories.temp.mkdir(parents=True, exist_ok=True)
|
||||
download_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -80,7 +84,9 @@ class Attachment:
|
||||
raise ValueError(f"Failed to download attachment from URL: {e}")
|
||||
|
||||
if path is not None and not isinstance(path, (str, Path)):
|
||||
raise ValueError("The attachment path must be provided.")
|
||||
raise ValueError(
|
||||
f"Invalid attachment path type: expected str or Path, got {type(path).__name__}."
|
||||
)
|
||||
|
||||
if path is not None:
|
||||
path = Path(path)
|
||||
|
||||
@@ -13,6 +13,7 @@ from typing import Any, Callable, Iterable, Optional, Union
|
||||
from uuid import UUID
|
||||
from zlib import crc32
|
||||
|
||||
import httpx
|
||||
from curl_cffi.requests import Session as CurlSession
|
||||
from langcodes import Language
|
||||
from requests import Session
|
||||
@@ -613,8 +614,8 @@ class Track:
|
||||
raise TypeError(f"Expected url to be a {str}, not {type(url)}")
|
||||
if not isinstance(byte_range, (str, type(None))):
|
||||
raise TypeError(f"Expected byte_range to be a {str}, not {type(byte_range)}")
|
||||
if not isinstance(session, (Session, CurlSession, type(None))):
|
||||
raise TypeError(f"Expected session to be a {Session} or {CurlSession}, not {type(session)}")
|
||||
if not isinstance(session, (Session, CurlSession, httpx.Client, type(None))):
|
||||
raise TypeError(f"Expected session to be a {Session} or {CurlSession} or {httpx.Client}, not {type(session)}")
|
||||
|
||||
if not url:
|
||||
if self.descriptor != self.Descriptor.URL:
|
||||
|
||||
40
uv.lock
generated
40
uv.lock
generated
@@ -608,6 +608,28 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "4.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "hpack" },
|
||||
{ name = "hyperframe" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hpack"
|
||||
version = "4.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276, upload-time = "2025-01-22T21:44:58.347Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpcore"
|
||||
version = "1.0.9"
|
||||
@@ -636,6 +658,20 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
http2 = [
|
||||
{ name = "h2" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyperframe"
|
||||
version = "6.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "identify"
|
||||
version = "2.6.16"
|
||||
@@ -1642,7 +1678,7 @@ dependencies = [
|
||||
{ name = "curl-cffi" },
|
||||
{ name = "filelock" },
|
||||
{ name = "fonttools" },
|
||||
{ name = "httpx" },
|
||||
{ name = "httpx", extra = ["http2"] },
|
||||
{ name = "jsonpickle" },
|
||||
{ name = "langcodes" },
|
||||
{ name = "language-data" },
|
||||
@@ -1700,7 +1736,7 @@ requires-dist = [
|
||||
{ name = "curl-cffi", specifier = ">=0.7.0b4,<0.14" },
|
||||
{ name = "filelock", specifier = ">=3.20.3,<4" },
|
||||
{ name = "fonttools", specifier = ">=4.60.2,<5" },
|
||||
{ name = "httpx", specifier = ">=0.28.1,<0.29" },
|
||||
{ name = "httpx", extras = ["http2"], specifier = ">=0.28.1,<0.29" },
|
||||
{ name = "jsonpickle", specifier = ">=3.0.4,<5" },
|
||||
{ name = "langcodes", specifier = ">=3.4.0,<4" },
|
||||
{ name = "language-data", specifier = ">=1.4.0" },
|
||||
|
||||
Reference in New Issue
Block a user