mainsequence 9.0.20.dev62__tar.gz → 9.0.21.dev64__tar.gz

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.
Files changed (106) hide show
  1. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/PKG-INFO +1 -1
  2. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/agent_scaffold/skills/a2a_sdk_execution/SKILL.md +20 -0
  3. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/mainsequence/cli/api.py +56 -22
  4. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/mainsequence/cli/cli.py +20 -2
  5. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/mainsequence/client/_transport.py +75 -1
  6. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/mainsequence/client/agent_runtime_models.py +224 -91
  7. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/mainsequence.egg-info/PKG-INFO +1 -1
  8. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/mainsequence.egg-info/SOURCES.txt +1 -0
  9. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/pyproject.toml +1 -1
  10. mainsequence-9.0.21.dev64/tests/test_cli_request_budget.py +232 -0
  11. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/tests/test_code_repository_skills.py +53 -2
  12. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/tests/test_http_retry_budget.py +66 -0
  13. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/LICENSE +0 -0
  14. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/README.md +0 -0
  15. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/agent_scaffold/AGENTS.md +0 -0
  16. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/agent_scaffold/skills/application_surfaces/api_surfaces/SKILL.md +0 -0
  17. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/agent_scaffold/skills/mainsequence-inference/SKILL.md +0 -0
  18. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/agent_scaffold/skills/maintenance/bug_auditor/SKILL.md +0 -0
  19. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/agent_scaffold/skills/maintenance/code_repository_maintenance/SKILL.md +0 -0
  20. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/agent_scaffold/skills/maintenance/metatables_transition/SKILL.md +0 -0
  21. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/agent_scaffold/skills/platform_operations/access_control_and_sharing/SKILL.md +0 -0
  22. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/agent_scaffold/skills/platform_operations/orchestration_and_releases/SKILL.md +0 -0
  23. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/agent_scaffold/skills/sdk_code_repository_execution/SKILL.md +0 -0
  24. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/mainsequence/__init__.py +0 -0
  25. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/mainsequence/__main__.py +0 -0
  26. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/mainsequence/_request_identity.py +0 -0
  27. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/mainsequence/bootstrap.py +0 -0
  28. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/mainsequence/cli/__init__.py +0 -0
  29. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/mainsequence/cli/browser_auth.py +0 -0
  30. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/mainsequence/cli/code_repository_status.py +0 -0
  31. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/mainsequence/cli/config.py +0 -0
  32. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/mainsequence/cli/docker_utils.py +0 -0
  33. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/mainsequence/cli/doctor.py +0 -0
  34. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/mainsequence/cli/local_ops.py +0 -0
  35. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/mainsequence/cli/model_filters.py +0 -0
  36. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/mainsequence/cli/pydantic_cli.py +0 -0
  37. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/mainsequence/cli/sdk_utils.py +0 -0
  38. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/mainsequence/cli/ssh_utils.py +0 -0
  39. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/mainsequence/cli/ui.py +0 -0
  40. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/mainsequence/client/__init__.py +0 -0
  41. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/mainsequence/client/base.py +0 -0
  42. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/mainsequence/client/client.py +0 -0
  43. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/mainsequence/client/compute_validation.py +0 -0
  44. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/mainsequence/client/exceptions.py +0 -0
  45. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/mainsequence/client/github_issues.py +0 -0
  46. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/mainsequence/client/inference.py +0 -0
  47. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/mainsequence/client/models_foundry.py +0 -0
  48. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/mainsequence/client/models_helpers.py +0 -0
  49. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/mainsequence/client/models_user.py +0 -0
  50. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/mainsequence/client/observability.py +0 -0
  51. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/mainsequence/client/utils.py +0 -0
  52. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/mainsequence/client/value_sets.py +0 -0
  53. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/mainsequence/code_repository_context.py +0 -0
  54. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/mainsequence/code_repository_skills.py +0 -0
  55. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/mainsequence/defaults.py +0 -0
  56. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/mainsequence/instrumentation/__init__.py +0 -0
  57. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/mainsequence/instrumentation/utils.py +0 -0
  58. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/mainsequence/logconf.py +0 -0
  59. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/mainsequence/repository_identity_security.py +0 -0
  60. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/mainsequence/runtime_credential_exchange.py +0 -0
  61. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/mainsequence/runtime_flags.py +0 -0
  62. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/mainsequence/scaffold_skills.py +0 -0
  63. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/mainsequence/server/__init__.py +0 -0
  64. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/mainsequence/server/caller_assertions.py +0 -0
  65. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/mainsequence/server/fastapi.py +0 -0
  66. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/mainsequence.egg-info/dependency_links.txt +0 -0
  67. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/mainsequence.egg-info/entry_points.txt +0 -0
  68. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/mainsequence.egg-info/requires.txt +0 -0
  69. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/mainsequence.egg-info/top_level.txt +0 -0
  70. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/setup.cfg +0 -0
  71. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/tests/test_auth_bootstrap.py +0 -0
  72. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/tests/test_auth_precedence.py +0 -0
  73. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/tests/test_caller_assertions.py +0 -0
  74. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/tests/test_canonical_api_route_contract.py +0 -0
  75. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/tests/test_cli_auth_contract.py +0 -0
  76. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/tests/test_cli_browser_auth.py +0 -0
  77. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/tests/test_cli_secure_storage.py +0 -0
  78. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/tests/test_client.py +0 -0
  79. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/tests/test_code_repository_branch_contract.py +0 -0
  80. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/tests/test_code_repository_documentation_contract.py +0 -0
  81. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/tests/test_code_repository_runtime_context.py +0 -0
  82. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/tests/test_credential_repr.py +0 -0
  83. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/tests/test_dependency_extras.py +0 -0
  84. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/tests/test_dev_release_version.py +0 -0
  85. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/tests/test_environment_resource_context.py +0 -0
  86. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/tests/test_environment_response_contracts.py +0 -0
  87. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/tests/test_fastapi_mcp.py +0 -0
  88. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/tests/test_fastapi_request_identity.py +0 -0
  89. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/tests/test_github_issues_client.py +0 -0
  90. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/tests/test_independent_context.py +0 -0
  91. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/tests/test_inference_client.py +0 -0
  92. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/tests/test_instrumentation.py +0 -0
  93. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/tests/test_job_client.py +0 -0
  94. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/tests/test_local_ops.py +0 -0
  95. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/tests/test_logconf.py +0 -0
  96. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/tests/test_models_user_request_bound_auth.py +0 -0
  97. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/tests/test_notification_uid_contract.py +0 -0
  98. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/tests/test_observability_client.py +0 -0
  99. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/tests/test_runtime_credential_exchange.py +0 -0
  100. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/tests/test_scaffold_skills.py +0 -0
  101. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/tests/test_secret_client_model.py +0 -0
  102. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/tests/test_source_ownership.py +0 -0
  103. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/tests/test_ssh_utils.py +0 -0
  104. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/tests/test_tolerant_response_reading.py +0 -0
  105. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/tests/test_tolerant_value_sets.py +0 -0
  106. {mainsequence-9.0.20.dev62 → mainsequence-9.0.21.dev64}/tests/test_user_application_facts.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mainsequence
3
- Version: 9.0.20.dev62
3
+ Version: 9.0.21.dev64
4
4
  Summary: Main Sequence SDK
5
5
  Author-email: Main Sequence GmbH <dev@main-sequence.io>
6
6
  License-Expression: MIT
@@ -33,6 +33,9 @@ internally and sends a standard A2A message.
33
33
  - Read `a2a_profile` from discovery, default to `message`, and never infer Task
34
34
  support.
35
35
  - Use `--response-kind task` only when the selected Agent advertises `task`.
36
+ - The platform answers a direct `message` send within its Message limit, 90
37
+ seconds, or the send fails with `A2AMessageLimitExceededError`. No client
38
+ setting extends that limit; send work that can take longer as a Task.
36
39
  - Do not send or request reasoning/thinking traces; standard A2A responses expose only message parts.
37
40
 
38
41
  ## Discovery Flow
@@ -244,6 +247,12 @@ result = AgentSession.send_a2a_message(
244
247
  task = AgentSession.wait_for_a2a_task(session, task_id=result.task.id)
245
248
  ```
246
249
 
250
+ `wait_for_a2a_task` reads the Task every `poll_interval_seconds` (default 1)
251
+ until it reaches a final state. Once `wait_timeout_seconds` (default 900) have
252
+ passed it raises `TimeoutError`; the Task keeps running, so read it again with
253
+ `AgentSession.get_a2a_task` or stop it with `AgentSession.cancel_a2a_task`. Its
254
+ `timeout` is the total budget of each read, as for the other A2A calls.
255
+
247
256
  For another message in the same conversation, reuse the same `session.uid`:
248
257
 
249
258
  ```python
@@ -312,7 +321,18 @@ handle for a different task or conversation.
312
321
  task` for an Agent whose discovery profile advertises it. Preserve its ID and
313
322
  follow it with the SDK task get/wait/cancel helpers.
314
323
  - Treat a result kind different from the requested kind as a protocol error.
324
+ - `timeout` (CLI `--timeout`) is one total budget for the send, including the
325
+ wait for a target runtime that is still starting or waking; it can only
326
+ shorten waits. If the runtime does not become ready within it, the send fails
327
+ with `AgentRuntimeNotReadyError` ("did not become ready") and nothing was
328
+ sent, so sending again later is safe.
315
329
  - Do not depend on reasoning, tool traces, runtime paths, or transport metadata.
330
+ - If a direct `message` send fails with `A2AMessageLimitExceededError` (the
331
+ error says the Agent did not answer within the platform's limit), the Agent
332
+ stopped the turn after the platform's 90-second Message limit, and work it
333
+ already did is not undone. Do not resend it as a Message; when the Agent
334
+ advertises `task`, send the work with `--response-kind task`
335
+ (`response_kind=A2AResponseKind.TASK`) and follow the Task.
316
336
  - If a send times out or disconnects and must be retried, reuse the same
317
337
  `--message-id` value to preserve request identity. Direct `message` sends do
318
338
  not have durable replay protection, so do not retry automatically: the first
@@ -63,7 +63,27 @@ MCP_PROTOCOL_VERSION = "2025-11-25"
63
63
  MCP_CLIENT_NAME = "mainsequence-cli"
64
64
  MCP_MAX_RESOURCE_PAGES = 100
65
65
 
66
- S = requests.Session()
66
+
67
+ class _CliSession(requests.Session):
68
+ """The CLI's HTTP session.
69
+
70
+ A request sent without its own timeout gets the SDK's default request
71
+ budget: 5 s to connect, 120 s to read and 125 s in total, inside any budget
72
+ that is already running.
73
+ """
74
+
75
+ def request(self, method, url, **kwargs):
76
+ if kwargs.get("timeout") is not None:
77
+ return super().request(method, url, **kwargs)
78
+ # The CLI imports the SDK client on its first request, not when it starts.
79
+ from mainsequence.client._transport import DEFAULT_TIMEOUT, request_budget
80
+
81
+ with request_budget(DEFAULT_TIMEOUT) as budget:
82
+ kwargs["timeout"] = budget.timeout()
83
+ return super().request(method, url, **kwargs)
84
+
85
+
86
+ S = _CliSession()
67
87
  S.headers.update({"Content-Type": "application/json"})
68
88
 
69
89
 
@@ -488,11 +508,13 @@ def _mcp_json_rpc(
488
508
  request_id: int,
489
509
  protocol_version: str | None = None,
490
510
  ) -> dict[str, Any]:
491
- """Send one authenticated stateless MCP JSON-RPC request."""
511
+ """Send one authenticated stateless MCP JSON-RPC request.
492
512
 
493
- access = _access_token()
494
- if not access:
495
- access = refresh_access()
513
+ The request, a token renewal after ``401`` and the resend share the SDK's
514
+ default request budget: 5 s to connect, 120 s to read, 125 s in total.
515
+ """
516
+ # The CLI imports the SDK client only where it needs it.
517
+ from mainsequence.client._transport import DEFAULT_TIMEOUT, request_budget
496
518
 
497
519
  payload = {
498
520
  "jsonrpc": "2.0",
@@ -501,24 +523,36 @@ def _mcp_json_rpc(
501
523
  "params": params,
502
524
  }
503
525
 
504
- def _send(token: str) -> requests.Response:
505
- headers = {
506
- "Authorization": f"Bearer {token}",
507
- "Content-Type": "application/json",
508
- "Accept": "application/json",
509
- }
510
- if protocol_version is not None:
511
- headers["MCP-Protocol-Version"] = protocol_version
512
- return S.request(
513
- "POST",
514
- _full(MCP_RESOURCE_PATH),
515
- headers=headers,
516
- data=json.dumps(payload),
517
- )
526
+ with request_budget(DEFAULT_TIMEOUT) as budget:
518
527
 
519
- response = _send(access)
520
- if response.status_code == 401:
521
- response = _send(refresh_access())
528
+ def _send(token: str) -> requests.Response:
529
+ headers = {
530
+ "Authorization": f"Bearer {token}",
531
+ "Content-Type": "application/json",
532
+ "Accept": "application/json",
533
+ }
534
+ if protocol_version is not None:
535
+ headers["MCP-Protocol-Version"] = protocol_version
536
+ return S.request(
537
+ "POST",
538
+ _full(MCP_RESOURCE_PATH),
539
+ headers=headers,
540
+ data=json.dumps(payload),
541
+ timeout=budget.timeout(),
542
+ )
543
+
544
+ try:
545
+ access = _access_token()
546
+ if not access:
547
+ access = refresh_access()
548
+ response = _send(access)
549
+ if response.status_code == 401:
550
+ response = _send(refresh_access())
551
+ except requests.Timeout as exc:
552
+ raise ApiError(
553
+ f"MCP {method} request timed out: the platform did not answer within "
554
+ "the request's timeout budget."
555
+ ) from exc
522
556
  if response.status_code == 401:
523
557
  raise NotLoggedIn("Not logged in.")
524
558
  if not response.ok:
@@ -1946,6 +1946,10 @@ def login(
1946
1946
  except ApiError as e:
1947
1947
  error(f"Login failed: {e}")
1948
1948
  raise typer.Exit(1) from e
1949
+ except (requests.exceptions.ConnectionError, requests.exceptions.Timeout) as e:
1950
+ # Never echo the exception: a transport error can carry request details.
1951
+ error(f"The backend at {normalized_backend} could not be reached ({type(e).__name__}).")
1952
+ raise typer.Exit(1) from e
1949
1953
  finally:
1950
1954
  if previous_backend_override is None:
1951
1955
  os.environ.pop("MAINSEQUENCE_ENDPOINT", None)
@@ -4386,9 +4390,23 @@ def agent_session_a2a_send_cmd(
4386
4390
  response_kind: str = typer.Option(
4387
4391
  "message",
4388
4392
  "--response-kind",
4389
- help="Select the A2A result contract: message or task.",
4393
+ help=(
4394
+ "Select the A2A result contract. message: wait for the Agent's answer "
4395
+ "within the platform's Message limit; a Message that reaches the limit "
4396
+ "fails and is not retried. task: get a Task back for longer work, when "
4397
+ "the Agent advertises it."
4398
+ ),
4399
+ ),
4400
+ timeout: int | None = typer.Option(
4401
+ None,
4402
+ "--timeout",
4403
+ help=(
4404
+ "Total time budget in seconds for the A2A send: waiting for the Agent "
4405
+ "runtime to become ready, the request and the response. "
4406
+ "Resolving a handle first is a separate request with the same budget. "
4407
+ "It can only shorten waits; it cannot extend the platform's limits."
4408
+ ),
4390
4409
  ),
4391
- timeout: int | None = typer.Option(None, "--timeout", help="Request timeout in seconds"),
4392
4410
  ):
4393
4411
  """
4394
4412
  Send one standard A2A message to an existing session UID or cached handle.
@@ -1,8 +1,10 @@
1
1
  """One bounded retry policy for SDK HTTP sessions."""
2
2
 
3
+ import contextvars
3
4
  import math
5
+ import threading
4
6
  import time
5
- from contextlib import contextmanager
7
+ from contextlib import contextmanager, suppress
6
8
  from contextvars import ContextVar
7
9
 
8
10
  import requests
@@ -80,6 +82,78 @@ def remaining_timeout(timeout):
80
82
  return Timeout(total=remaining, connect=min(connect, remaining), read=min(read, remaining))
81
83
 
82
84
 
85
+ def request_within_budget(method, url, *, timeout, **kwargs):
86
+ """Send one request, never retried, whose whole exchange ends by one deadline.
87
+
88
+ ``timeout`` has the shared transport's meaning: a number is one total budget
89
+ in seconds, and a ``(connect, read)`` tuple keeps those phase limits with a
90
+ total equal to their sum. An enclosing :func:`request_budget` can only
91
+ shorten it. The deadline covers connecting, sending, the response headers
92
+ and the whole body, so a server that keeps sending bytes slowly cannot
93
+ extend it.
94
+
95
+ A socket read that keeps receiving bytes never times out on its own, so the
96
+ exchange runs on a helper thread and the caller stops waiting at the
97
+ deadline. Then the response, when it has started, is shut down and
98
+ ``requests.Timeout`` is raised. The request may already have reached the
99
+ server.
100
+ """
101
+ with request_budget(timeout) as budget:
102
+ attempt_timeout = budget.timeout()
103
+ lock = threading.Lock()
104
+ finished = threading.Event()
105
+ state = {"abandoned": False, "response": None, "outcome": None}
106
+
107
+ def exchange():
108
+ response = None
109
+ try:
110
+ with requests.Session() as session:
111
+ response = session.request(
112
+ method, url, timeout=attempt_timeout, stream=True, **kwargs
113
+ )
114
+ with lock:
115
+ state["response"] = response
116
+ abandoned = state["abandoned"]
117
+ if not abandoned:
118
+ # Read the body here, so the caller's deadline bounds it too.
119
+ _ = response.content
120
+ outcome = response
121
+ except BaseException as exc: # handed to the waiting caller
122
+ outcome = exc
123
+ with lock:
124
+ state["outcome"] = outcome
125
+ abandoned = state["abandoned"]
126
+ if abandoned and response is not None:
127
+ response.close()
128
+ finished.set()
129
+
130
+ threading.Thread(
131
+ target=contextvars.copy_context().run,
132
+ args=(exchange,),
133
+ name="mainsequence-request-within-budget",
134
+ daemon=True,
135
+ ).start()
136
+ try:
137
+ finished.wait(budget.remaining)
138
+ finally:
139
+ with lock:
140
+ outcome = state["outcome"]
141
+ if outcome is None:
142
+ state["abandoned"] = True
143
+ started = state["response"]
144
+ if outcome is None and started is not None:
145
+ # Wakes a read that is still waiting for bytes.
146
+ with suppress(OSError, RuntimeError, ValueError):
147
+ started.raw.shutdown()
148
+ if outcome is None:
149
+ raise requests.Timeout(
150
+ "The SDK request did not finish within its total timeout budget."
151
+ )
152
+ if isinstance(outcome, BaseException):
153
+ raise outcome
154
+ return outcome
155
+
156
+
83
157
  class DeadlineHTTPAdapter(HTTPAdapter):
84
158
  def __init__(self, *, retries, backoff_factor):
85
159
  if not isinstance(retries, int) or retries < 0: