From 607568e1751462a7d0ece65e97e3b3af36cd4695 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 18:54:51 +0000 Subject: [PATCH 1/3] fix(security): redact proxy credentials that do not match the env value redact_proxy_credentials bailed out unless the configured WEBSHARE_PROXY_URL appeared in the text byte-for-byte: if not url or url not in text: return text It is called from exception handlers that log subprocess and HTTP failures, so anything it skipped was written to logs verbatim. Reproduced leaks, all with WEBSHARE_PROXY_URL=http://user:s3cr3t@proxy.internal:8080: * host case-normalised by requests/urllib3 when re-rendering the URL connect to http://user:s3cr3t@PROXY.INTERNAL:8080 -> leaked * percent-encoded variant echoed back by yt-dlp http://user:s3cr3t%40x@proxy.internal:8080 -> leaked * a different proxy variable entirely, never equal to the configured one HTTPS_PROXY=http://bob:hunter2@corp.proxy:3128 -> leaked * a CalledProcessError repr of the argv ['yt-dlp','--proxy','http://u:p4ss@h:1'] -> leaked Adds a second, generic pass: a scheme://user[:password]@ sweep that redacts credentials regardless of which variable they came from or how they were rendered. The exact-match pass is kept and runs first, because it preserves the host so operators can still tell which proxy was in play. The userinfo classes exclude the authority delimiters (whitespace, "/", "?", "#") so a path or query containing "@" is never mistaken for credentials and a match cannot span two URLs, but they permit a literal "@". RFC 3986 requires "@" in userinfo to be percent-encoded while real proxy values carry a raw one; because the classes are greedy the engine settles on the LAST "@" in the authority, so http://user:pa@ss@host is consumed whole instead of the match stopping at the first separator and leaving "ss@host" behind (#1113). The helper now also accepts non-str input and never raises -- it is called from except blocks, where raising would mask the original error. A hostile __str__ yields ; a redaction failure yields . Both fail closed rather than returning text that cannot be vouched for. Applied to the canonical helper and to the drifted standalone copy in shared/libs/ that the importlib fallback path uses, whose three logger calls in the retry handler are the actual leak site. Adds tests/unit/test_proxy_utils.py -- the module had no test coverage at all. It parametrises over both implementations so neither can drift into leaking alone, and covers over-redaction (an "@" in a path, query, or fragment, and a bare host:port) since destroying diagnostics is its own failure. Verified non-vacuous: 22 of the 38 new tests fail against the implementation on main and all 38 pass after this change. Full unit suite 8035 passed, 0 failed. No new ruff findings. Re-cut from PR #1118, which was orphaned by the secret-purge force-push and shares no ancestry with main. Tracked in #1378. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YcHjCZ6pGn6A5BeeoZ6eZi --- shared/libs/youtube_proxy.py | 78 ++++++++++-- src/youtube_extension/utils/proxy.py | 101 ++++++++++++++-- tests/unit/test_proxy_utils.py | 175 +++++++++++++++++++++++++++ 3 files changed, 328 insertions(+), 26 deletions(-) create mode 100644 tests/unit/test_proxy_utils.py diff --git a/shared/libs/youtube_proxy.py b/shared/libs/youtube_proxy.py index 77c354095..1020a8434 100644 --- a/shared/libs/youtube_proxy.py +++ b/shared/libs/youtube_proxy.py @@ -12,6 +12,7 @@ import logging import os import random +import re import time import urllib.parse from dataclasses import dataclass @@ -72,20 +73,71 @@ def _get_transcript_proxy_config() -> GenericProxyConfig | None: return GenericProxyConfig(http_url=url, https_url=url) -def _redact_proxy_credentials(text: str) -> str: - """Strip user:pass credentials of the configured proxy URL from text.""" - url = os.getenv("WEBSHARE_PROXY_URL", "").strip() - if not url or url not in text: - return text +# Matches the ``user[:password]@`` userinfo segment of any URL. Kept byte-identical +# to the canonical copy in ``src/youtube_extension/utils/proxy.py``; see that module +# for the full rationale. In short: the classes exclude the authority delimiters +# (whitespace, "/", "?", "#") so paths and query strings containing "@" are never +# mistaken for credentials, but they permit a literal "@" so an unencoded one in +# the password is consumed whole rather than leaving the tail behind. +_USERINFO_RE = re.compile( + r"(?P[A-Za-z][A-Za-z0-9+.\-]*://)" + r"(?P[^\s/:?#]*)" + r"(?::(?P[^\s/?#]*))?" + r"@" +) + +_REDACTED = "***" +_UNPRINTABLE = "" +_REDACTION_FAILED = "" + + +def _redact_proxy_credentials(text: Any) -> str: + """Strip URL userinfo (``user:pass@``) from ``text``. + + Two passes: exact replacement of the configured ``WEBSHARE_PROXY_URL`` (which + preserves the host, so operators can still tell which proxy was in play), + then a generic ``scheme://user:pass@`` sweep that catches credentials never + matching the env value verbatim -- a normalised or percent-encoded form + echoed back by yt-dlp, a ``CalledProcessError`` repr of the argv, or a + different proxy variable such as ``HTTPS_PROXY``. + + Never raises: every caller here is an exception handler, where a failure + would mask the original error. + """ + if isinstance(text, str): + candidate = text + else: + try: + candidate = str(text) + except Exception: # noqa: BLE001 - a hostile __str__ must not propagate + return _UNPRINTABLE + try: - parsed = urllib.parse.urlparse(url) - netloc = parsed.hostname or "" - if parsed.port: - netloc = f"{netloc}:{parsed.port}" - redacted = parsed._replace(netloc=netloc).geturl() - except (ValueError, AttributeError): - redacted = "" - return text.replace(url, redacted) + return _redact(candidate) + except Exception: # noqa: BLE001 - never return text we cannot vouch for + return _REDACTION_FAILED + + +def _redact(text: str) -> str: + """Run the two redaction passes over an already-stringified ``text``.""" + url = os.getenv("WEBSHARE_PROXY_URL", "").strip() + if url and url in text: + try: + parsed = urllib.parse.urlparse(url) + netloc = parsed.hostname or "" + if parsed.port: + netloc = f"{netloc}:{parsed.port}" + redacted = parsed._replace(netloc=netloc).geturl() + except (ValueError, AttributeError): + redacted = "" + text = text.replace(url, redacted) + + def _mask(match: re.Match[str]) -> str: + if match.group("password") is None: + return f"{match.group('scheme')}{_REDACTED}@" + return f"{match.group('scheme')}{_REDACTED}:{_REDACTED}@" + + return _USERINFO_RE.sub(_mask, text) class YouTubeErrorType(Enum): """YouTube API specific error types""" diff --git a/src/youtube_extension/utils/proxy.py b/src/youtube_extension/utils/proxy.py index def0da69e..f82a9481f 100644 --- a/src/youtube_extension/utils/proxy.py +++ b/src/youtube_extension/utils/proxy.py @@ -13,6 +13,7 @@ import logging import os +import re import urllib.parse from typing import Any @@ -28,6 +29,38 @@ _PROXY_ENV_VAR = "WEBSHARE_PROXY_URL" +# Matches the ``user[:password]@`` userinfo segment of any URL. +# +# The classes exclude whitespace, "/", "?" and "#" -- the delimiters that end a +# URL authority -- so a path, query, or fragment containing "@" (e.g. +# "https://example.com/a@b" or "https://example.com?e=a@b") is never mistaken +# for credentials, and a match can never span from one URL into the next. +# +# They deliberately *permit* a literal "@". RFC 3986 requires "@" inside +# userinfo to be percent-encoded, but real *_PROXY values routinely carry a raw +# one. Because the classes are greedy, the engine backtracks to the LAST "@" +# inside the authority, so an unencoded "@" in the password +# ("http://user:pa@ss@host") is consumed whole instead of the match stopping at +# the first separator and leaving the password tail behind. +# +# The user may be empty so credentials with no username ("http://:pass@host") +# are still redacted. +_USERINFO_RE = re.compile( + r"(?P[A-Za-z][A-Za-z0-9+.\-]*://)" + r"(?P[^\s/:?#]*)" + r"(?::(?P[^\s/?#]*))?" + r"@" +) + +_REDACTED = "***" + +# Returned when the input cannot be stringified, or when redaction itself +# fails. Both are fail-closed: emitting a fixed placeholder is preferable to +# raising (which would mask the original error) or to returning text we cannot +# guarantee is clean (which could leak the credential we are trying to strip). +_UNPRINTABLE = "" +_REDACTION_FAILED = "" + def get_proxy_url() -> str | None: """Return the validated Webshare proxy URL, or None for direct connection.""" @@ -67,17 +100,59 @@ def get_transcript_proxy_config() -> Any | None: return GenericProxyConfig(http_url=url, https_url=url) -def redact_proxy_credentials(text: str) -> str: - """Strip user:pass credentials of the configured proxy URL from text.""" - url = os.getenv(_PROXY_ENV_VAR, "").strip() - if not url or url not in text: - return text +def redact_proxy_credentials(text: Any) -> str: + """Strip URL userinfo (``user:pass@``) from ``text``. + + Two passes, because either alone is insufficient: + + 1. Exact replacement of the configured ``WEBSHARE_PROXY_URL`` -- preserves + the host so operators can still tell *which* proxy was in play. + 2. A generic ``scheme://user:pass@`` sweep -- catches credentials that never + match the env value verbatim: yt-dlp echoing a normalised or + percent-encoded form back on stderr, a ``CalledProcessError`` repr of the + argv, or a different proxy variable (``HTTPS_PROXY`` and friends) + entirely. + + Pass 1 alone was the previous behaviour and leaked in all of those cases, + because it bailed out whenever the configured URL did not appear in the text + byte-for-byte. + + Always returns a string and never raises; it is called from exception + handlers, where a failure would mask the original error. If ``text`` cannot + be stringified (a ``__str__`` that itself raises) or redaction fails, a + fixed non-sensitive placeholder is returned instead. + """ + if isinstance(text, str): + candidate = text + else: + try: + candidate = str(text) + except Exception: # noqa: BLE001 - a hostile __str__ must not propagate + return _UNPRINTABLE + try: - parsed = urllib.parse.urlparse(url) - netloc = parsed.hostname or "" - if parsed.port: - netloc = f"{netloc}:{parsed.port}" - redacted = parsed._replace(netloc=netloc).geturl() - except (ValueError, AttributeError): - redacted = "" - return text.replace(url, redacted) + return _redact(candidate) + except Exception: # noqa: BLE001 - never return text we cannot vouch for + return _REDACTION_FAILED + + +def _redact(text: str) -> str: + """Run the two redaction passes over an already-stringified ``text``.""" + url = os.getenv(_PROXY_ENV_VAR, "").strip() + if url and url in text: + try: + parsed = urllib.parse.urlparse(url) + netloc = parsed.hostname or "" + if parsed.port: + netloc = f"{netloc}:{parsed.port}" + redacted = parsed._replace(netloc=netloc).geturl() + except (ValueError, AttributeError): + redacted = "" + text = text.replace(url, redacted) + + def _mask(match: re.Match[str]) -> str: + if match.group("password") is None: + return f"{match.group('scheme')}{_REDACTED}@" + return f"{match.group('scheme')}{_REDACTED}:{_REDACTED}@" + + return _USERINFO_RE.sub(_mask, text) diff --git a/tests/unit/test_proxy_utils.py b/tests/unit/test_proxy_utils.py new file mode 100644 index 000000000..8e24962ad --- /dev/null +++ b/tests/unit/test_proxy_utils.py @@ -0,0 +1,175 @@ +"""Regression tests for proxy credential redaction. + +``redact_proxy_credentials`` is called from exception handlers that log +subprocess and HTTP failures, so anything it fails to strip is written to logs +verbatim. Every ``LEAKED`` case below reproduces text that leaked before the +two-pass implementation landed. + +The helper exists in two copies -- the canonical +``src/youtube_extension/utils/proxy.py`` and the standalone +``shared/libs/youtube_proxy.py`` used by the importlib fallback path. Both are +exercised here, because a fix applied to only one of them leaves the other +leaking. +""" + +from __future__ import annotations + +import importlib.util +import pathlib +import sys + +import pytest + +from youtube_extension.utils.proxy import redact_proxy_credentials + +_PROXY_ENV_VAR = "WEBSHARE_PROXY_URL" +_CONFIGURED = "http://user:s3cr3t@proxy.internal:8080" + + +def _load_shared_copy(): + """Import ``shared/libs/youtube_proxy.py`` directly, without a package.""" + path = ( + pathlib.Path(__file__).resolve().parents[2] + / "shared" + / "libs" + / "youtube_proxy.py" + ) + name = "_shared_youtube_proxy" + if name in sys.modules: + return sys.modules[name] + spec = importlib.util.spec_from_file_location(name, path) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + # Register before exec: the module uses ``from __future__ import + # annotations``, so its dataclass field annotations are strings that + # ``dataclasses`` resolves by looking the module up in ``sys.modules``. + sys.modules[name] = module + try: + spec.loader.exec_module(module) + except Exception: + del sys.modules[name] + raise + return module + + +@pytest.fixture(params=["canonical", "shared"]) +def redact(request): + """Both implementations, so neither can drift into leaking alone.""" + if request.param == "canonical": + return redact_proxy_credentials + return _load_shared_copy()._redact_proxy_credentials + + +@pytest.fixture(autouse=True) +def _configured_proxy(monkeypatch): + monkeypatch.setenv(_PROXY_ENV_VAR, _CONFIGURED) + + +# Each case is (text, secret-that-must-not-survive). +LEAKED = [ + pytest.param( + f"transcript fetch failed via {_CONFIGURED}", + "s3cr3t", + id="exact-configured-url", + ), + pytest.param( + f"ProxyError: {_CONFIGURED}/", + "s3cr3t", + id="trailing-slash-from-urllib3", + ), + # requests/urllib3 lowercase the host when re-rendering a URL, so the text + # no longer matches the env value byte-for-byte. + pytest.param( + "connect to http://user:s3cr3t@PROXY.INTERNAL:8080 refused", + "s3cr3t", + id="host-case-normalised", + ), + pytest.param( + "http://user:s3cr3t%40x@proxy.internal:8080", + "s3cr3t", + id="percent-encoded-password", + ), + # A different proxy variable entirely -- never equal to WEBSHARE_PROXY_URL, + # so the exact-match pass alone never touched it. + pytest.param( + "HTTPS_PROXY=http://bob:hunter2@corp.proxy:3128 connection refused", + "hunter2", + id="different-proxy-variable", + ), + # RFC 3986 requires "@" in userinfo to be percent-encoded, but real proxy + # values carry a raw one. Matching to the FIRST "@" left the tail behind. + pytest.param( + "http://user:pa@ss@proxy.internal:8080", + "ss@proxy", + id="raw-at-sign-in-password", + ), + pytest.param( + "socks5://:tokenz@proxy:1080", + "tokenz", + id="password-with-no-username", + ), + pytest.param( + "CalledProcessError: ['yt-dlp', '--proxy', 'http://u:p4ss@h:1']", + "p4ss", + id="argv-repr-from-subprocess", + ), +] + + +@pytest.mark.parametrize(("text", "secret"), LEAKED) +def test_credentials_never_survive_redaction(redact, text, secret): + assert secret not in redact(text) + + +def test_configured_proxy_host_is_preserved(redact): + """Operators must still be able to tell *which* proxy was in play.""" + result = redact(f"failed via {_CONFIGURED}") + assert "proxy.internal:8080" in result + assert "s3cr3t" not in result + + +# Text that must survive untouched -- over-redaction destroys diagnostics. +PRESERVED = [ + pytest.param("https://example.com/a@b", id="at-sign-in-path"), + pytest.param("https://example.com?e=a@b", id="at-sign-in-query"), + pytest.param("https://example.com#f=a@b", id="at-sign-in-fragment"), + pytest.param("connect to proxy.internal:8080 failed", id="bare-host-port"), + pytest.param("mailto is not a url: a@b.com", id="bare-email"), +] + + +@pytest.mark.parametrize("text", PRESERVED) +def test_non_credential_text_is_untouched(redact, text): + assert redact(text) == text + + +def test_match_never_spans_two_urls(redact): + """A greedy class must not swallow the gap between separate URLs.""" + result = redact("http://a:1@h1:1 and http://b:2@h2:2") + assert result == "http://***:***@h1:1 and http://***:***@h2:2" + + +def test_redaction_is_a_noop_when_no_proxy_configured(redact, monkeypatch): + """The generic sweep still applies with no WEBSHARE_PROXY_URL set.""" + monkeypatch.delenv(_PROXY_ENV_VAR, raising=False) + assert "hunter2" not in redact("http://bob:hunter2@corp.proxy:3128") + assert redact("no credentials here") == "no credentials here" + + +def test_hostile_str_does_not_propagate(redact): + """Called from except blocks -- raising here would mask the real error.""" + + class Hostile: + def __str__(self) -> str: + raise RuntimeError("boom") + + assert redact(Hostile()) == "" + + +def test_non_string_input_is_stringified(redact): + assert redact(12345) == "12345" + + +def test_exception_object_is_accepted(redact): + """The common call shape is redact(str(error)) -- accept the error too.""" + assert "s3cr3t" not in redact(RuntimeError(f"boom via {_CONFIGURED}")) From de6f36cae4f184a4961c15807e5d9060431fb7ba Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 19:01:09 +0000 Subject: [PATCH 2/3] fix(deps): drop phantom python-jose to clear the unfixable ecdsa advisory python-jose was declared in pyproject.toml and requirements.txt but is never imported by this codebase. The only occurrence is inside a string template in backend/code_generator.py: auth_imports = ''' from jose import JWTError, jwt from passlib.context import CryptContext''' That text is written into projects the generator emits, and the generator writes those projects their own requirements.txt pinning python-jose (line 468). Parsing the file confirms it: zero jose imports at AST level anywhere in src/, shared/, or scripts/. Declaring it pulled in ecdsa, whose GHSA-wj6h-64fc-37mp has no patched release -- so the advisory could not be resolved by upgrading, only by removing the path to it. python-jose is ecdsa's sole dependent in the resolution, so dropping it removes the advisory outright. uv lock removes three packages: ecdsa, python-jose, and rsa. rsa goes because python-jose was its only dependent too -- google-auth in this resolution depends on cryptography and pyasn1-modules, not rsa. Verified zero residual references to all three in uv.lock and zero AST-level imports of any of them. Generated projects are unaffected: they install from the requirements.txt the generator writes for them, which still pins python-jose[cryptography]==3.3.0. Left in place: passlib is the same template-only case and could be dropped on the same reasoning, but it carries no advisory and removing it is not needed here. Noted in both manifests rather than changed unilaterally. Verified: backend imports and serves 11 routes; code_generator (which holds the template) imports; full unit suite 8035 passed, 0 failed. Re-cut from PR #1156, which was orphaned by the secret-purge force-push and shares no ancestry with main. Tracked in #1378. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YcHjCZ6pGn6A5BeeoZ6eZi --- pyproject.toml | 8 ++- requirements.txt | 4 +- uv.lock | 183 ++++++++++++++++++----------------------------- 3 files changed, 79 insertions(+), 116 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 442ee1670..2aa9ad3ad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,7 +48,13 @@ dependencies = [ "sqlalchemy>=2.0.0", "psycopg[binary]>=3.2,<4", "aiosqlite>=0.19.0", - "python-jose[cryptography]>=3.3.0", + # NOTE: python-jose was removed here. It was never imported by this + # codebase -- the only "from jose import ..." occurrence is inside a string + # template in backend/code_generator.py, which writes its own + # requirements.txt for the project it generates. Declaring it dragged in + # ecdsa, whose GHSA-wj6h-64fc-37mp has no patched release. passlib below is + # the same template-only case but carries no advisory; it can be dropped + # too if generated-project deps are confirmed self-contained. "passlib[bcrypt]>=1.7.4", "python-decouple>=3.8", "structlog>=23.2.0", diff --git a/requirements.txt b/requirements.txt index ddb75dacc..1e7d37739 100644 --- a/requirements.txt +++ b/requirements.txt @@ -29,7 +29,9 @@ aiosqlite>=0.19.0 alembic>=1.12.0 # Security & Auth -python-jose[cryptography]>=3.3.0 +# python-jose removed: never imported here (only inside a code_generator.py +# string template, which emits its own requirements.txt). It pulled in ecdsa, +# whose GHSA-wj6h-64fc-37mp has no patched release. passlib[bcrypt]>=1.7.4 # Configuration diff --git a/uv.lock b/uv.lock index 40d16cc8c..281aeacff 100644 --- a/uv.lock +++ b/uv.lock @@ -1237,7 +1237,7 @@ name = "cuda-bindings" version = "13.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cuda-pathfinder" }, + { name = "cuda-pathfinder", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (python_full_version < '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32')" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/a9/21/8464d133752951c154feafb3b65c297e7d80f301183d220bec4c830f1441/cuda_bindings-13.3.1-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:120fcc53d57903df529c3486962c56528cba5b7d6c57c99537320ed9922c8b86", size = 6073403, upload-time = "2026-05-29T23:11:36.22Z" }, @@ -1272,43 +1272,43 @@ wheels = [ [package.optional-dependencies] cublas = [ - { name = "nvidia-cublas", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, - { name = "nvidia-cuda-nvrtc", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cublas", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-cuda-nvrtc", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] cudart = [ - { name = "nvidia-cuda-runtime", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cuda-runtime", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] cufft = [ - { name = "nvidia-cufft", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, - { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cufft", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-nvjitlink", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] cufile = [ - { name = "nvidia-cufile", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cufile", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] cupti = [ - { name = "nvidia-cuda-cupti", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cuda-cupti", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] curand = [ - { name = "nvidia-curand", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-curand", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] cusolver = [ - { name = "nvidia-cublas", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, - { name = "nvidia-cusolver", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, - { name = "nvidia-cusparse", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, - { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cublas", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-cusolver", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-cusparse", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-nvjitlink", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] cusparse = [ - { name = "nvidia-cusparse", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, - { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cusparse", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-nvjitlink", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] nvjitlink = [ - { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-nvjitlink", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] nvrtc = [ - { name = "nvidia-cuda-nvrtc", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cuda-nvrtc", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] nvtx = [ - { name = "nvidia-nvtx", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-nvtx", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] [[package]] @@ -1520,18 +1520,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b0/0d/9feae160378a3553fa9a339b0e9c1a048e147a4127210e286ef18b730f03/durationpy-0.10-py3-none-any.whl", hash = "sha256:3b41e1b601234296b4fb368338fdcd3e13e0b4fb5b67345948f4f2bf9868b286", size = 3922, upload-time = "2025-05-17T13:52:36.463Z" }, ] -[[package]] -name = "ecdsa" -version = "0.19.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/25/ca/8de7744cb3bc966c85430ca2d0fcaeea872507c6a4cf6e007f7fe269ed9d/ecdsa-0.19.2.tar.gz", hash = "sha256:62635b0ac1ca2e027f82122b5b81cb706edc38cd91c63dda28e4f3455a2bf930", size = 202432, upload-time = "2026-03-26T09:58:17.675Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/51/79/119091c98e2bf49e24ed9f3ae69f816d715d2904aefa6a2baa039a2ba0b0/ecdsa-0.19.2-py2.py3-none-any.whl", hash = "sha256:840f5dc5e375c68f36c1a7a5b9caad28f95daa65185c9253c0c08dd952bb7399", size = 150818, upload-time = "2026-03-26T09:58:15.808Z" }, -] - [[package]] name = "envier" version = "0.6.1" @@ -1546,7 +1534,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -2881,17 +2869,17 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "decorator" }, - { name = "exceptiongroup" }, - { name = "jedi" }, - { name = "matplotlib-inline" }, - { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "prompt-toolkit" }, - { name = "pygments" }, - { name = "stack-data" }, - { name = "traitlets" }, - { name = "typing-extensions" }, + { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, + { name = "decorator", marker = "python_full_version < '3.11'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "jedi", marker = "python_full_version < '3.11'" }, + { name = "matplotlib-inline", marker = "python_full_version < '3.11'" }, + { name = "pexpect", marker = "python_full_version < '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit", marker = "python_full_version < '3.11'" }, + { name = "pygments", marker = "python_full_version < '3.11'" }, + { name = "stack-data", marker = "python_full_version < '3.11'" }, + { name = "traitlets", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/40/18/f8598d287006885e7136451fdea0755af4ebcbfe342836f24deefaed1164/ipython-8.39.0.tar.gz", hash = "sha256:4110ae96012c379b8b6db898a07e186c40a2a1ef5d57a7fa83166047d9da7624", size = 5513971, upload-time = "2026-03-27T10:02:13.94Z" } wheels = [ @@ -2920,17 +2908,17 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "ipython-pygments-lexers" }, - { name = "jedi" }, - { name = "matplotlib-inline" }, - { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "prompt-toolkit" }, - { name = "psutil", marker = "sys_platform != 'cygwin' and sys_platform != 'emscripten'" }, - { name = "pygments" }, - { name = "stack-data" }, - { name = "traitlets" }, - { name = "typing-extensions", marker = "python_full_version < '3.12'" }, + { name = "colorama", marker = "python_full_version >= '3.11' and sys_platform == 'win32'" }, + { name = "ipython-pygments-lexers", marker = "python_full_version >= '3.11'" }, + { name = "jedi", marker = "python_full_version >= '3.11'" }, + { name = "matplotlib-inline", marker = "python_full_version >= '3.11'" }, + { name = "pexpect", marker = "python_full_version >= '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit", marker = "python_full_version >= '3.11'" }, + { name = "psutil", marker = "python_full_version >= '3.11' and sys_platform != 'cygwin' and sys_platform != 'emscripten'" }, + { name = "pygments", marker = "python_full_version >= '3.11'" }, + { name = "stack-data", marker = "python_full_version >= '3.11'" }, + { name = "traitlets", marker = "python_full_version >= '3.11'" }, + { name = "typing-extensions", marker = "python_full_version == '3.11.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/52/49/04360f83b4d110195751b4171b75dc1cd7b97ba122b18da34b5828172d59/ipython-9.16.0.tar.gz", hash = "sha256:d2f92587b1ef51d84f934dffe05fabb9255f0038ed0a21426f2ea761e39ad09a", size = 4515375, upload-time = "2026-07-31T08:02:51.977Z" } wheels = [ @@ -2942,7 +2930,7 @@ name = "ipython-pygments-lexers" version = "1.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pygments" }, + { name = "pygments", marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } wheels = [ @@ -4532,7 +4520,7 @@ name = "nvidia-cublas" version = "13.1.1.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cuda-nvrtc" }, + { name = "nvidia-cuda-nvrtc", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/a7/a1/0bd24ee8c8d03adac032fd2909426a00c88f8c57961b1277ded97f91119f/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5", size = 542848918, upload-time = "2026-04-08T18:46:22.985Z" }, @@ -4571,7 +4559,7 @@ name = "nvidia-cudnn-cu13" version = "9.20.0.48" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas" }, + { name = "nvidia-cublas", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/56/c5/83384d846b2fd17c44bd499b36c75a45ed4f095fbbb2252294e89cea5c5c/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:e31454ae00094b0c55319d9d15b6fa2fc50a9e1c0f5c8c80fb75258234e731e1", size = 444574296, upload-time = "2026-03-09T19:28:27.751Z" }, @@ -4583,7 +4571,7 @@ name = "nvidia-cufft" version = "12.0.0.61" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink" }, + { name = "nvidia-nvjitlink", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" }, @@ -4613,9 +4601,9 @@ name = "nvidia-cusolver" version = "12.0.4.66" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas" }, - { name = "nvidia-cusparse" }, - { name = "nvidia-nvjitlink" }, + { name = "nvidia-cublas", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, + { name = "nvidia-cusparse", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, + { name = "nvidia-nvjitlink", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" }, @@ -4627,7 +4615,7 @@ name = "nvidia-cusparse" version = "12.6.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink" }, + { name = "nvidia-nvjitlink", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" }, @@ -4985,10 +4973,10 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, - { name = "python-dateutil" }, - { name = "pytz" }, - { name = "tzdata" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "python-dateutil", marker = "python_full_version < '3.11'" }, + { name = "pytz", marker = "python_full_version < '3.11'" }, + { name = "tzdata", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } wheels = [ @@ -5063,10 +5051,10 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "python-dateutil" }, - { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, + { name = "python-dateutil", marker = "python_full_version >= '3.11'" }, + { name = "tzdata", marker = "(python_full_version >= '3.11' and sys_platform == 'emscripten') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/be/4f/5f3422a2afec5ffc46308b79e53291365a93748b498ac2e58bead0197916/pandas-3.0.5.tar.gz", hash = "sha256:dca3734d6ab7c906e6730f0788b0a1dbb9f2467731f9711f77995c8e9d62d712", size = 4658219, upload-time = "2026-07-22T22:19:28.819Z" } wheels = [ @@ -5171,7 +5159,7 @@ name = "pexpect" version = "4.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ptyprocess" }, + { name = "ptyprocess", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } wheels = [ @@ -6018,25 +6006,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/17/3d/26e14cf47c56c9ba3c3e12cae21f24716bc3182bb52260213ec0c819d0b9/python_engineio-4.13.4-py3-none-any.whl", hash = "sha256:272de73124e255d3d2bba6f86358c1a1ba618f938f337a0c868b60550fe38719", size = 60129, upload-time = "2026-07-31T10:30:54.637Z" }, ] -[[package]] -name = "python-jose" -version = "3.5.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "ecdsa" }, - { name = "pyasn1" }, - { name = "rsa" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c6/77/3a1c9039db7124eb039772b935f2244fbb73fc8ee65b9acf2375da1c07bf/python_jose-3.5.0.tar.gz", hash = "sha256:fb4eaa44dbeb1c26dcc69e4bd7ec54a1cb8dd64d3b4d81ef08d90ff453f2b01b", size = 92726, upload-time = "2025-05-28T17:31:54.288Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d9/c3/0bd11992072e6a1c513b16500a5d07f91a24017c5909b02c72c62d7ad024/python_jose-3.5.0-py2.py3-none-any.whl", hash = "sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771", size = 34624, upload-time = "2025-05-28T17:31:52.802Z" }, -] - -[package.optional-dependencies] -cryptography = [ - { name = "cryptography" }, -] - [[package]] name = "python-json-logger" version = "4.1.0" @@ -6822,18 +6791,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/85/48f0abdcef5cce4e034c7a5b0ceeceba0b01bf0d942824f4bb720afe2dec/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826", size = 586486, upload-time = "2026-06-30T07:17:51.141Z" }, ] -[[package]] -name = "rsa" -version = "4.9.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyasn1" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, -] - [[package]] name = "ruff" version = "0.16.1" @@ -6891,10 +6848,10 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "joblib" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, - { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" } }, - { name = "threadpoolctl" }, + { name = "joblib", marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "threadpoolctl", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/98/c2/a7855e41c9d285dfe86dc50b250978105dce513d6e459ea66a6aeb0e1e0c/scikit_learn-1.7.2.tar.gz", hash = "sha256:20e9e49ecd130598f1ca38a1d85090e1a600147b9c02fa6f15d69cb53d968fda", size = 7193136, upload-time = "2025-09-09T08:21:29.075Z" } wheels = [ @@ -6952,13 +6909,13 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "joblib" }, - { name = "narwhals" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "joblib", marker = "python_full_version >= '3.11'" }, + { name = "narwhals", marker = "python_full_version >= '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "threadpoolctl" }, + { name = "threadpoolctl", marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fa/6f/37092bdb25f712817231799fc5674d8e704066a8a70c1d2d40517e18b4ab/scikit_learn-1.9.0.tar.gz", hash = "sha256:8833266989d3a5110178a9fae30783675460724d0e1efb13b14901d2c660c557", size = 7750767, upload-time = "2026-06-02T11:54:32.706Z" } wheels = [ @@ -7002,7 +6959,7 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0f/37/6964b830433e654ec7485e45a00fc9a27cf868d622838f6b6d9c5ec0d532/scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf", size = 59419214, upload-time = "2025-05-08T16:13:05.955Z" } wheels = [ @@ -7063,7 +7020,7 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } wheels = [ @@ -7148,7 +7105,7 @@ resolution-markers = [ "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447, upload-time = "2026-06-19T15:01:43.456Z" } wheels = [ @@ -8644,7 +8601,6 @@ dependencies = [ { name = "pydantic-settings" }, { name = "python-decouple" }, { name = "python-dotenv" }, - { name = "python-jose", extra = ["cryptography"] }, { name = "python-multipart" }, { name = "pyyaml" }, { name = "qrcode", extra = ["pil"] }, @@ -8808,7 +8764,6 @@ requires-dist = [ { name = "pytest-xdist", marker = "extra == 'dev'", specifier = ">=3.5.0" }, { name = "python-decouple", specifier = ">=3.8" }, { name = "python-dotenv", specifier = ">=1.2.2" }, - { name = "python-jose", extras = ["cryptography"], specifier = ">=3.3.0" }, { name = "python-multipart", specifier = ">=0.0.31" }, { name = "pyyaml", specifier = ">=6.0.0" }, { name = "qrcode", extras = ["pil"], specifier = ">=7.0" }, From 5cefd7d8e464f1c33f46effc5de5c0b8828cd32f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 19:14:36 +0000 Subject: [PATCH 3/3] fix(deps): generate PyJWT auth instead of python-jose CI caught what my local run missed: three tests in test_code_generator.py failed with ModuleNotFoundError: No module named 'jose'. TestGeneratedFastAPIBehaviour does not inspect the generated source, it *executes* the FastAPI app that code_generator emits -- deliberate regression cover for #1257, where the template shipped placeholder endpoints returning 200 so a generated project passed a naive smoke test while being non-functional. The emitted app imports jose, so removing the dependency broke those tests. I checked src/, shared/ and scripts/ for jose imports and found none, but never checked tests/. My local suite passed only because the venv still had python-jose installed from an earlier editable install; CI installs fresh. Moving python-jose to the dev extra would have fixed CI while leaving ecdsa in uv.lock, so the advisory would have survived -- and every generated project would still inherit it. Instead the template now emits PyJWT: -from jose import JWTError, jwt +import jwt +from jwt import PyJWTError encode/decode signatures are identical; only the exception type changes. PyJWT is maintained and depends on nothing with an open advisory, whereas python-jose's ecdsa (GHSA-wj6h-64fc-37mp) has no patched release. Generated projects get pyjwt>=2.10.1 in their requirements.txt instead of python-jose[cryptography]==3.3.0. This changes generator output -- called out explicitly on the PR so it can be objected to -- but a generated project should not ship a known-vulnerable transitive dependency. pyjwt is added to the dev extra, not the runtime dependencies: EventRelay never imports it, only the generated app the tests execute does. Verified in a venv with jose uninstalled, matching CI: 96 code_generator tests pass, full unit suite 8035 passed, 0 failed. ecdsa, python-jose and rsa all absent from uv.lock; pyjwt present. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YcHjCZ6pGn6A5BeeoZ6eZi --- pyproject.toml | 4 ++++ src/youtube_extension/backend/code_generator.py | 16 +++++++++++----- tests/unit/test_code_generator.py | 6 +++++- uv.lock | 14 ++++++++++++++ 4 files changed, 34 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 2aa9ad3ad..5324b86d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -97,6 +97,10 @@ dev = [ "ipykernel>=6.25.0", "notebook>=7.0.0", "locust==2.46.0", + # Test-time only. TestGeneratedFastAPIBehaviour executes the FastAPI app + # that code_generator emits, and that app imports jwt. EventRelay itself + # never imports it, so it does not belong in the runtime dependencies. + "pyjwt>=2.10.1", ] docs = [ "mkdocs>=1.5.0", diff --git a/src/youtube_extension/backend/code_generator.py b/src/youtube_extension/backend/code_generator.py index 614fe8490..5de39057c 100644 --- a/src/youtube_extension/backend/code_generator.py +++ b/src/youtube_extension/backend/code_generator.py @@ -463,9 +463,14 @@ async def _generate_python_api(self, project_path: Path, video_analysis: dict, f if "database" in features: requirements.append("sqlalchemy==2.0.23") if "authentication" in features: - # The generated main.py imports passlib.context; omitting it here - # produced a project that fails at import time. - requirements.append("python-jose[cryptography]==3.3.0") + # The generated main.py imports jwt and passlib.context; omitting + # either here produced a project that fails at import time. + # + # PyJWT rather than python-jose: python-jose depends on ecdsa, + # whose GHSA-wj6h-64fc-37mp has no patched release, so every + # generated project inherited an unfixable advisory. PyJWT is + # maintained and its encode/decode signatures are the same. + requirements.append("pyjwt>=2.10.1") requirements.append("passlib[bcrypt]==1.7.4") # Generate main.py @@ -1115,7 +1120,8 @@ def _generate_fastapi_main(self, title: str, features: list[str]) -> str: if "authentication" in features: scaffolding_endpoints.append("POST /auth/login") auth_imports = ''' -from jose import JWTError, jwt +import jwt +from jwt import PyJWTError from passlib.context import CryptContext''' auth_code = ''' # ─── Authentication ───────────────────────────────────────────────────────── @@ -1162,7 +1168,7 @@ def decode_access_token(token: str) -> dict: key = _require_secret_key() try: return jwt.decode(token, key, algorithms=[ALGORITHM]) - except JWTError as exc: + except PyJWTError as exc: raise HTTPException(status_code=401, detail="Invalid or expired token") from exc diff --git a/tests/unit/test_code_generator.py b/tests/unit/test_code_generator.py index aaf4456ab..fc9c09c11 100644 --- a/tests/unit/test_code_generator.py +++ b/tests/unit/test_code_generator.py @@ -1382,4 +1382,8 @@ def test_auth_projects_declare_their_password_hashing_dependency( requirements = (project_dir / "requirements.txt").read_text() assert "passlib" in requirements - assert "python-jose" in requirements + # PyJWT, not python-jose: the latter depends on ecdsa, whose + # GHSA-wj6h-64fc-37mp has no patched release, so every generated + # project inherited an unfixable advisory. + assert "pyjwt" in requirements + assert "python-jose" not in requirements diff --git a/uv.lock b/uv.lock index 281aeacff..b98ee61f0 100644 --- a/uv.lock +++ b/uv.lock @@ -5847,6 +5847,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, +] + [[package]] name = "pymdown-extensions" version = "11.0.1" @@ -8638,6 +8650,7 @@ dev = [ { name = "mypy" }, { name = "notebook" }, { name = "pre-commit" }, + { name = "pyjwt" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, @@ -8756,6 +8769,7 @@ requires-dist = [ { name = "psycopg", extras = ["binary"], marker = "extra == 'postgres'", specifier = ">=3.2,<4" }, { name = "pydantic", specifier = ">=2.5.0" }, { name = "pydantic-settings", specifier = ">=2.1.0" }, + { name = "pyjwt", marker = "extra == 'dev'", specifier = ">=2.10.1" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.4.0" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.21.0" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1.0" },