privacyfence-sync 6.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- privacyfence_sync/__init__.py +8 -0
- privacyfence_sync/auth.py +189 -0
- privacyfence_sync/cli.py +136 -0
- privacyfence_sync/client.py +125 -0
- privacyfence_sync/recipe.py +343 -0
- privacyfence_sync/refs.py +84 -0
- privacyfence_sync/runner.py +227 -0
- privacyfence_sync/template.py +24 -0
- privacyfence_sync/transform.py +54 -0
- privacyfence_sync-6.0.0.dist-info/METADATA +797 -0
- privacyfence_sync-6.0.0.dist-info/RECORD +14 -0
- privacyfence_sync-6.0.0.dist-info/WHEEL +5 -0
- privacyfence_sync-6.0.0.dist-info/entry_points.txt +2 -0
- privacyfence_sync-6.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""OAuth sign-in for organization servers: token storage, the loopback callback and the provider."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import tempfile
|
|
10
|
+
import time
|
|
11
|
+
import webbrowser
|
|
12
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
from urllib.parse import parse_qs, urlparse
|
|
16
|
+
|
|
17
|
+
from mcp.client.auth import OAuthClientProvider
|
|
18
|
+
from mcp.shared.auth import AuthorizationCodeResult, OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
|
|
19
|
+
|
|
20
|
+
from .client import SignInError, SyncError
|
|
21
|
+
|
|
22
|
+
# The redirect URI must match the registered one exactly (PrivacyFence does no port-agnostic
|
|
23
|
+
# loopback matching), so the port is fixed and stored with the client registration.
|
|
24
|
+
DEFAULT_CALLBACK_PORT = 33418
|
|
25
|
+
CALLBACK_TIMEOUT_SECONDS = 300.0
|
|
26
|
+
_SIGNED_IN_PAGE = b"Signed in to PrivacyFence. You can close this tab."
|
|
27
|
+
_FAILED_PAGE = b"Sign-in failed. Return to the terminal."
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def login_hint(server_url: str) -> str:
|
|
31
|
+
return f"Not signed in to {server_url}, or the sign-in expired. Run: privacyfence-sync login --server {server_url}"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def storage_path(server_url: str) -> Path:
|
|
35
|
+
base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
|
|
36
|
+
digest = hashlib.sha256(server_url.encode("utf-8")).hexdigest()[:16]
|
|
37
|
+
return Path(base) / "privacyfence-sync" / f"{digest}.json"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class FileTokenStorage:
|
|
41
|
+
"""The SDK's TokenStorage over one JSON file, written atomically with mode 0o600."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, path: Path) -> None:
|
|
44
|
+
self._path = path
|
|
45
|
+
|
|
46
|
+
def _read(self) -> dict[str, Any]:
|
|
47
|
+
try:
|
|
48
|
+
data = json.loads(self._path.read_text(encoding="utf-8"))
|
|
49
|
+
except FileNotFoundError:
|
|
50
|
+
return {}
|
|
51
|
+
return data if isinstance(data, dict) else {}
|
|
52
|
+
|
|
53
|
+
def _write(self, data: dict[str, Any]) -> None:
|
|
54
|
+
self._path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
55
|
+
fd, tmp = tempfile.mkstemp(dir=self._path.parent, prefix=".tmp-", suffix=".json")
|
|
56
|
+
try:
|
|
57
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
58
|
+
handle.write(json.dumps(data))
|
|
59
|
+
os.chmod(tmp, 0o600)
|
|
60
|
+
os.replace(tmp, self._path)
|
|
61
|
+
except BaseException:
|
|
62
|
+
Path(tmp).unlink(missing_ok=True)
|
|
63
|
+
raise
|
|
64
|
+
|
|
65
|
+
def has_tokens(self) -> bool:
|
|
66
|
+
return bool(self._read().get("tokens"))
|
|
67
|
+
|
|
68
|
+
async def get_tokens(self) -> OAuthToken | None:
|
|
69
|
+
raw = self._read().get("tokens")
|
|
70
|
+
return OAuthToken.model_validate(raw) if raw else None
|
|
71
|
+
|
|
72
|
+
async def set_tokens(self, tokens: OAuthToken) -> None:
|
|
73
|
+
data = self._read()
|
|
74
|
+
data["tokens"] = tokens.model_dump(mode="json", exclude_none=True)
|
|
75
|
+
self._write(data)
|
|
76
|
+
|
|
77
|
+
async def get_client_info(self) -> OAuthClientInformationFull | None:
|
|
78
|
+
raw = self._read().get("client_info")
|
|
79
|
+
return OAuthClientInformationFull.model_validate(raw) if raw else None
|
|
80
|
+
|
|
81
|
+
async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
|
|
82
|
+
data = self._read()
|
|
83
|
+
data["client_info"] = client_info.model_dump(mode="json", exclude_none=True)
|
|
84
|
+
self._write(data)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class _CallbackServer(HTTPServer):
|
|
88
|
+
result: AuthorizationCodeResult | None = None
|
|
89
|
+
error: str | None = None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class _CallbackHandler(BaseHTTPRequestHandler):
|
|
93
|
+
server: _CallbackServer
|
|
94
|
+
|
|
95
|
+
def do_GET(self) -> None: # noqa: N802 (the http.server hook name)
|
|
96
|
+
url = urlparse(self.path)
|
|
97
|
+
if url.path != "/callback":
|
|
98
|
+
self._reply(404, b"Not found")
|
|
99
|
+
return
|
|
100
|
+
query = {key: values[0] for key, values in parse_qs(url.query).items()}
|
|
101
|
+
if "code" not in query:
|
|
102
|
+
self.server.error = query.get("error_description") or query.get("error") or "no code in the callback"
|
|
103
|
+
self._reply(400, _FAILED_PAGE)
|
|
104
|
+
return
|
|
105
|
+
self.server.result = AuthorizationCodeResult(code=query["code"], state=query.get("state"), iss=query.get("iss"))
|
|
106
|
+
self._reply(200, _SIGNED_IN_PAGE)
|
|
107
|
+
|
|
108
|
+
def _reply(self, status: int, body: bytes) -> None:
|
|
109
|
+
self.send_response(status)
|
|
110
|
+
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
|
111
|
+
self.send_header("Content-Length", str(len(body)))
|
|
112
|
+
self.end_headers()
|
|
113
|
+
self.wfile.write(body)
|
|
114
|
+
|
|
115
|
+
def log_message(self, format: str, *args: Any) -> None: # noqa: A002
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _bind_callback_server(port: int) -> _CallbackServer:
|
|
120
|
+
try:
|
|
121
|
+
return _CallbackServer(("127.0.0.1", port), _CallbackHandler)
|
|
122
|
+
except OSError as exc:
|
|
123
|
+
raise SyncError(f"cannot listen on 127.0.0.1:{port} for the sign-in ({exc}); pass --callback-port") from exc
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _wait_for_callback(server: _CallbackServer, timeout: float) -> AuthorizationCodeResult:
|
|
127
|
+
deadline = time.monotonic() + timeout
|
|
128
|
+
try:
|
|
129
|
+
while server.result is None:
|
|
130
|
+
remaining = deadline - time.monotonic()
|
|
131
|
+
if remaining <= 0:
|
|
132
|
+
raise SyncError("No sign-in arrived within 5 minutes.")
|
|
133
|
+
server.timeout = remaining
|
|
134
|
+
server.handle_request()
|
|
135
|
+
if server.error is not None:
|
|
136
|
+
raise SyncError(f"Sign-in failed: {server.error}")
|
|
137
|
+
return server.result
|
|
138
|
+
finally:
|
|
139
|
+
server.server_close()
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class _InteractiveSignIn:
|
|
143
|
+
def __init__(self, callback_port: int, timeout: float = CALLBACK_TIMEOUT_SECONDS) -> None:
|
|
144
|
+
self._port = callback_port
|
|
145
|
+
self._timeout = timeout
|
|
146
|
+
self._server: _CallbackServer | None = None
|
|
147
|
+
|
|
148
|
+
async def redirect_handler(self, url: str) -> None:
|
|
149
|
+
# Listen before the browser opens, so a fast redirect cannot beat the server.
|
|
150
|
+
self._server = _bind_callback_server(self._port)
|
|
151
|
+
print(f"Open this page to sign in: {url}", flush=True)
|
|
152
|
+
webbrowser.open(url)
|
|
153
|
+
|
|
154
|
+
async def callback_handler(self) -> AuthorizationCodeResult:
|
|
155
|
+
server, self._server = self._server, None
|
|
156
|
+
if server is None:
|
|
157
|
+
raise SyncError("the sign-in callback was requested before the sign-in page")
|
|
158
|
+
return await asyncio.to_thread(_wait_for_callback, server, self._timeout)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def oauth_provider(
|
|
162
|
+
server_url: str, storage: FileTokenStorage, *, interactive: bool, callback_port: int
|
|
163
|
+
) -> OAuthClientProvider:
|
|
164
|
+
metadata = OAuthClientMetadata(
|
|
165
|
+
client_name="privacyfence-sync",
|
|
166
|
+
redirect_uris=[f"http://127.0.0.1:{callback_port}/callback"], # type: ignore[list-item]
|
|
167
|
+
grant_types=["authorization_code", "refresh_token"],
|
|
168
|
+
response_types=["code"],
|
|
169
|
+
token_endpoint_auth_method="none", # nosec B106 # a public loopback client holds no secret
|
|
170
|
+
)
|
|
171
|
+
if interactive:
|
|
172
|
+
flow = _InteractiveSignIn(callback_port)
|
|
173
|
+
return OAuthClientProvider(
|
|
174
|
+
server_url,
|
|
175
|
+
metadata,
|
|
176
|
+
storage,
|
|
177
|
+
redirect_handler=flow.redirect_handler,
|
|
178
|
+
callback_handler=flow.callback_handler,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
async def refuse_redirect(url: str) -> None:
|
|
182
|
+
raise SignInError(login_hint(server_url))
|
|
183
|
+
|
|
184
|
+
async def refuse_callback() -> AuthorizationCodeResult:
|
|
185
|
+
raise SignInError(login_hint(server_url))
|
|
186
|
+
|
|
187
|
+
return OAuthClientProvider(
|
|
188
|
+
server_url, metadata, storage, redirect_handler=refuse_redirect, callback_handler=refuse_callback
|
|
189
|
+
)
|
privacyfence_sync/cli.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Command line entry point: ``privacyfence-sync run|login|check``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import httpx2
|
|
12
|
+
from mcp.shared.exceptions import MCPError
|
|
13
|
+
|
|
14
|
+
from .auth import DEFAULT_CALLBACK_PORT, FileTokenStorage, login_hint, storage_path
|
|
15
|
+
from .client import (
|
|
16
|
+
RecipeRuntimeError,
|
|
17
|
+
SignInError,
|
|
18
|
+
SyncClient,
|
|
19
|
+
SyncError,
|
|
20
|
+
ToolDenied,
|
|
21
|
+
connect_oauth,
|
|
22
|
+
connect_token,
|
|
23
|
+
)
|
|
24
|
+
from .recipe import RecipeError, load_recipe
|
|
25
|
+
from .runner import run_recipe
|
|
26
|
+
from .template import TemplateError
|
|
27
|
+
from .transform import ScriptError
|
|
28
|
+
|
|
29
|
+
DEFAULT_SERVER = "http://127.0.0.1:8765/mcp"
|
|
30
|
+
DEFAULT_TOKEN_ENV = "PRIVACYFENCE_TOKEN" # nosec B105 # the name of an environment variable, not a secret
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _parser() -> argparse.ArgumentParser:
|
|
34
|
+
parser = argparse.ArgumentParser(prog="privacyfence-sync", description=__doc__, allow_abbrev=False)
|
|
35
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
36
|
+
run = sub.add_parser("run", help="run a recipe", allow_abbrev=False)
|
|
37
|
+
run.add_argument("recipe", type=Path)
|
|
38
|
+
run.add_argument("--server", help="the PrivacyFence MCP URL")
|
|
39
|
+
run.add_argument(
|
|
40
|
+
"--attended", action="store_true", help="wait for approvals instead of declaring the run unattended"
|
|
41
|
+
)
|
|
42
|
+
run.add_argument("--token-env", default=DEFAULT_TOKEN_ENV, help="environment variable holding the token")
|
|
43
|
+
login = sub.add_parser("login", help="sign in to an organization server", allow_abbrev=False)
|
|
44
|
+
login.add_argument("--server", required=True, help="the PrivacyFence MCP URL")
|
|
45
|
+
login.add_argument(
|
|
46
|
+
"--callback-port", type=int, default=DEFAULT_CALLBACK_PORT, help="loopback port for the browser's return"
|
|
47
|
+
)
|
|
48
|
+
check = sub.add_parser("check", help="validate a recipe without any network call")
|
|
49
|
+
check.add_argument("recipe", type=Path)
|
|
50
|
+
return parser
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _fail(message: str, code: int) -> int:
|
|
54
|
+
print(f"privacyfence-sync: {message}", file=sys.stderr)
|
|
55
|
+
return code
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _resolve_server(flag: str | None, recipe_server: str | None) -> str:
|
|
59
|
+
return flag or recipe_server or os.environ.get("PRIVACYFENCE_MCP_URL") or DEFAULT_SERVER
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _connect(server: str, token_env: str) -> SyncClient:
|
|
63
|
+
token = os.environ.get(token_env)
|
|
64
|
+
if token:
|
|
65
|
+
return connect_token(server, token)
|
|
66
|
+
storage = FileTokenStorage(storage_path(server))
|
|
67
|
+
if not storage.has_tokens():
|
|
68
|
+
raise SignInError(login_hint(server))
|
|
69
|
+
return connect_oauth(server, storage)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
async def _run(recipe_path: Path, server_flag: str | None, attended: bool, token_env: str) -> None:
|
|
73
|
+
recipe = load_recipe(recipe_path)
|
|
74
|
+
client = _connect(_resolve_server(server_flag, recipe.server), token_env)
|
|
75
|
+
try:
|
|
76
|
+
async with client:
|
|
77
|
+
await run_recipe(recipe, client, attended=attended, out=sys.stdout)
|
|
78
|
+
except BaseExceptionGroup as group:
|
|
79
|
+
# The MCP transport runs in an anyio task group, which wraps the step's own error.
|
|
80
|
+
raise _first_leaf(group) from None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
async def _login(server: str, callback_port: int) -> None:
|
|
84
|
+
client = connect_oauth(
|
|
85
|
+
server, FileTokenStorage(storage_path(server)), interactive=True, callback_port=callback_port
|
|
86
|
+
)
|
|
87
|
+
try:
|
|
88
|
+
async with client:
|
|
89
|
+
await client.call("privacyfence_status", {"reason": "privacyfence-sync login check"})
|
|
90
|
+
except BaseExceptionGroup as group:
|
|
91
|
+
raise _first_leaf(group) from None
|
|
92
|
+
print(f"Signed in to {server}.")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _first_leaf(group: BaseExceptionGroup[BaseException]) -> BaseException:
|
|
96
|
+
first = group.exceptions[0]
|
|
97
|
+
return _first_leaf(first) if isinstance(first, BaseExceptionGroup) else first
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def main(argv: list[str] | None = None) -> int:
|
|
101
|
+
try:
|
|
102
|
+
args = _parser().parse_args(argv)
|
|
103
|
+
except SystemExit as exc:
|
|
104
|
+
return 1 if exc.code else 0
|
|
105
|
+
try:
|
|
106
|
+
if args.command == "check":
|
|
107
|
+
recipe = load_recipe(args.recipe)
|
|
108
|
+
print(f"{recipe.name}: {len(recipe.steps)} step(s), OK")
|
|
109
|
+
return 0
|
|
110
|
+
if args.command == "login":
|
|
111
|
+
asyncio.run(_login(args.server, args.callback_port))
|
|
112
|
+
return 0
|
|
113
|
+
asyncio.run(_run(args.recipe, args.server, args.attended, args.token_env))
|
|
114
|
+
return 0
|
|
115
|
+
except (RecipeError, TemplateError, RecipeRuntimeError) as exc:
|
|
116
|
+
return _fail(str(exc), 1)
|
|
117
|
+
except ToolDenied as exc:
|
|
118
|
+
return _fail(str(exc), 3)
|
|
119
|
+
except SignInError as exc:
|
|
120
|
+
return _fail(str(exc), 4)
|
|
121
|
+
except (SyncError, ScriptError) as exc:
|
|
122
|
+
return _fail(str(exc), 2)
|
|
123
|
+
except httpx2.HTTPError as exc:
|
|
124
|
+
return _fail(f"cannot reach the server: {exc}", 2)
|
|
125
|
+
except MCPError as exc:
|
|
126
|
+
# The MCP client turns an HTTP error on the handshake (a bad or revoked token, a wrong URL,
|
|
127
|
+
# a server error) into this generic error, so name the usual causes.
|
|
128
|
+
return _fail(
|
|
129
|
+
f"the server refused the connection ({exc.message}); check that the token in "
|
|
130
|
+
"$PRIVACYFENCE_TOKEN (or --token-env) is valid and that the server URL is right",
|
|
131
|
+
2,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
if __name__ == "__main__":
|
|
136
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""MCP session, tool calls and file transfer: the only code that talks to the daemon."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from contextlib import AsyncExitStack
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import TYPE_CHECKING, Any
|
|
10
|
+
|
|
11
|
+
import httpx2
|
|
12
|
+
from mcp import ClientSession
|
|
13
|
+
from mcp.client.streamable_http import streamable_http_client
|
|
14
|
+
from mcp.types import TextContent
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from .auth import FileTokenStorage
|
|
18
|
+
|
|
19
|
+
_TRANSFER_TIMEOUT = 120.0
|
|
20
|
+
_DENIED_PREFIX = "Request denied"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SyncError(Exception):
|
|
24
|
+
"""Base of every error privacyfence-sync raises on purpose."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ToolFailed(SyncError):
|
|
28
|
+
"""A tool call or file transfer failed."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ToolDenied(SyncError):
|
|
32
|
+
"""The daemon denied the request (policy or the user said no)."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SignInError(SyncError):
|
|
36
|
+
"""Not signed in, sign-in expired, or no token."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class RecipeRuntimeError(SyncError):
|
|
40
|
+
"""A reference in a recipe failed at run time."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class SyncClient:
|
|
44
|
+
def __init__(self, server_url: str, auth: httpx2.Auth | None, headers: dict[str, str]) -> None:
|
|
45
|
+
self._server_url = server_url
|
|
46
|
+
self._auth = auth
|
|
47
|
+
self._headers = headers
|
|
48
|
+
self._stack: AsyncExitStack | None = None
|
|
49
|
+
self._session: ClientSession | None = None
|
|
50
|
+
|
|
51
|
+
async def __aenter__(self) -> SyncClient:
|
|
52
|
+
stack = AsyncExitStack()
|
|
53
|
+
try:
|
|
54
|
+
http = await stack.enter_async_context(httpx2.AsyncClient(auth=self._auth, headers=self._headers))
|
|
55
|
+
read, write = await stack.enter_async_context(streamable_http_client(self._server_url, http_client=http))
|
|
56
|
+
session = await stack.enter_async_context(ClientSession(read, write))
|
|
57
|
+
await session.initialize()
|
|
58
|
+
except BaseException:
|
|
59
|
+
await stack.aclose()
|
|
60
|
+
raise
|
|
61
|
+
self._stack = stack
|
|
62
|
+
self._session = session
|
|
63
|
+
return self
|
|
64
|
+
|
|
65
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
66
|
+
stack, self._stack, self._session = self._stack, None, None
|
|
67
|
+
if stack is not None:
|
|
68
|
+
await stack.aclose()
|
|
69
|
+
|
|
70
|
+
def _require_session(self) -> ClientSession:
|
|
71
|
+
if self._session is None:
|
|
72
|
+
raise RuntimeError("SyncClient used outside `async with`")
|
|
73
|
+
return self._session
|
|
74
|
+
|
|
75
|
+
async def call(self, tool: str, args: dict[str, Any]) -> Any:
|
|
76
|
+
result = await self._require_session().call_tool(tool, args)
|
|
77
|
+
text = "\n".join(c.text for c in result.content if isinstance(c, TextContent))
|
|
78
|
+
if result.is_error:
|
|
79
|
+
if text.startswith(_DENIED_PREFIX):
|
|
80
|
+
raise ToolDenied(text)
|
|
81
|
+
raise ToolFailed(text)
|
|
82
|
+
if result.structured_content is not None:
|
|
83
|
+
return result.structured_content
|
|
84
|
+
try:
|
|
85
|
+
return json.loads(text)
|
|
86
|
+
except ValueError:
|
|
87
|
+
return text
|
|
88
|
+
|
|
89
|
+
async def download(self, url: str, dest: Path, expected_sha256: str | None) -> None:
|
|
90
|
+
# The URL is the credential (ADR 0028), so this client carries no Authorization header.
|
|
91
|
+
async with httpx2.AsyncClient(timeout=_TRANSFER_TIMEOUT) as http:
|
|
92
|
+
response = await http.get(url)
|
|
93
|
+
response.raise_for_status()
|
|
94
|
+
data = response.content
|
|
95
|
+
if expected_sha256 is not None and hashlib.sha256(data).hexdigest() != expected_sha256:
|
|
96
|
+
raise ToolFailed("downloaded file does not match the sha256 the tool reported")
|
|
97
|
+
dest.write_bytes(data)
|
|
98
|
+
|
|
99
|
+
async def upload(self, path: Path, reason: str) -> str:
|
|
100
|
+
slot = await self.call(
|
|
101
|
+
"privacyfence_create_upload_slot",
|
|
102
|
+
{"filename": path.name, "size_bytes": path.stat().st_size, "reason": reason},
|
|
103
|
+
)
|
|
104
|
+
async with httpx2.AsyncClient(timeout=_TRANSFER_TIMEOUT) as http:
|
|
105
|
+
response = await http.put(slot["upload_url"], content=path.read_bytes())
|
|
106
|
+
response.raise_for_status()
|
|
107
|
+
return str(slot["upload_id"])
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def connect_token(server_url: str, token: str) -> SyncClient:
|
|
111
|
+
return SyncClient(server_url, None, {"Authorization": f"Bearer {token}"})
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def connect_oauth(
|
|
115
|
+
server_url: str, storage: FileTokenStorage, *, interactive: bool = False, callback_port: int | None = None
|
|
116
|
+
) -> SyncClient:
|
|
117
|
+
from .auth import DEFAULT_CALLBACK_PORT, oauth_provider # auth.py imports this module's errors
|
|
118
|
+
|
|
119
|
+
provider = oauth_provider(
|
|
120
|
+
server_url,
|
|
121
|
+
storage,
|
|
122
|
+
interactive=interactive,
|
|
123
|
+
callback_port=DEFAULT_CALLBACK_PORT if callback_port is None else callback_port,
|
|
124
|
+
)
|
|
125
|
+
return SyncClient(server_url, provider, {})
|