vis-agent 0.2.32.dev7316__tar.gz → 0.2.32.dev7350__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 (98) hide show
  1. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/PKG-INFO +1 -1
  2. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/pyproject.toml +1 -1
  3. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/src/blockether/vis/_outside.py +8 -2
  4. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/src/blockether/vis/engine/_agent.py +8 -8
  5. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/src/blockether/vis/engine/_client.py +26 -7
  6. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/src/blockether/vis/engine/_local.py +10 -6
  7. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/src/blockether/vis/extension.py +93 -54
  8. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/src/blockether/vis/extension_package.py +9 -4
  9. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/src/blockether/vis/rooms.py +1 -1
  10. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/src/blockether/vis/views.py +1 -0
  11. vis_agent-0.2.32.dev7350/tests/test_sdk_typing.py +108 -0
  12. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/.gitignore +0 -0
  13. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/LICENSE +0 -0
  14. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/README.md +0 -0
  15. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/contracts/fixtures/activity-arguments.json +0 -0
  16. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/contracts/fixtures/activity-cases.json +0 -0
  17. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/contracts/fixtures/activity-copy.json +0 -0
  18. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/contracts/fixtures/activity-execution.json +0 -0
  19. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/contracts/fixtures/activity-groups.json +0 -0
  20. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/contracts/fixtures/activity-read-session.json +0 -0
  21. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/contracts/fixtures/activity-reads.json +0 -0
  22. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/contracts/fixtures/activity-results.json +0 -0
  23. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/contracts/fixtures/activity-tables.json +0 -0
  24. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/contracts/fixtures/activity.json +0 -0
  25. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/contracts/fixtures/diff.json +0 -0
  26. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/contracts/fixtures/live-primitives.json +0 -0
  27. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/contracts/fixtures/session-health.json +0 -0
  28. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/contracts/fixtures/view.json +0 -0
  29. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/contracts/schema/activity.json +0 -0
  30. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/contracts/schema/agents.json +0 -0
  31. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/contracts/schema/automations.json +0 -0
  32. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/contracts/schema/common.json +0 -0
  33. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/contracts/schema/config.json +0 -0
  34. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/contracts/schema/content.json +0 -0
  35. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/contracts/schema/council.json +0 -0
  36. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/contracts/schema/diff.json +0 -0
  37. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/contracts/schema/gateway.json +0 -0
  38. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/contracts/schema/improve.json +0 -0
  39. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/contracts/schema/provider.json +0 -0
  40. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/contracts/schema/rooms.json +0 -0
  41. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/contracts/schema/symbol.json +0 -0
  42. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/contracts/schema/toggle.json +0 -0
  43. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/contracts/schema/view.json +0 -0
  44. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/examples/greeter/tests/test_greeter.py +0 -0
  45. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/hatch_build.py +0 -0
  46. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/src/blockether/vis/__init__.py +0 -0
  47. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/src/blockether/vis/_contracts.py +0 -0
  48. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/src/blockether/vis/_wire.py +0 -0
  49. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/src/blockether/vis/activity.py +0 -0
  50. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/src/blockether/vis/engine/__init__.py +0 -0
  51. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/src/blockether/vis/engine/_agents.py +0 -0
  52. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/src/blockether/vis/engine/_council.py +0 -0
  53. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/src/blockether/vis/engine/_extensions.py +0 -0
  54. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/src/blockether/vis/py.typed +0 -0
  55. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/tests/conftest.py +0 -0
  56. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/tests/test_activity.py +0 -0
  57. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/tests/test_agent.py +0 -0
  58. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/tests/test_agent_extension_lifecycle.py +0 -0
  59. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/tests/test_agent_extensions.py +0 -0
  60. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/tests/test_agents.py +0 -0
  61. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/tests/test_api_guides.py +0 -0
  62. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/tests/test_authoring.py +0 -0
  63. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/tests/test_catalog.py +0 -0
  64. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/tests/test_catalog_recipe.py +0 -0
  65. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/tests/test_ci_report_recipe.py +0 -0
  66. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/tests/test_client.py +0 -0
  67. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/tests/test_client_extensions_engine.py +0 -0
  68. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/tests/test_code_quality_recipe.py +0 -0
  69. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/tests/test_contracts.py +0 -0
  70. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/tests/test_council.py +0 -0
  71. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/tests/test_declarations.py +0 -0
  72. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/tests/test_engine.py +0 -0
  73. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/tests/test_engine_docstrings.py +0 -0
  74. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/tests/test_extension_package.py +0 -0
  75. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/tests/test_extension_reference.py +0 -0
  76. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/tests/test_gateway_performance.py +0 -0
  77. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/tests/test_goals.py +0 -0
  78. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/tests/test_live_primitives.py +0 -0
  79. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/tests/test_live_wait.py +0 -0
  80. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/tests/test_local.py +0 -0
  81. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/tests/test_method_typing.py +0 -0
  82. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/tests/test_monitor_recipe.py +0 -0
  83. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/tests/test_namespace.py +0 -0
  84. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/tests/test_outside.py +0 -0
  85. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/tests/test_providers.py +0 -0
  86. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/tests/test_record_arguments.py +0 -0
  87. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/tests/test_registration.py +0 -0
  88. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/tests/test_repair_hooks.py +0 -0
  89. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/tests/test_result_sequence.py +0 -0
  90. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/tests/test_rooms.py +0 -0
  91. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/tests/test_scoped_settings.py +0 -0
  92. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/tests/test_sdk_bootstrap.py +0 -0
  93. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/tests/test_sdk_guide.py +0 -0
  94. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/tests/test_structured_live.py +0 -0
  95. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/tests/test_symbol_contract.py +0 -0
  96. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/tests/test_testing.py +0 -0
  97. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/tests/test_tools.py +0 -0
  98. {vis_agent-0.2.32.dev7316 → vis_agent-0.2.32.dev7350}/tests/test_views.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: vis-agent
3
- Version: 0.2.32.dev7316
3
+ Version: 0.2.32.dev7350
4
4
  Summary: The Vis Python SDK — author extensions and control local or remote engines.
5
5
  Project-URL: Homepage, https://vis.blockether.com
6
6
  Project-URL: Documentation, https://vis.blockether.com
@@ -8,7 +8,7 @@ build-backend = "hatchling.build"
8
8
 
9
9
  [project]
10
10
  name = "vis-agent"
11
- version = "0.2.32.dev7316"
11
+ version = "0.2.32.dev7350"
12
12
  description = "The Vis Python SDK — author extensions and control local or remote engines."
13
13
  readme = "README.md"
14
14
  requires-python = ">=3.11"
@@ -21,11 +21,15 @@ import uuid
21
21
  from copy import deepcopy
22
22
  from datetime import date
23
23
  from pathlib import Path
24
+ from typing import TYPE_CHECKING, Any
24
25
 
25
26
  from blockether.vis import _contracts
26
27
 
28
+ if TYPE_CHECKING:
29
+ from blockether.vis.extension import Host
27
30
 
28
- def check_host(host):
31
+
32
+ def check_host(host) -> Host:
29
33
  """Refuse a host that does not answer every implemented host operation.
30
34
 
31
35
  Returns the host, so a constructor can `return check_host(built)`. An incomplete
@@ -412,7 +416,7 @@ class _Run:
412
416
  def _result(run, stage, **extra):
413
417
  code = run.poll()
414
418
  now = time.time()
415
- result = dict.fromkeys(_SHELL_RESULT_KEYS)
419
+ result: dict[str, Any] = dict.fromkeys(_SHELL_RESULT_KEYS)
416
420
  out, next_offset, omitted = run.read(
417
421
  extra.pop("_offset", None), extra.pop("_limit", None)
418
422
  )
@@ -1083,6 +1087,7 @@ def _live_apply(view, op):
1083
1087
  complaint = _live_check_node(spec, _live_ids(view["nodes"]))
1084
1088
  if complaint:
1085
1089
  raise Refused(complaint)
1090
+ assert isinstance(spec, dict) # _live_check_node refuses any other spec.
1086
1091
  found = _live_find(view["nodes"], op.get("after"))
1087
1092
  # A new node joins the row that holds the node it named, not the top of
1088
1093
  # the view: `after` is a sibling, so the arrangement stays the one declared.
@@ -1234,6 +1239,7 @@ def live(envelope_json):
1234
1239
  complaint = _check_view(view)
1235
1240
  if complaint:
1236
1241
  raise Refused(complaint)
1242
+ assert isinstance(view, dict) # _check_view refuses any other view.
1237
1243
  view_id = str(uuid.uuid4())
1238
1244
  held = {"view_id": view_id, "view": json.loads(json.dumps(view)), "seq": 0}
1239
1245
  held["condition"] = threading.Condition()
@@ -11,7 +11,7 @@ from typing import Any, TypeVar, overload
11
11
  from jsonschema import Draft202012Validator, validators
12
12
  from jsonschema.exceptions import ValidationError, best_match
13
13
  from pydantic import BaseModel
14
- from pydantic_core import SchemaError, SchemaValidator, core_schema
14
+ from pydantic_core import ErrorDetails, SchemaError, SchemaValidator, core_schema
15
15
  from pydantic_core import ValidationError as PydanticValidationError
16
16
  from referencing import Registry
17
17
  from referencing.exceptions import Unresolvable
@@ -173,7 +173,7 @@ def _schema_problems(error: ValidationError, show_input: bool):
173
173
  """Describe a JSON Schema error without printing whole objects or arrays."""
174
174
  path = tuple(error.absolute_path)
175
175
  keyword, expected, value = error.validator, error.validator_value, error.instance
176
- if keyword == "required" and isinstance(value, dict):
176
+ if keyword == "required" and isinstance(value, dict) and isinstance(expected, list):
177
177
  for name in expected:
178
178
  if name not in value:
179
179
  yield _problem("schema", (*path, name), "required property is missing")
@@ -352,7 +352,7 @@ class _ResponseContract:
352
352
  for item in details
353
353
  )
354
354
 
355
- def _pydantic_message(self, item: dict) -> str:
355
+ def _pydantic_message(self, item: ErrorDetails) -> str:
356
356
  value = item.get("input")
357
357
  if self.show_input and item["type"] != "missing" and _is_scalar(value):
358
358
  return f"{item['msg']} (input: {_brief(value)})"
@@ -426,7 +426,7 @@ class Agent:
426
426
  self._session = self.execution_layer.create_session(
427
427
  **self._session_options
428
428
  )
429
- self._mount_extensions(self._extensions)
429
+ self._mount_extensions(self._session, self._extensions)
430
430
  except BaseException:
431
431
  try:
432
432
  self.close()
@@ -435,13 +435,13 @@ class Agent:
435
435
  raise
436
436
  return self._session
437
437
 
438
- def _mount_extensions(self, extensions):
438
+ def _mount_extensions(self, session: Session, extensions):
439
439
  if extensions.manifest:
440
440
  self.execution_layer._ensure_client_lease()
441
441
  self.execution_layer.put_session_client_extensions(
442
- self._session.id, body={"extensions": extensions.manifest}
442
+ session.id, body={"extensions": extensions.manifest}
443
443
  )
444
- self.execution_layer._client_extensions[self._session.id] = extensions
444
+ self.execution_layer._client_extensions[session.id] = extensions
445
445
 
446
446
  def register_extension(self, extension):
447
447
  """Add an application-owned Extension before this Agent's first request.
@@ -458,7 +458,7 @@ class Agent:
458
458
  raise RuntimeError("register extensions before the Agent's first request")
459
459
  candidate = ClientExtensions((*self._extensions.declarations, extension))
460
460
  if self._session is not None:
461
- self._mount_extensions(candidate)
461
+ self._mount_extensions(self._session, candidate)
462
462
  self._extensions = candidate
463
463
 
464
464
  def send(self, request: str, **options) -> Turn:
@@ -7,6 +7,7 @@ means that a submitted mutation was rolled back.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ import inspect
10
11
  import json
11
12
  import math
12
13
  import threading
@@ -135,7 +136,7 @@ def _field(data: Any, name: str, kind: type) -> Any:
135
136
  value = data.get(name) if isinstance(data, dict) else None
136
137
  if type(value) is not kind or (kind is str and not value):
137
138
  raise ProtocolError(f"invalid response field: {name}")
138
- if kind is int and value < 0:
139
+ if type(value) is int and value < 0:
139
140
  raise ProtocolError(f"invalid response field: {name}")
140
141
  return value
141
142
 
@@ -286,7 +287,7 @@ class ExecutionLayer(ABC):
286
287
  @abstractmethod
287
288
  def _open(
288
289
  self, method, route, *, query=None, body=None, content=None, timeout=None
289
- ):
290
+ ) -> Any:
290
291
  """Run one transport exchange. Do not retry mutations."""
291
292
 
292
293
  def _ensure_client_lease(self, *, pid: int | None = None) -> str:
@@ -297,7 +298,8 @@ class ExecutionLayer(ABC):
297
298
  if pid is not None:
298
299
  body["pid"] = pid
299
300
  lease = self._request("POST", "/v1/clients", body=body).json()
300
- self._lease = _field(lease, "client_id", str)
301
+ client_id: str = _field(lease, "client_id", str)
302
+ self._lease = client_id
301
303
  return self._lease
302
304
 
303
305
  def __enter__(self):
@@ -364,7 +366,8 @@ class ExecutionLayer(ABC):
364
366
  f"maximum is {_MAX_DECISION_ARCHIVE} bytes"
365
367
  )
366
368
  names = {s[1:] for s in route.split("/") if s.startswith(":")}
367
- if set(path or {}) != names:
369
+ path = path or {}
370
+ if set(path) != names:
368
371
  raise ValueError("path parameters do not match route")
369
372
  resolved = "/".join(
370
373
  _segment(path[s[1:]]) if s.startswith(":") else s for s in route.split("/")
@@ -2995,6 +2998,9 @@ class GatewayClient(ExecutionLayer):
2995
2998
  if upload_sha256 is not None:
2996
2999
  headers["Content-Type"] = "application/zip"
2997
3000
  headers["Transfer-Encoding"] = "chunked"
3001
+ # _request sends a hash only with a bounded stream and its length.
3002
+ assert content is not None and not isinstance(content, bytes)
3003
+ assert upload_length is not None
2998
3004
  content = _UploadStream(content, upload_length)
2999
3005
  headers["X-Content-SHA256"] = upload_sha256
3000
3006
  url = self._url + route + ("?" + urlencode(query) if query else "")
@@ -3136,7 +3142,7 @@ class Session:
3136
3142
  client: ExecutionLayer
3137
3143
  id: str
3138
3144
 
3139
- def _call(self, method, suffix="", **kwargs):
3145
+ def _call(self, method, suffix="", **kwargs) -> Any:
3140
3146
  path = {"sid": self.id, **kwargs.pop("path", {})}
3141
3147
  response = self.client._request(
3142
3148
  method, "/v1/sessions/:sid" + suffix, path=path, **kwargs
@@ -3476,7 +3482,7 @@ class _EventStream:
3476
3482
  if self._raw is not None:
3477
3483
  self._raw.close()
3478
3484
  # A pipe failure can close its owning client from inside this generator.
3479
- if not self._iterator.gi_running:
3485
+ if inspect.getgeneratorstate(self._iterator) != inspect.GEN_RUNNING:
3480
3486
  self._iterator.close()
3481
3487
  self.client._streams.discard(self)
3482
3488
 
@@ -3533,8 +3539,16 @@ class _EventStream:
3533
3539
  def _terminal(self, event):
3534
3540
  return False
3535
3541
 
3542
+ def _endpoint(self):
3543
+ """Return the route and query of this subscription."""
3544
+ raise NotImplementedError
3545
+
3546
+ def _accept(self, name, value):
3547
+ """Return the event of one frame, or None to skip the frame."""
3548
+ raise NotImplementedError
3536
3549
 
3537
- class _PollingEvents:
3550
+
3551
+ class _PollingEvents(_EventStream):
3538
3552
  """Poll finite event pages so callback code runs on the SDK calling thread."""
3539
3553
 
3540
3554
  def _iterate(self):
@@ -3558,6 +3572,10 @@ class _PollingEvents:
3558
3572
  def _pump(self):
3559
3573
  pass
3560
3574
 
3575
+ def _page(self):
3576
+ """Return the next finite page of `(event name, value)` pairs."""
3577
+ raise NotImplementedError
3578
+
3561
3579
 
3562
3580
  class Events(_EventStream):
3563
3581
  """Iterate typed session events, with replay and bounded reconnection.
@@ -3604,6 +3622,7 @@ class Events(_EventStream):
3604
3622
  if name is not None and name != event.type:
3605
3623
  raise ProtocolError("event name disagrees with payload")
3606
3624
  seq = event.cursor if event.type == "subscription.ready" else event.seq
3625
+ assert seq is not None # Event.from_wire requires the field of each type.
3607
3626
  if event.type == "subscription.ready" or seq > self.cursor:
3608
3627
  self.cursor = seq
3609
3628
  return event
@@ -117,11 +117,13 @@ class LocalEngine(ExecutionLayer):
117
117
  raise NotADirectoryError(str(path))
118
118
  return {"root": str(path)}
119
119
 
120
- def _ensure_client_lease(self) -> str:
120
+ def _ensure_client_lease(self, *, pid: int | None = None) -> str:
121
121
  # Stdio shares the application host; its PID keeps idle callbacks alive.
122
- return super()._ensure_client_lease(pid=os.getpid())
122
+ return super()._ensure_client_lease(pid=os.getpid() if pid is None else pid)
123
123
 
124
124
  def _read_line(self, deadline):
125
+ # connect() starts the engine with piped stdio before any read.
126
+ assert self._process is not None and self._process.stdout is not None
125
127
  while b"\n" not in self._buffer:
126
128
  remaining = deadline - time.monotonic()
127
129
  if (
@@ -176,6 +178,7 @@ class LocalEngine(ExecutionLayer):
176
178
  stderr=stderr,
177
179
  start_new_session=True,
178
180
  )
181
+ assert self._process.stdin is not None # Popen got stdin=PIPE.
179
182
  os.set_blocking(self._process.stdin.fileno(), False)
180
183
  hello = self._read_line(time.monotonic() + self._startup_timeout)
181
184
  if (
@@ -197,6 +200,7 @@ class LocalEngine(ExecutionLayer):
197
200
  self, method, route, *, query=None, body=None, content=None, timeout=None
198
201
  ):
199
202
  self.connect()
203
+ assert self._process is not None and self._process.stdin is not None
200
204
  deadline = time.monotonic() + _duration(
201
205
  self.timeout if timeout is None else timeout
202
206
  )
@@ -296,14 +300,14 @@ class LocalEngine(ExecutionLayer):
296
300
  if process.stdout:
297
301
  process.stdout.close()
298
302
  if self._home is not None:
299
- self._stderr_tail = self._stderr_end()
303
+ self._stderr_tail = self._stderr_end(self._home.name)
300
304
  self._home.cleanup()
301
305
  self._cleanup_complete = True
302
306
 
303
- def _stderr_end(self):
304
- """Return the end of the engine's stderr log, from a line start."""
307
+ def _stderr_end(self, home):
308
+ """Return the end of the engine's stderr log in `home`, from a line start."""
305
309
  try:
306
- with open(Path(self._home.name) / "stderr.log", "rb") as stream:
310
+ with open(Path(home) / "stderr.log", "rb") as stream:
307
311
  size = stream.seek(0, os.SEEK_END)
308
312
  stream.seek(max(0, size - _STDERR_TAIL))
309
313
  end = stream.read()
@@ -27,6 +27,7 @@ from typing import (
27
27
  Annotated,
28
28
  Any,
29
29
  ClassVar,
30
+ Generic,
30
31
  Literal,
31
32
  Optional,
32
33
  Protocol,
@@ -39,6 +40,9 @@ from typing import (
39
40
  runtime_checkable,
40
41
  )
41
42
 
43
+ # A setting value: `bool` for a boolean setting, `str` for a choice setting.
44
+ _SettingValue = TypeVar("_SettingValue", bool, str)
45
+
42
46
 
43
47
  @runtime_checkable
44
48
  class Host(Protocol):
@@ -46,47 +50,60 @@ class Host(Protocol):
46
50
 
47
51
  def workspace_root(self) -> str:
48
52
  """Read the active session working copy, or the outside process directory."""
53
+ ...
49
54
 
50
55
  def setting_declaration(self, spec: Mapping[str, Any]) -> Mapping[str, Any]:
51
56
  """Validate a declaration and fill defaults from the contract schema."""
57
+ ...
52
58
 
53
- def setting(self, id: str, default: bool | str) -> bool | str:
54
- """Read one setting from the current callback snapshot."""
59
+ def setting(self, id: str, default: _SettingValue) -> _SettingValue:
60
+ """Read one setting from the current callback snapshot, as the type of `default`."""
61
+ ...
55
62
 
56
63
  def state_get(self, key: str) -> Any:
57
64
  """Read one value out of the extension's durable state."""
65
+ ...
58
66
 
59
67
  def state_put(self, key: str, value: Any) -> Any:
60
68
  """Write one JSON value into the extension's durable state."""
69
+ ...
61
70
 
62
71
  def state_del(self, key: str) -> Any:
63
72
  """Drop one key from the extension's durable state."""
73
+ ...
64
74
 
65
75
  def state_keys(self) -> Any:
66
76
  """List every key the extension's durable state holds."""
77
+ ...
67
78
 
68
79
  def log(self, level: str, message: str) -> Any:
69
80
  """Emit one engine log line at a level."""
81
+ ...
70
82
 
71
83
  def notify(self, text: str, level: str) -> Any:
72
84
  """Show one notification on the user's channel."""
85
+ ...
73
86
 
74
87
  def council_wake(self, options: Mapping[str, Any]) -> Mapping[str, Any]:
75
88
  """Publish to the bound session. Only an eligible managed subagent wakes itself."""
89
+ ...
76
90
 
77
91
  def shell(self, options: Mapping[str, Any]) -> Mapping[str, Any]:
78
92
  """Run one shell operation. Return the contract's result shape."""
93
+ ...
79
94
 
80
95
  def jailed_shell(self, options: Mapping[str, Any]) -> Mapping[str, Any]:
81
96
  """Run one shell op inside the workspace jail."""
97
+ ...
82
98
 
83
99
  def request_input(
84
100
  self,
85
101
  request_json: str,
86
102
  validator_arities_json: str,
87
- run_validator: Callable[[str, str], str],
103
+ run_validator: Callable[[str, int, str, str], str],
88
104
  ) -> str:
89
105
  """Ask the human, and block until the answer settles or is cancelled."""
106
+ ...
90
107
 
91
108
  def live(self, envelope_json: str) -> str:
92
109
  """Open, patch, read or close a live view.
@@ -96,28 +113,34 @@ class Host(Protocol):
96
113
  An unchanged timeout returns is_open=True, timed_out=True, without view.
97
114
  Ordinary state reads and changed waits return the current view with seq.
98
115
  """
116
+ ...
99
117
 
100
118
  def activity(self, presentation: dict[str, Any]) -> bool:
101
119
  """Replace the running symbol's headline, summary, content and sections."""
120
+ ...
102
121
 
103
122
  def reveal_secret(self, handle: str) -> Any:
104
123
  """Resolve a `vis-secret:` handle to its plaintext."""
124
+ ...
105
125
 
106
126
  def forget_secret(self, handle: str) -> Any:
107
127
  """Drop the plaintext a secret handle stands for."""
128
+ ...
108
129
 
109
130
  def declare_env(self, declarations_json: str) -> str:
110
131
  """Resolve the environment variables the extension declared."""
132
+ ...
111
133
 
112
134
  def call_tool(
113
135
  self, tool: str, args: Sequence[Any], kwargs: Mapping[str, Any]
114
136
  ) -> Any:
115
137
  """Run one active session tool by the name `python_execution` calls."""
138
+ ...
116
139
 
117
140
 
118
- try:
119
- _host # noqa: B018, F821 — the host seeds this into the module dict before exec.
120
- except NameError: # Installed from PyPI: no host in the room, so bring one.
141
+ # The engine seeds `_host` into the module dict before exec.
142
+ _host: Host
143
+ if "_host" not in globals(): # Installed from PyPI: no host in the room, so bring one.
121
144
  from blockether.vis import _outside as outside
122
145
 
123
146
  _host = outside.host
@@ -253,7 +276,7 @@ def _tool_value(value):
253
276
  return value
254
277
 
255
278
 
256
- def _tool_data(value):
279
+ def _tool_data(value) -> Any:
257
280
  """One argument as JSON data. A dataclass instance travels as its public fields."""
258
281
  if is_dataclass(value) and not isinstance(value, type):
259
282
  return {
@@ -333,7 +356,7 @@ def _bounded_text(value, limit, name):
333
356
  raise ValueError(f"{name} must be text of at most {limit} characters")
334
357
 
335
358
 
336
- def _wire_value(value):
359
+ def _wire_value(value) -> Any:
337
360
  if isinstance(value, Mapping):
338
361
  return {key: _wire_value(item) for key, item in value.items()}
339
362
  if isinstance(value, (tuple, list)):
@@ -341,7 +364,7 @@ def _wire_value(value):
341
364
  return value
342
365
 
343
366
 
344
- def _freeze_config(value):
367
+ def _freeze_config(value) -> Any:
345
368
  if isinstance(value, Mapping):
346
369
  if not all(isinstance(key, str) for key in value):
347
370
  raise TypeError("provider JSON keys must be strings")
@@ -359,6 +382,7 @@ def _freeze_config(value):
359
382
 
360
383
  class _ActivityBlock:
361
384
  __slots__ = ()
385
+ __dataclass_fields__: ClassVar[dict[str, Any]] # Each subclass is a dataclass.
362
386
  type: ClassVar[str]
363
387
 
364
388
  def to_wire(self) -> dict[str, Any]:
@@ -510,7 +534,9 @@ class ActivityProgress(_ActivityBlock):
510
534
  if self.value is None and self.total is None:
511
535
  return
512
536
  if (
513
- type(self.value) not in (int, float)
537
+ self.value is None
538
+ or self.total is None
539
+ or type(self.value) not in (int, float)
514
540
  or type(self.total) not in (int, float)
515
541
  or not math.isfinite(self.value)
516
542
  or not math.isfinite(self.total)
@@ -539,8 +565,9 @@ ActivityBlock: TypeAlias = (
539
565
 
540
566
  _ActivitySummaryFormat: TypeAlias = Literal["inline", "markdown"]
541
567
  _ActivityVerdict: TypeAlias = Literal["passed", "failed"]
542
- _SymbolTag: TypeAlias = Literal["observation", "mutation", "verification", "external"]
543
- _SYMBOL_TAGS = get_args(_SymbolTag)
568
+ # The kind of work of one tool call. Annotate a tag in a variable with this type.
569
+ SymbolTag: TypeAlias = Literal["observation", "mutation", "verification", "external"]
570
+ _SYMBOL_TAGS = get_args(SymbolTag)
544
571
 
545
572
 
546
573
  @dataclass(frozen=True, slots=True)
@@ -762,9 +789,10 @@ def _activity_call(fn, activity):
762
789
  except Exception:
763
790
  pass # Presentation must not alter the operation's result or error.
764
791
 
792
+ # Each branch declares `invoke`; only one of them runs.
765
793
  if inspect.iscoroutinefunction(fn):
766
794
 
767
- async def invoke(*args, **kwargs):
795
+ async def invoke(*args, **kwargs): # pyright: ignore[reportRedeclaration]
768
796
  if activity.show_start:
769
797
  render("start", args, kwargs)
770
798
  try:
@@ -792,23 +820,24 @@ def _activity_call(fn, activity):
792
820
  return wraps(fn)(invoke)
793
821
 
794
822
 
795
- _registration = {"spec": None}
823
+ _registration: dict[str, Any] = {"spec": None}
796
824
 
797
825
 
798
826
  @dataclass(frozen=True, slots=True, kw_only=True)
799
- class Setting:
827
+ class Setting(Generic[_SettingValue]):
800
828
  """Declare a boolean or choice setting shared by the app and TUI.
801
829
 
802
830
  `scopes` allows any non-empty combination of global, project, group and session.
803
831
  Omitting it uses the contract's global-only default. A project extension exists
804
832
  only in its project, so there `global` means the whole project. The setting shows
805
833
  in its extension's own settings section. `value()` reads the current callback's
806
- response snapshot. Outside Vis, it returns the declared default.
834
+ response snapshot. Its type follows `default`: `bool` for a boolean setting, `str`
835
+ for a choice setting. Outside Vis, it returns the declared default.
807
836
  """
808
837
 
809
838
  id: str
810
839
  label: str
811
- default: bool | str
840
+ default: _SettingValue
812
841
  type: str = "boolean"
813
842
  choices: Sequence[str] = ()
814
843
  scopes: Sequence[str] | None = None
@@ -850,7 +879,7 @@ class Setting:
850
879
  spec["description"] = self.description
851
880
  return spec
852
881
 
853
- def value(self) -> bool | str:
882
+ def value(self) -> _SettingValue:
854
883
  """Read this setting in the current callback without changing any scope."""
855
884
  return _host.setting(self.id, self.default)
856
885
 
@@ -1863,8 +1892,9 @@ def sequence(*, field: str) -> Callable[[_SequenceClass], _SequenceClass]:
1863
1892
  )
1864
1893
  if field not in {item.name for item in fields(cls)}:
1865
1894
  raise ValueError(f"sequence field {field!r} is not a dataclass field")
1866
- cls.__vis_sequence_field__ = field
1867
- return cls
1895
+ # Pyright narrows `cls` to a plain dataclass type and cannot see the marker.
1896
+ cls.__vis_sequence_field__ = field # pyright: ignore[reportAttributeAccessIssue]
1897
+ return cls # pyright: ignore[reportReturnType]
1868
1898
 
1869
1899
  return decorate
1870
1900
 
@@ -1879,11 +1909,16 @@ class _MethodDecorator(Protocol):
1879
1909
  def __call__(self, actual: _Method) -> _Method: ...
1880
1910
 
1881
1911
 
1912
+ def _method_function(value: Any) -> Any:
1913
+ """Return the function under a `staticmethod` or `classmethod`, else `value`."""
1914
+ return value.__func__ if isinstance(value, (staticmethod, classmethod)) else value
1915
+
1916
+
1882
1917
  @overload
1883
1918
  def method(
1884
1919
  fn: _Method,
1885
1920
  *,
1886
- tag: _SymbolTag = "observation",
1921
+ tag: SymbolTag = "observation",
1887
1922
  is_hidden: bool = False,
1888
1923
  activity: Activity | None = None,
1889
1924
  ) -> _Method: ...
@@ -1893,19 +1928,19 @@ def method(
1893
1928
  def method(
1894
1929
  fn: None = None,
1895
1930
  *,
1896
- tag: _SymbolTag = "observation",
1931
+ tag: SymbolTag = "observation",
1897
1932
  is_hidden: bool = False,
1898
1933
  activity: Activity | None = None,
1899
1934
  ) -> _MethodDecorator: ...
1900
1935
 
1901
1936
 
1902
1937
  def method(
1903
- fn: _Method | None = None,
1938
+ fn: Any = None,
1904
1939
  *,
1905
- tag: _SymbolTag = "observation",
1940
+ tag: SymbolTag = "observation",
1906
1941
  is_hidden: bool = False,
1907
1942
  activity: Activity | None = None,
1908
- ) -> _Method | _MethodDecorator:
1943
+ ) -> Any:
1909
1944
  """Describe a public method on an object exported through `Symbol`.
1910
1945
 
1911
1946
  Args:
@@ -1950,16 +1985,13 @@ def method(
1950
1985
  _activity_spec(activity)
1951
1986
 
1952
1987
  def _mark(actual: _Method) -> _Method:
1953
- declared = (
1954
- actual.__func__
1955
- if isinstance(actual, (staticmethod, classmethod))
1956
- else actual
1957
- )
1988
+ declared = _method_function(actual)
1958
1989
  if not callable(declared):
1959
1990
  raise ValueError("vis.method(...) requires a callable method")
1960
- declared.__vis_symbol_tag__ = tag
1961
- declared.__vis_symbol_hidden__ = bool(is_hidden)
1962
- declared.__vis_symbol_activity__ = activity
1991
+ # Pyright does not model attributes that code sets on a function.
1992
+ declared.__vis_symbol_tag__ = tag # pyright: ignore[reportFunctionMemberAccess]
1993
+ declared.__vis_symbol_hidden__ = bool(is_hidden) # pyright: ignore[reportFunctionMemberAccess]
1994
+ declared.__vis_symbol_activity__ = activity # pyright: ignore[reportFunctionMemberAccess]
1963
1995
  return actual
1964
1996
 
1965
1997
  return _mark if fn is None else _mark(fn)
@@ -1972,9 +2004,7 @@ def _public_members(obj):
1972
2004
  for name, raw in vars(cls).items():
1973
2005
  if name.startswith("_"):
1974
2006
  continue
1975
- declared = (
1976
- raw.__func__ if isinstance(raw, (staticmethod, classmethod)) else raw
1977
- )
2007
+ declared = _method_function(raw)
1978
2008
  candidates[name] = (
1979
2009
  "method" if inspect.isroutine(declared) else "value",
1980
2010
  declared,
@@ -2074,7 +2104,7 @@ class Symbol:
2074
2104
 
2075
2105
  fn: Callable[..., Any] | object
2076
2106
  name: str | None = None
2077
- tag: _SymbolTag = "observation"
2107
+ tag: SymbolTag = "observation"
2078
2108
  is_hidden: bool = False
2079
2109
  activity: Activity | None = None
2080
2110
 
@@ -2184,7 +2214,7 @@ class ToolSpec:
2184
2214
 
2185
2215
  version: int
2186
2216
  name: str
2187
- tag: _SymbolTag
2217
+ tag: SymbolTag
2188
2218
  description: str
2189
2219
  signature: str
2190
2220
  parameters: tuple[ParameterSpec, ...]
@@ -2223,7 +2253,7 @@ def _catalog_type(contract):
2223
2253
  )
2224
2254
 
2225
2255
 
2226
- def _catalog_presentation(*, phase, result=None, error=None, **_):
2256
+ def _catalog_presentation(*, phase, result: Any = None, error=None, **_):
2227
2257
  if phase == "failure":
2228
2258
  return ActivityPresentation("Tool reference", str(error))
2229
2259
  if phase == "start":
@@ -2565,10 +2595,11 @@ def _provider_wire(value):
2565
2595
 
2566
2596
  class _ProviderValue:
2567
2597
  __slots__ = ()
2598
+ __dataclass_fields__: ClassVar[dict[str, Any]] # Each subclass is a dataclass.
2568
2599
 
2569
2600
  def to_wire(self) -> dict[str, ProviderJSON]:
2570
2601
  """Return fresh host data. Declared optional fields are omitted, not null."""
2571
- value = {
2602
+ value: dict[str, ProviderJSON] = {
2572
2603
  f.name: _provider_wire(getattr(self, f.name))
2573
2604
  for f in fields(self)
2574
2605
  if f.name != "extra" and getattr(self, f.name) is not None
@@ -2839,13 +2870,14 @@ def _provider_callback(name, fn):
2839
2870
  raise TypeError(f"vis.Provider {name} returned an awaitable")
2840
2871
  if result is None or name in ("logout_fn", "on_selected_fn"):
2841
2872
  return None
2842
- expected = {
2873
+ expected_types: dict[str, type[_ProviderValue]] = {
2843
2874
  "get_token_fn": ProviderCredential,
2844
2875
  "detect_fn": ProviderCredential,
2845
2876
  "refresh_token_fn": ProviderCredential,
2846
2877
  "status_fn": ProviderStatus,
2847
2878
  "limits_fn": ProviderLimits,
2848
- }.get(name)
2879
+ }
2880
+ expected = expected_types.get(name)
2849
2881
  if expected is not None:
2850
2882
  if not isinstance(result, expected):
2851
2883
  raise TypeError(
@@ -3092,7 +3124,7 @@ class Shell(dict):
3092
3124
 
3093
3125
  def logs(self, offset=None, limit=None):
3094
3126
  # A NEGATIVE offset reads the last n LINES; a positive one is a byte cursor.
3095
- opts = {"op": "logs"}
3127
+ opts: dict[str, Any] = {"op": "logs"}
3096
3128
  if offset is not None:
3097
3129
  opts["offset"] = int(offset)
3098
3130
  if limit is not None:
@@ -3152,7 +3184,8 @@ class _Fs:
3152
3184
 
3153
3185
  @staticmethod
3154
3186
  def _door():
3155
- import _vis_fs
3187
+ # Only the engine's sandbox provides this module.
3188
+ import _vis_fs # pyright: ignore[reportMissingImports]
3156
3189
 
3157
3190
  return _vis_fs
3158
3191
 
@@ -3634,7 +3667,8 @@ class _Node:
3634
3667
  class _KeyedNode(_Node):
3635
3668
  """A node holding items the extension addresses by id: it can drop them."""
3636
3669
 
3637
- def remove(self, *item_ids):
3670
+ def remove(self: _Node, *item_ids):
3671
+ # Stat and Steps borrow this method, so `self` can be any node.
3638
3672
  # Ids as arguments or as one iterable, because a caller with a list
3639
3673
  # should not have to spread it.
3640
3674
  ids = (
@@ -4346,13 +4380,17 @@ class _LiveRecorder:
4346
4380
  yield node
4347
4381
  yield from _LiveRecorder._nodes(node.get("fields"))
4348
4382
 
4349
- def node(self, node_id):
4350
- """Return one materialized node by id, at any depth."""
4383
+ def _open_view(self):
4384
+ """Return the materialized view; a test must open one first."""
4351
4385
  if self._view is None:
4352
4386
  raise AssertionError("no test live view is open")
4387
+ return self._view
4388
+
4389
+ def node(self, node_id):
4390
+ """Return one materialized node by id, at any depth."""
4353
4391
  return next(
4354
4392
  node
4355
- for node in self._nodes(self._view.get("nodes"))
4393
+ for node in self._nodes(self._open_view().get("nodes"))
4356
4394
  if node["id"] == node_id
4357
4395
  )
4358
4396
 
@@ -4408,7 +4446,7 @@ class _LiveRecorder:
4408
4446
  return found
4409
4447
  return None
4410
4448
 
4411
- return find(self._view.get("nodes"))
4449
+ return find(self._open_view().get("nodes"))
4412
4450
 
4413
4451
  def _apply(self, op):
4414
4452
  action = op["op"]
@@ -4417,10 +4455,9 @@ class _LiveRecorder:
4417
4455
  found = (
4418
4456
  self._parent(op.get("after")) if op.get("after") is not None else None
4419
4457
  )
4458
+ view = self._open_view()
4420
4459
  siblings, at = (
4421
- (self._view["nodes"], len(self._view["nodes"]))
4422
- if found is None
4423
- else found
4460
+ (view["nodes"], len(view["nodes"])) if found is None else found
4424
4461
  )
4425
4462
  siblings.insert(at + (1 if found is not None else 0), node)
4426
4463
  return
@@ -4537,7 +4574,7 @@ class _LiveRecorder:
4537
4574
  for op in (envelope.get("patch") or {}).get("ops") or []:
4538
4575
  self._apply(op)
4539
4576
  self._seq += 1
4540
- self._view["seq"] = self._seq
4577
+ self._open_view()["seq"] = self._seq
4541
4578
  answer = {"view_id": self.view_id, "is_open": True, "seq": self._seq}
4542
4579
  elif action == "state":
4543
4580
  answer = {"view_id": self.view_id, "is_open": True, "view": self._view}
@@ -4684,9 +4721,11 @@ def _assert_catalog(
4684
4721
  mutation names. Test invocation, validation, IO and cancellation separately. Any and
4685
4722
  opaque types are allowed. Unresolved annotations fail with their path.
4686
4723
  """
4724
+ roots = catalog.spec()
4725
+ assert isinstance(roots, tuple), "Catalog.spec() lists the top-level specs"
4687
4726
  tools = tuple(
4688
4727
  tool
4689
- for spec in catalog.spec()
4728
+ for spec in roots
4690
4729
  for tool in (spec.members if isinstance(spec, NamespaceSpec) else (spec,))
4691
4730
  )
4692
4731
  actual_names = [tool.name for tool in tools]
@@ -18,6 +18,7 @@ import tomllib
18
18
  import uuid
19
19
  from http.client import HTTPException
20
20
  from pathlib import Path
21
+ from typing import TYPE_CHECKING
21
22
  from urllib.error import HTTPError, URLError
22
23
  from urllib.parse import unquote, urlsplit
23
24
  from urllib.request import HTTPRedirectHandler, Request, build_opener
@@ -27,7 +28,9 @@ try:
27
28
  from packaging.specifiers import SpecifierSet
28
29
  from packaging.utils import canonicalize_name
29
30
  from packaging.version import Version
30
- except ImportError:
31
+ except ImportError: # The engine's Python can lack packaging; pip vendors a copy.
32
+ if TYPE_CHECKING: # Type checkers see only the declared packaging dependency.
33
+ raise
31
34
  from pip._vendor.packaging.requirements import Requirement
32
35
  from pip._vendor.packaging.specifiers import SpecifierSet
33
36
  from pip._vendor.packaging.utils import canonicalize_name
@@ -961,6 +964,7 @@ def update(
961
964
  if subdirectory is None and not folder:
962
965
  folder = None
963
966
  active, current = _installed_repository(directory, repository, folder)
967
+ assert active is not None and current is not None # A required lookup raises.
964
968
  name = current["name"]
965
969
  release = _select(
966
970
  _releases(current["repository_url"], current["subdirectory"]), version
@@ -1011,6 +1015,7 @@ def rollback(
1011
1015
  if subdirectory is None and not folder:
1012
1016
  folder = None
1013
1017
  active, current = _installed_repository(directory, repository, folder)
1018
+ assert active is not None and current is not None # A required lookup raises.
1014
1019
  name = current["name"]
1015
1020
  if version is not None:
1016
1021
  release = _select(
@@ -1356,8 +1361,7 @@ def sync(
1356
1361
  if not dry_run:
1357
1362
  directory.mkdir(parents=True, exist_ok=True)
1358
1363
  lock = directory / ".sync-lock"
1359
- if not dry_run:
1360
- fd = _acquire(lock)
1364
+ fd = None if dry_run else _acquire(lock)
1361
1365
  try:
1362
1366
  records = _sync_records(directory)
1363
1367
  results = []
@@ -1381,7 +1385,8 @@ def sync(
1381
1385
  _save_sync_records(directory, records)
1382
1386
  elif dry_run:
1383
1387
  unchanged = (
1384
- _sync_owned(destination, current)
1388
+ current is not None
1389
+ and _sync_owned(destination, current)
1385
1390
  and current["spec"] == spec
1386
1391
  and not refresh
1387
1392
  and not _tracks_latest(spec)
@@ -375,7 +375,7 @@ class RoomsClient:
375
375
  *,
376
376
  lease_seconds: int | None = None,
377
377
  ) -> int:
378
- body = {"sessions": sessions}
378
+ body: dict[str, Any] = {"sessions": sessions}
379
379
  if lease_seconds is not None:
380
380
  body["lease_seconds"] = lease_seconds
381
381
  return self._request("POST", self._path(room_id, "/presence"), body)[
@@ -177,6 +177,7 @@ class ViewEvent:
177
177
  "view.close": "close_event",
178
178
  }[event_type]
179
179
  validate("view", definition, value)
180
+ payload: dict[str, Any]
180
181
  if event_type == "view.open":
181
182
  model = InputView if value["kind"] == "input" else LiveView
182
183
  document = model.from_wire(value["view"])
@@ -0,0 +1,108 @@
1
+ """Regression coverage for SDK types under Pyright standard checks."""
2
+
3
+ import json
4
+ import subprocess
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import blockether.vis.extension as vis
9
+
10
+ _SYMBOL_TAG_FIXTURE = """from typing import assert_type
11
+
12
+ import blockether.vis.extension as vis
13
+
14
+
15
+ def _knows(tag: vis.SymbolTag) -> bool:
16
+ try:
17
+ vis.method(tag=tag)
18
+ except ValueError:
19
+ return False
20
+ return True
21
+
22
+
23
+ CHECK: vis.SymbolTag = "verification" if _knows("verification") else "observation"
24
+
25
+
26
+ def bind(fn, *, tag: vis.SymbolTag = "observation"):
27
+ return vis.method(tag=tag)(fn)
28
+
29
+
30
+ bind(print, tag=CHECK)
31
+ assert_type(vis.Symbol(print, tag=CHECK).tag, vis.SymbolTag)
32
+ UNKNOWN: vis.SymbolTag = "unknown"
33
+ """
34
+
35
+ _SETTING_FIXTURE = """from typing import assert_type
36
+
37
+ import blockether.vis.extension as vis
38
+
39
+ ENABLED = vis.Setting(id="enabled", label="Desktop alerts", default=True)
40
+ LEVEL = vis.Setting(
41
+ id="level", label="Level", default="fast", type="enum", choices=["fast", "slow"]
42
+ )
43
+
44
+
45
+ def start(*, enabled: bool) -> None: ...
46
+
47
+
48
+ def context(env):
49
+ start(enabled=ENABLED.value())
50
+ assert_type(ENABLED, vis.Setting[bool])
51
+ assert_type(ENABLED.value(), bool)
52
+ assert_type(LEVEL.value(), str)
53
+ """
54
+
55
+
56
+ def _pyright(tmp_path, *targets):
57
+ config = tmp_path / "pyrightconfig.json"
58
+ config.write_text(
59
+ json.dumps({"typeCheckingMode": "standard", "pythonVersion": "3.11"})
60
+ )
61
+ command = [
62
+ sys.executable,
63
+ "-m",
64
+ "pyright",
65
+ "--project",
66
+ str(config),
67
+ "--pythonpath",
68
+ sys.executable,
69
+ "--warnings",
70
+ "--outputjson",
71
+ *(str(target) for target in targets),
72
+ ]
73
+ return subprocess.run(command, capture_output=True, text=True, check=False)
74
+
75
+
76
+ def test_sdk_sources_pass_pyright_standard(tmp_path):
77
+ # The SDK ships py.typed, so type checkers trust its annotations. Each
78
+ # standard-mode finding needs a fix or an explicit suppression (#315).
79
+ package = Path(vis.__file__).resolve().parent
80
+ result = _pyright(tmp_path, package)
81
+ assert result.returncode == 0, result.stdout + result.stderr
82
+ summary = json.loads(result.stdout)["summary"]
83
+ assert summary["filesAnalyzed"] == len(list(package.rglob("*.py")))
84
+
85
+
86
+ def test_symbol_tag_types_tags_in_variables(tmp_path):
87
+ # A tag in a variable or a helper parameter needs a public type (#313).
88
+ # The alias still rejects a tag that Vis does not know.
89
+ fixture = tmp_path / "symbol_tag.py"
90
+ fixture.write_text(_SYMBOL_TAG_FIXTURE)
91
+ result = _pyright(tmp_path, fixture)
92
+ report = json.loads(result.stdout)
93
+ found = [
94
+ (item["range"]["start"]["line"], item["rule"])
95
+ for item in report["generalDiagnostics"]
96
+ ]
97
+ unknown = _SYMBOL_TAG_FIXTURE.splitlines().index(
98
+ 'UNKNOWN: vis.SymbolTag = "unknown"'
99
+ )
100
+ assert found == [(unknown, "reportAssignmentType")], result.stdout
101
+
102
+
103
+ def test_setting_value_has_the_type_of_its_default(tmp_path):
104
+ # A boolean setting reads a bool and a choice setting reads a str (#314).
105
+ fixture = tmp_path / "setting_value.py"
106
+ fixture.write_text(_SETTING_FIXTURE)
107
+ result = _pyright(tmp_path, fixture)
108
+ assert result.returncode == 0, result.stdout + result.stderr