diff --git a/main.py b/main.py index 7911ad6..22ffbd2 100644 --- a/main.py +++ b/main.py @@ -1,15 +1,18 @@ import argparse import base64 +import hashlib +import hmac +import json import os import re import socket import subprocess import sys import time +import urllib.error +import urllib.request import docker -from rsconnect.api import RSConnectClient, RSConnectServer -from rsconnect.json_web_token import TokenGenerator IMAGE = "ghcr.io/posit-dev/connect" @@ -484,6 +487,62 @@ def wait_for_http_server( return False +def make_bootstrap_jwt(secret: bytes, now: float | None = None) -> str: + """ + Create an HS256-signed JWT for Connect's bootstrap endpoint. + + Connect requires exactly these issuer, audience, and scope values (they + are hard-coded to match what rsconnect-python sends), plus iat and exp. + """ + if now is None: + now = time.time() + header = {"alg": "HS256", "typ": "JWT"} + claims = { + "iss": "rsconnect-python", + "aud": "rsconnect", + "scope": "bootstrap", + "iat": int(now), + "exp": int(now) + 15 * 60, + } + + def b64url(data: bytes) -> str: + return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") + + def encode_json(obj: dict) -> str: + return b64url(json.dumps(obj, separators=(",", ":")).encode("utf-8")) + + signing_input = f"{encode_json(header)}.{encode_json(claims)}" + signature = hmac.new(secret, signing_input.encode("ascii"), hashlib.sha256).digest() + return f"{signing_input}.{b64url(signature)}" + + +def post_bootstrap(server_url: str, token: str) -> dict: + """ + POST to Connect's bootstrap endpoint and return the decoded JSON response. + + Connect 2024.05.0 added v1/bootstrap; earlier versions only have + v1/experimental/bootstrap, so fall back to that on a 404. + """ + for path in ("v1/bootstrap", "v1/experimental/bootstrap"): + request = urllib.request.Request( + f"{server_url.rstrip('/')}/__api__/{path}", + method="POST", + headers={ + "Authorization": f"Connect-Bootstrap {token}", + "Accept": "application/json", + }, + ) + try: + with urllib.request.urlopen(request, timeout=30) as response: + return json.loads(response.read()) + except urllib.error.HTTPError as e: + if e.code == 404 and path == "v1/bootstrap": + continue + body = e.read().decode("utf-8", errors="replace") + raise RuntimeError(f"HTTP {e.code} from {path}: {body}") + raise AssertionError("unreachable") + + def get_api_key(bootstrap_secret: str, container, server_url: str) -> str: """ Bootstrap Connect and retrieve an API key. @@ -493,31 +552,14 @@ def get_api_key(bootstrap_secret: str, container, server_url: str) -> str: commands run against the Connect instance. Requires Connect 2022.10.0+. """ try: - # Generate bootstrap token from secret # The bootstrap_secret we received is base64-encoded, need to decode it secret_bytes = base64.b64decode(bootstrap_secret.encode("utf-8")) - token_gen = TokenGenerator(secret_bytes) - bootstrap_token = token_gen.bootstrap() - - # Create server connection with bootstrap JWT - server = RSConnectServer(server_url, None, bootstrap_jwt=bootstrap_token) - client = RSConnectClient(server) - - # Call bootstrap endpoint - response = client.bootstrap() - - # Extract API key from response - if response and "api_key" in response: - api_key = response["api_key"] - if not api_key: - print("\nContainer logs:", file=sys.stderr) - print(container.logs().decode("utf-8", errors="replace"), file=sys.stderr) - raise RuntimeError("Bootstrap succeeded but returned empty API key") - return api_key - else: - print("\nContainer logs:", file=sys.stderr) - print(container.logs().decode("utf-8", errors="replace"), file=sys.stderr) + response = post_bootstrap(server_url, make_bootstrap_jwt(secret_bytes)) + + api_key = response.get("api_key") if isinstance(response, dict) else None + if not api_key: raise RuntimeError(f"Bootstrap returned unexpected response: {response}") + return api_key except Exception as e: print("\nContainer logs:", file=sys.stderr) print(container.logs().decode("utf-8", errors="replace"), file=sys.stderr) diff --git a/pyproject.toml b/pyproject.toml index 80905a2..1d48458 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,6 @@ readme = "README.md" requires-python = ">=3.13" dependencies = [ "docker>=7.1.0", - "rsconnect-python", ] [project.scripts] @@ -15,7 +14,3 @@ with-connect = "main:main" [tool.setuptools] py-modules = ["main"] -[tool.uv.sources] -# Unreleased: use v1 bootstrap endpoint with fallback to experimental -# (posit-dev/rsconnect-python#879). Revert to a PyPI release once one includes it. -rsconnect-python = { git = "https://github.com/posit-dev/rsconnect-python", rev = "8c578e7d4b2a851ce0817cf282d4b1636a968146" } diff --git a/test_main.py b/test_main.py index fa0071d..41525dc 100644 --- a/test_main.py +++ b/test_main.py @@ -1,5 +1,10 @@ +import base64 +import hashlib +import hmac import io +import json import os +import urllib.error import subprocess import sys import tempfile @@ -341,6 +346,73 @@ def test_stop_nonexistent_container(): assert "Container not found" in result.stderr +def _decode_segment(segment): + return json.loads(base64.urlsafe_b64decode(segment + "=" * (-len(segment) % 4))) + + +def test_make_bootstrap_jwt(): + secret = os.urandom(32) + token = main.make_bootstrap_jwt(secret, now=1000) + header, claims, signature = token.split(".") + assert _decode_segment(header) == {"alg": "HS256", "typ": "JWT"} + # Connect rejects the token unless iss, aud, and scope match exactly + assert _decode_segment(claims) == { + "iss": "rsconnect-python", + "aud": "rsconnect", + "scope": "bootstrap", + "iat": 1000, + "exp": 1900, + } + expected = hmac.new(secret, f"{header}.{claims}".encode(), hashlib.sha256).digest() + assert signature == base64.urlsafe_b64encode(expected).rstrip(b"=").decode() + assert "=" not in token + + +def _http_error(url, code, body=b""): + return urllib.error.HTTPError(url, code, "error", {}, io.BytesIO(body)) + + +def test_post_bootstrap_uses_v1(): + response = MagicMock() + response.__enter__.return_value.read.return_value = b'{"api_key": "abc"}' + with patch("urllib.request.urlopen", return_value=response) as urlopen: + assert main.post_bootstrap("http://localhost:3939", "tok") == {"api_key": "abc"} + request = urlopen.call_args.args[0] + assert request.full_url == "http://localhost:3939/__api__/v1/bootstrap" + assert request.get_method() == "POST" + assert request.get_header("Authorization") == "Connect-Bootstrap tok" + + +def test_post_bootstrap_falls_back_to_experimental(): + response = MagicMock() + response.__enter__.return_value.read.return_value = b'{"api_key": "abc"}' + with patch( + "urllib.request.urlopen", + side_effect=[_http_error("u", 404), response], + ) as urlopen: + assert main.post_bootstrap("http://localhost:3939", "tok") == {"api_key": "abc"} + urls = [call.args[0].full_url for call in urlopen.call_args_list] + assert urls == [ + "http://localhost:3939/__api__/v1/bootstrap", + "http://localhost:3939/__api__/v1/experimental/bootstrap", + ] + + +def test_post_bootstrap_raises_on_other_errors(): + with patch( + "urllib.request.urlopen", + side_effect=_http_error("u", 401, b'{"error": "invalid jwt"}'), + ) as urlopen: + try: + main.post_bootstrap("http://localhost:3939", "tok") + assert False, "Expected RuntimeError" + except RuntimeError as e: + assert "401" in str(e) + assert "invalid jwt" in str(e) + # A non-404 error should not trigger the experimental fallback + assert urlopen.call_count == 1 + + if __name__ == "__main__": test_license_file_not_exists() print("✓ test_license_file_not_exists passed") @@ -438,4 +510,16 @@ def test_stop_nonexistent_container(): test_stop_nonexistent_container() print("✓ test_stop_nonexistent_container passed") + test_make_bootstrap_jwt() + print("✓ test_make_bootstrap_jwt passed") + + test_post_bootstrap_uses_v1() + print("✓ test_post_bootstrap_uses_v1 passed") + + test_post_bootstrap_falls_back_to_experimental() + print("✓ test_post_bootstrap_falls_back_to_experimental passed") + + test_post_bootstrap_raises_on_other_errors() + print("✓ test_post_bootstrap_raises_on_other_errors passed") + print("\nAll tests passed!") diff --git a/uv.lock b/uv.lock index 22f6fcb..35cd408 100644 --- a/uv.lock +++ b/uv.lock @@ -42,27 +42,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175, upload-time = "2025-08-09T07:57:26.864Z" }, ] -[[package]] -name = "click" -version = "8.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943, upload-time = "2025-09-18T17:32:23.696Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295, upload-time = "2025-09-18T17:32:22.42Z" }, -] - -[[package]] -name = "colorama" -version = "0.4.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, -] - [[package]] name = "docker" version = "7.1.0" @@ -86,33 +65,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, ] -[[package]] -name = "packaging" -version = "26.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, -] - -[[package]] -name = "pip" -version = "25.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/20/16/650289cd3f43d5a2fadfd98c68bd1e1e7f2550a1a5326768cddfbcedb2c5/pip-25.2.tar.gz", hash = "sha256:578283f006390f85bb6282dffb876454593d637f5d1be494b5202ce4877e71f2", size = 1840021, upload-time = "2025-07-30T21:50:15.401Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/3f/945ef7ab14dc4f9d7f40288d2df998d1837ee0888ec3659c813487572faa/pip-25.2-py3-none-any.whl", hash = "sha256:6d67a2b4e7f14d8b31b8b52648866fa717f45a1eb70e83002f4331d07e953717", size = 1752557, upload-time = "2025-07-30T21:50:13.323Z" }, -] - -[[package]] -name = "pyjwt" -version = "2.15.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/ea/5194e52748b0da83d71e082d75496eaec6e58f419f5e184786ded517e6a9/pyjwt-2.15.1.tar.gz", hash = "sha256:4f259e80cdfb6b3fc18a7de51fd1ef9ec79652f25019bae68975ca2468a34df8", size = 121252, upload-time = "2026-09-28T18:40:42.598Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/50/ca/44de4e75f8aadc457f0634be3b542815078ded46dca30efb960edeecad6e/pyjwt-2.15.1-py3-none-any.whl", hash = "sha256:42d59d631f7768a1028a64c7ff581a9bf7519804daf91fc5b6c56e30eec5e193", size = 33860, upload-time = "2026-09-28T18:40:41.429Z" }, -] - [[package]] name = "pywin32" version = "311" @@ -141,38 +93,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, ] -[[package]] -name = "rsconnect-python" -version = "1.32.1.dev1" -source = { git = "https://github.com/posit-dev/rsconnect-python?rev=8c578e7d4b2a851ce0817cf282d4b1636a968146#8c578e7d4b2a851ce0817cf282d4b1636a968146" } -dependencies = [ - { name = "click" }, - { name = "packaging" }, - { name = "pip" }, - { name = "pyjwt" }, - { name = "semver" }, - { name = "typing-extensions" }, - { name = "uv" }, -] - -[[package]] -name = "semver" -version = "3.0.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/d1/d3159231aec234a59dd7d601e9dd9fe96f3afff15efd33c1070019b26132/semver-3.0.4.tar.gz", hash = "sha256:afc7d8c584a5ed0a11033af086e8af226a9c0b206f313e0301f8dd7b6b589602", size = 269730, upload-time = "2025-01-24T13:19:27.617Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a6/24/4d91e05817e92e3a61c8a21e08fd0f390f5301f1c448b137c57c4bc6e543/semver-3.0.4-py3-none-any.whl", hash = "sha256:9c824d87ba7f7ab4a1890799cec8596f15c1241cb473404ea1cb0c55e4b04746", size = 17912, upload-time = "2025-01-24T13:19:24.949Z" }, -] - -[[package]] -name = "typing-extensions" -version = "4.15.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, -] - [[package]] name = "urllib3" version = "2.5.0" @@ -182,43 +102,13 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, ] -[[package]] -name = "uv" -version = "0.11.15" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/da/34/609d5d01ba21dc8f0974610ca7802fbb2c946a0c38665cfe5c5aeddbefb5/uv-0.11.15.tar.gz", hash = "sha256:755f959ec6a2fd8ccb6ee76ad90ab759d2eb1f4797444078645dd1ee4bca92d6", size = 4159545, upload-time = "2026-05-18T19:57:48.133Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a6/7c/dcc230c5911884d8848145dabcac8fb95a5ed6f9fe1c57fae8242618f28a/uv-0.11.15-py3-none-linux_armv6l.whl", hash = "sha256:83b04ab49514a0a761ffedb36a748ee81f87746671e72088e5f32c9585e5f1a9", size = 23110183, upload-time = "2026-05-18T19:57:23.051Z" }, - { url = "https://files.pythonhosted.org/packages/f4/f3/efd4e044b60eb9c3c12ee386be098d56c335538ccec7caa49349cfba9344/uv-0.11.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b6cae61f737be075b90be9e3f07d961072aed7019f4c9b8ed5c5d41c4d6cade3", size = 22637941, upload-time = "2026-05-18T19:57:26.752Z" }, - { url = "https://files.pythonhosted.org/packages/a6/b8/48627f895a1569e576822e0a8416aa4797eb4a4551de21a4ad97b9b5819d/uv-0.11.15-py3-none-macosx_11_0_arm64.whl", hash = "sha256:9accae33619a9166e5c48531deb455d672cfb89f9357a00975e669c76b0bd49f", size = 21258803, upload-time = "2026-05-18T19:57:05.473Z" }, - { url = "https://files.pythonhosted.org/packages/af/50/4bc8a148274feabee2d9c9f1fa15009e10c0228dfe57981ee3ea2ef1d481/uv-0.11.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:c0cf52cd6d50bb9e05e2d968f45f80761107e4cbc8d4a26d9758f9d8274aaec1", size = 23066178, upload-time = "2026-05-18T19:57:33.058Z" }, - { url = "https://files.pythonhosted.org/packages/a9/56/139fc3bec9a8b0a25bfe2196123adb9f16124da437bf4fbcf0d21cfcafb2/uv-0.11.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:49dc6ed70bff00937384f96cdc4b1a4742d18e5504ec2c4a1214dba2dee5687a", size = 22705332, upload-time = "2026-05-18T19:57:36.714Z" }, - { url = "https://files.pythonhosted.org/packages/ca/b0/b18b3dd204f8c213236a1ebd148e009861637129a8cce34df0e9aa22ed40/uv-0.11.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:adb9a89352539fdd8f7cd5f9966cf9f94fc5b98e0ccdf5003a04123dc6423bec", size = 22707534, upload-time = "2026-05-18T19:58:04.117Z" }, - { url = "https://files.pythonhosted.org/packages/76/36/3ca09f95572df99d361b49c96b1297149e96e120d8d1ecf074095a4b6da4/uv-0.11.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40ff67e3f8e8a7533781a2e892a534975a93acb83ea35460e64e7b2bf2111774", size = 24096607, upload-time = "2026-05-18T19:58:11.625Z" }, - { url = "https://files.pythonhosted.org/packages/64/be/3bdee21a296bbf5336a526e3613d0e7d4538dacc39c62d7fcba55d15f6b0/uv-0.11.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c6463a299ed7e6b5a800ed6f108af8e1588352629424133ddef7572b0e1e1118", size = 25082562, upload-time = "2026-05-18T19:57:40.69Z" }, - { url = "https://files.pythonhosted.org/packages/cd/73/f371f3689ffe741066468d001d85f739fc4b5574de83b639ef19b5e8a7f4/uv-0.11.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:68c1e62d4b78578b90b833553286b65d6a7e327537716441068583ba652ec4f5", size = 24253391, upload-time = "2026-05-18T19:57:18.47Z" }, - { url = "https://files.pythonhosted.org/packages/d3/16/fe392d618af6b00c064b3e718d585dcf791546a77c5123a5bec07ce53a0a/uv-0.11.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98edf1bdaf82447014852051d93e3ee95012509c567bf057fd117e6bdbd9a807", size = 24415871, upload-time = "2026-05-18T19:58:19.651Z" }, - { url = "https://files.pythonhosted.org/packages/6e/24/2e92a052fb6334fcd746d1c7cb57847c204b118c84f5da53c0f9e129f7b7/uv-0.11.15-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:be8f76d25bcf4c92bb384240ac1bf9aa7f51063d0bdeca4c9cf0ec3ed8b145e0", size = 23159007, upload-time = "2026-05-18T19:57:10.653Z" }, - { url = "https://files.pythonhosted.org/packages/3d/2e/6923d0658d164bb2c435ed1868aa2d49b3074594679917a001ff92dc95bb/uv-0.11.15-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:f9f4fbbf4fe485522054f3c7496c6e8e932d6436e4200ff3daf718db0b7c7bd5", size = 23769385, upload-time = "2026-05-18T19:58:15.856Z" }, - { url = "https://files.pythonhosted.org/packages/a4/99/7e34cd949e57360814e8064cc9fb7104df445d0f6a663504e5f7473480aa/uv-0.11.15-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:0ed920e896b2fd13a35031707e307e42fbb2681458b967440a17272d86d49137", size = 23860973, upload-time = "2026-05-18T19:57:55.575Z" }, - { url = "https://files.pythonhosted.org/packages/28/98/8fe1f5f9d816e94569a0298dd8e0936801097625fa1952162951f0d628b6/uv-0.11.15-py3-none-musllinux_1_1_i686.whl", hash = "sha256:41d907611f3e6a13262807fd7f0a17849f76285ca80f536f6b3943732bdc6656", size = 23431392, upload-time = "2026-05-18T19:57:59.814Z" }, - { url = "https://files.pythonhosted.org/packages/cc/6b/76a1ce2fa860026913a5941700cdc7d715fce9c3277a3fa3489cf2523ca0/uv-0.11.15-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:e3b68f8bf1a4568710f77e5bda9182ce7682811d89a8e7468c22460e032b234d", size = 24519478, upload-time = "2026-05-18T19:57:51.165Z" }, - { url = "https://files.pythonhosted.org/packages/43/60/1d58e8a05718cb50494763115710b73846cacb651fd735d285233fd72c59/uv-0.11.15-py3-none-win32.whl", hash = "sha256:8e2da3076761086a5b76869c3f38ef0509c836046ef41ddd19485dfd7271dca9", size = 22020178, upload-time = "2026-05-18T19:58:07.64Z" }, - { url = "https://files.pythonhosted.org/packages/55/53/40fcefcb348af660488597ed3c01363df7344e60611f8883750dc596f5c6/uv-0.11.15-py3-none-win_amd64.whl", hash = "sha256:cc3915ab291a1ecaf31de05f5d8bd70d09c66fe9911a53f70d9efa62ff0dbd8a", size = 24668779, upload-time = "2026-05-18T19:57:44.894Z" }, - { url = "https://files.pythonhosted.org/packages/e5/7d/fa3a9960c95af9bbe2a629048760d0b9b4fead8ccd4f2235af747ec7cdf0/uv-0.11.15-py3-none-win_arm64.whl", hash = "sha256:4f39426a13dee24897aed60c4b98058c66f18bd983885ac5f4a54a04b24fbddf", size = 23198178, upload-time = "2026-05-18T19:57:14.68Z" }, -] - [[package]] name = "with-connect" version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "docker" }, - { name = "rsconnect-python" }, ] [package.metadata] -requires-dist = [ - { name = "docker", specifier = ">=7.1.0" }, - { name = "rsconnect-python", git = "https://github.com/posit-dev/rsconnect-python?rev=8c578e7d4b2a851ce0817cf282d4b1636a968146" }, -] +requires-dist = [{ name = "docker", specifier = ">=7.1.0" }]