vis-agent 0.2.35.dev7411__tar.gz → 0.2.37__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 (99) hide show
  1. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/PKG-INFO +1 -1
  2. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/contracts/schema/gateway.json +1 -1
  3. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/contracts/schema/toggle.json +1 -1
  4. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/pyproject.toml +1 -1
  5. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/src/blockether/vis/_contracts.py +51 -5
  6. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/src/blockether/vis/_outside.py +8 -1
  7. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/src/blockether/vis/engine/_client.py +2 -2
  8. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/src/blockether/vis/extension.py +19 -6
  9. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/src/blockether/vis/extension_package.py +57 -23
  10. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/tests/test_contracts.py +6 -0
  11. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/tests/test_extension_package.py +101 -0
  12. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/tests/test_goals.py +1 -1
  13. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/tests/test_scoped_settings.py +26 -0
  14. vis_agent-0.2.37/tests/test_text_list_arguments.py +89 -0
  15. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/.gitignore +0 -0
  16. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/LICENSE +0 -0
  17. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/README.md +0 -0
  18. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/contracts/fixtures/activity-arguments.json +0 -0
  19. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/contracts/fixtures/activity-cases.json +0 -0
  20. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/contracts/fixtures/activity-copy.json +0 -0
  21. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/contracts/fixtures/activity-execution.json +0 -0
  22. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/contracts/fixtures/activity-groups.json +0 -0
  23. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/contracts/fixtures/activity-read-session.json +0 -0
  24. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/contracts/fixtures/activity-reads.json +0 -0
  25. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/contracts/fixtures/activity-results.json +0 -0
  26. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/contracts/fixtures/activity-tables.json +0 -0
  27. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/contracts/fixtures/activity.json +0 -0
  28. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/contracts/fixtures/diff.json +0 -0
  29. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/contracts/fixtures/live-primitives.json +0 -0
  30. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/contracts/fixtures/session-health.json +0 -0
  31. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/contracts/fixtures/view.json +0 -0
  32. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/contracts/schema/activity.json +0 -0
  33. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/contracts/schema/agents.json +0 -0
  34. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/contracts/schema/automations.json +0 -0
  35. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/contracts/schema/common.json +0 -0
  36. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/contracts/schema/config.json +0 -0
  37. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/contracts/schema/content.json +0 -0
  38. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/contracts/schema/council.json +0 -0
  39. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/contracts/schema/diff.json +0 -0
  40. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/contracts/schema/improve.json +0 -0
  41. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/contracts/schema/provider.json +0 -0
  42. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/contracts/schema/rooms.json +0 -0
  43. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/contracts/schema/symbol.json +0 -0
  44. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/contracts/schema/view.json +0 -0
  45. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/examples/greeter/tests/test_greeter.py +0 -0
  46. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/hatch_build.py +0 -0
  47. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/src/blockether/vis/__init__.py +0 -0
  48. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/src/blockether/vis/_wire.py +0 -0
  49. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/src/blockether/vis/activity.py +0 -0
  50. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/src/blockether/vis/engine/__init__.py +0 -0
  51. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/src/blockether/vis/engine/_agent.py +0 -0
  52. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/src/blockether/vis/engine/_agents.py +0 -0
  53. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/src/blockether/vis/engine/_council.py +0 -0
  54. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/src/blockether/vis/engine/_extensions.py +0 -0
  55. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/src/blockether/vis/engine/_local.py +0 -0
  56. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/src/blockether/vis/py.typed +0 -0
  57. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/src/blockether/vis/rooms.py +0 -0
  58. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/src/blockether/vis/views.py +0 -0
  59. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/tests/conftest.py +0 -0
  60. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/tests/test_activity.py +0 -0
  61. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/tests/test_agent.py +0 -0
  62. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/tests/test_agent_extension_lifecycle.py +0 -0
  63. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/tests/test_agent_extensions.py +0 -0
  64. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/tests/test_agents.py +0 -0
  65. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/tests/test_api_guides.py +0 -0
  66. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/tests/test_authoring.py +0 -0
  67. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/tests/test_catalog.py +0 -0
  68. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/tests/test_catalog_recipe.py +0 -0
  69. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/tests/test_ci_report_recipe.py +0 -0
  70. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/tests/test_client.py +0 -0
  71. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/tests/test_client_extensions_engine.py +0 -0
  72. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/tests/test_code_quality_recipe.py +0 -0
  73. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/tests/test_council.py +0 -0
  74. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/tests/test_declarations.py +0 -0
  75. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/tests/test_engine.py +0 -0
  76. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/tests/test_engine_docstrings.py +0 -0
  77. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/tests/test_extension_reference.py +0 -0
  78. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/tests/test_gateway_performance.py +0 -0
  79. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/tests/test_live_primitives.py +0 -0
  80. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/tests/test_live_wait.py +0 -0
  81. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/tests/test_local.py +0 -0
  82. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/tests/test_method_typing.py +0 -0
  83. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/tests/test_monitor_recipe.py +0 -0
  84. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/tests/test_namespace.py +0 -0
  85. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/tests/test_outside.py +0 -0
  86. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/tests/test_providers.py +0 -0
  87. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/tests/test_record_arguments.py +0 -0
  88. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/tests/test_registration.py +0 -0
  89. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/tests/test_repair_hooks.py +0 -0
  90. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/tests/test_result_sequence.py +0 -0
  91. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/tests/test_rooms.py +0 -0
  92. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/tests/test_sdk_bootstrap.py +0 -0
  93. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/tests/test_sdk_guide.py +0 -0
  94. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/tests/test_sdk_typing.py +0 -0
  95. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/tests/test_structured_live.py +0 -0
  96. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/tests/test_symbol_contract.py +0 -0
  97. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/tests/test_testing.py +0 -0
  98. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/tests/test_tools.py +0 -0
  99. {vis_agent-0.2.35.dev7411 → vis_agent-0.2.37}/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.35.dev7411
3
+ Version: 0.2.37
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
@@ -1779,7 +1779,7 @@
1779
1779
  "objective": {
1780
1780
  "type": "string",
1781
1781
  "minLength": 1,
1782
- "maxLength": 8192,
1782
+ "maxLength": 44000,
1783
1783
  "pattern": "\\S"
1784
1784
  },
1785
1785
  "status": {
@@ -46,7 +46,7 @@
46
46
  "description": {
47
47
  "type": "string",
48
48
  "minLength": 1,
49
- "maxLength": 100,
49
+ "maxLength": 300,
50
50
  "pattern": "^[^\\r\\n]+$"
51
51
  },
52
52
  "default": {},
@@ -8,7 +8,7 @@ build-backend = "hatchling.build"
8
8
 
9
9
  [project]
10
10
  name = "vis-agent"
11
- version = "0.2.35.dev7411"
11
+ version = "0.2.37"
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"
@@ -74,16 +74,62 @@ def _json_value(item):
74
74
  )
75
75
 
76
76
 
77
+ def _problem(error) -> str:
78
+ """One readable line for a schema error: field, rule and limit, never the value."""
79
+ field = ".".join(str(part) for part in error.absolute_path) or "declaration"
80
+ rule, limit = error.validator, error.validator_value
81
+ if rule in ("maxLength", "minLength"):
82
+ bound = "maximum" if rule == "maxLength" else "minimum"
83
+ fix = f" Shorten the {field}." if rule == "maxLength" else ""
84
+ return (
85
+ f"{field} is {len(error.instance)} characters; {bound} is {limit} ({rule})."
86
+ + fix
87
+ )
88
+ if rule == "required":
89
+ missing = ", ".join(key for key in limit if key not in error.instance)
90
+ return f"{missing} is missing (required)."
91
+ if rule == "enum":
92
+ return f"{field} must be one of: {', '.join(map(str, limit))} (enum)."
93
+ if rule == "pattern":
94
+ if (
95
+ limit
96
+ == definition("toggle", "contribution")["properties"]["description"][
97
+ "pattern"
98
+ ]
99
+ ):
100
+ return f"{field} must be one line without line breaks (pattern)."
101
+ return f"{field} does not match the pattern {limit} (pattern)."
102
+ return f"{field} breaks the {rule} rule."
103
+
104
+
105
+ def problems(name: str, definition: str, value: Any) -> list[str]:
106
+ """Readable rule failures of `value`, one line for each. Empty when it is valid.
107
+
108
+ Each line names the field, the rule and its limit. It never echoes the value.
109
+ """
110
+ if not _json_value(value):
111
+ return ["the value is not JSON data."]
112
+ errors = _validator(name, definition).iter_errors(value)
113
+ return [
114
+ _problem(error)
115
+ for error in sorted(errors, key=lambda e: list(map(str, e.absolute_path)))
116
+ ]
117
+
118
+
77
119
  def validate(name: str, definition: str, value: Any) -> Any:
78
120
  """Validate portable JSON data, returning it or raising a payload-free ValueError.
79
121
 
80
- Schemas resolve only from the installed package. Runtime lifecycle, IO and
122
+ The error names each failed field, rule and limit, never the value. Schemas
123
+ resolve only from the installed package. Runtime lifecycle, IO and
81
124
  authorization belong to the engine, not JSON Schema.
82
125
  """
83
126
  try:
84
- valid = _json_value(value) and _validator(name, definition).is_valid(value)
127
+ found = problems(name, definition, value)
85
128
  except (RecursionError, TypeError, ValueError):
86
- valid = False
87
- if not valid:
88
- raise ValueError(f"invalid {name}.{definition} contract")
129
+ found = [""]
130
+ if found:
131
+ detail = " ".join(problem for problem in found if problem)
132
+ raise ValueError(
133
+ f"invalid {name}.{definition} contract" + (f": {detail}" if detail else "")
134
+ )
89
135
  return value
@@ -91,13 +91,20 @@ def workspace_root():
91
91
 
92
92
  def setting_declaration(spec):
93
93
  """Use the package's own JSON Schemas for outside-host declarations."""
94
- from ._contracts import definition, validate
94
+ from ._contracts import definition, problems, validate
95
95
 
96
96
  spec = dict(spec)
97
97
  spec.setdefault(
98
98
  "scopes",
99
99
  definition("toggle", "contribution")["properties"]["scopes"]["default"],
100
100
  )
101
+ found = problems("toggle", "contribution", spec)
102
+ if found:
103
+ name = spec.get("id")
104
+ label = (
105
+ f"Invalid setting {name}" if isinstance(name, str) else "Invalid setting"
106
+ )
107
+ raise ValueError(f"{label}: {' '.join(found)}")
101
108
  return validate("toggle", "contribution", spec)
102
109
 
103
110
 
@@ -3251,8 +3251,8 @@ class Session:
3251
3251
  replies. The last iteration can run its tools, and then the limit blocks
3252
3252
  the next model request. Token usage is recorded only as a statistic.
3253
3253
  """
3254
- if not isinstance(objective, str) or not 1 <= len(objective.strip()) <= 8192:
3255
- raise ValueError("objective must contain 1–8192 characters")
3254
+ if not isinstance(objective, str) or not 1 <= len(objective.strip()) <= 44000:
3255
+ raise ValueError("objective must contain 1–44000 characters")
3256
3256
  if iteration_budget is not None and (
3257
3257
  type(iteration_budget) is not int
3258
3258
  or not 1 <= iteration_budget <= 9007199254740991
@@ -1388,10 +1388,11 @@ def _call_arguments(fn, args, kwargs):
1388
1388
  An argument crosses the host boundary as JSON, so a record arrives as a dict of its
1389
1389
  public fields. A parameter annotated with a dataclass gets that dataclass back,
1390
1390
  built through its constructor. This also works inside a union, list, tuple, set or
1391
- dict annotation (Blockether/vis#289). Annotations resolve statically, as for tool
1392
- contracts. A value that does not fit its annotation stays as it arrived. So does
1393
- every argument of a call that the signature cannot bind: the call itself reports
1394
- that mistake.
1391
+ dict annotation (Blockether/vis#289). One bare string for a sequence of strings
1392
+ becomes one item, not one item for each character (Blockether/vis#324).
1393
+ Annotations resolve statically, as for tool contracts. A value that does not fit
1394
+ its annotation stays as it arrived. So does every argument of a call that the
1395
+ signature cannot bind: the call itself reports that mistake.
1395
1396
  """
1396
1397
  try:
1397
1398
  # A wrapper around a bound method must not unwrap past the binding.
@@ -1561,9 +1562,10 @@ def _holds_record(shape):
1561
1562
  def _rebuilt_value(shape, value):
1562
1563
  """Return `value` with the records that `shape` expects rebuilt.
1563
1564
 
1565
+ One bare string for a sequence of strings becomes that sequence with one item.
1564
1566
  A value that does not fit stays as is.
1565
1567
  """
1566
- if not _holds_record(shape):
1568
+ if not (_holds_record(shape) or isinstance(value, str)):
1567
1569
  return value
1568
1570
  result = _fitted_argument(shape, value)
1569
1571
  return value if result is _UNFIT else result
@@ -1588,7 +1590,11 @@ def _fitted_argument(shape, value):
1588
1590
  if kind == "record":
1589
1591
  return _rebuilt_record(shape[1], value, shape[2])
1590
1592
  if kind == "union":
1591
- for member in shape[1]:
1593
+ members = shape[1]
1594
+ if isinstance(value, str):
1595
+ # A member that takes the string as it is wins over one that wraps it.
1596
+ members = sorted(members, key=lambda member: member[0] == "sequence")
1597
+ for member in members:
1592
1598
  result = _fitted_argument(member, value)
1593
1599
  if result is not _UNFIT:
1594
1600
  return result
@@ -1599,6 +1605,13 @@ def _fitted_argument(shape, value):
1599
1605
  if not _holds_record(shape):
1600
1606
  return value
1601
1607
  return {key: _rebuilt_value(shape[1], item) for key, item in value.items()}
1608
+ if kind == "sequence" and isinstance(value, str):
1609
+ # A string is a sequence of its characters; the caller named one item. An
1610
+ # item of any type also takes the whole string, so it stays as it is.
1611
+ if shape[2] in (("any",), ("plain", object)):
1612
+ return _UNFIT
1613
+ item = _fitted_argument(shape[2], value)
1614
+ return _UNFIT if item is _UNFIT else shape[1]((item,))
1602
1615
  if not isinstance(value, (list, tuple)):
1603
1616
  return _UNFIT
1604
1617
  if kind == "fixed":
@@ -954,6 +954,59 @@ def install(
954
954
  return _admit(source, directory, subdirectory, revision, vis_version, release)
955
955
 
956
956
 
957
+ def _replace_managed(directory, active, current, release, vis_version):
958
+ """Activate a managed version and keep its sync ownership in the same operation."""
959
+ name = current["name"]
960
+ lock = directory / ".sync-lock"
961
+ fd = _acquire(lock)
962
+ try:
963
+ records = _sync_records(directory)
964
+ owned = records.get(name)
965
+ destination = _destination(directory, name)
966
+ if owned is not None and not _sync_owned(destination, owned):
967
+ raise ValueError(
968
+ "Existing extension was changed externally; no files replaced"
969
+ )
970
+ spec = _sync_spec(owned["spec"]) if owned is not None else None
971
+ previous_target = str(active)
972
+ snapshot = destination.parent / release["version"]
973
+ receipt_path = snapshot / "receipt.json"
974
+ previous_receipt = receipt_path.read_bytes() if receipt_path.is_file() else None
975
+ result = _admit(
976
+ current["repository_url"],
977
+ directory,
978
+ current["subdirectory"],
979
+ release["revision"],
980
+ vis_version,
981
+ release,
982
+ name,
983
+ active.name,
984
+ )
985
+ if spec is None:
986
+ return result
987
+ # Configuration pins remain authoritative on the next reconciliation.
988
+ if spec["revision"] is not None:
989
+ spec["revision"] = result["revision"]
990
+ elif spec["version"] not in (None, LATEST):
991
+ spec["version"] = result["version"]
992
+ try:
993
+ _save_sync_records(
994
+ directory,
995
+ {
996
+ **records,
997
+ name: {"spec": spec, "target": result["path"], "result": result},
998
+ },
999
+ )
1000
+ except BaseException:
1001
+ _restore_saved_link(directory, name, result["path"], previous_target)
1002
+ if previous_receipt is not None:
1003
+ receipt_path.write_bytes(previous_receipt)
1004
+ raise
1005
+ return result
1006
+ finally:
1007
+ _release(lock, fd)
1008
+
1009
+
957
1010
  def update(
958
1011
  source, directory, trust=False, version=None, vis_version=None, subdirectory=None
959
1012
  ):
@@ -988,16 +1041,7 @@ def update(
988
1041
  }
989
1042
  if Version(release["version"]) == Version(current["version"]):
990
1043
  raise ValueError("A published version cannot change its approved commit")
991
- return _admit(
992
- current["repository_url"],
993
- directory,
994
- current["subdirectory"],
995
- release["revision"],
996
- vis_version,
997
- release,
998
- name,
999
- active.name,
1000
- )
1044
+ return _replace_managed(directory, active, current, release, vis_version)
1001
1045
 
1002
1046
 
1003
1047
  def rollback(
@@ -1016,7 +1060,6 @@ def rollback(
1016
1060
  folder = None
1017
1061
  active, current = _installed_repository(directory, repository, folder)
1018
1062
  assert active is not None and current is not None # A required lookup raises.
1019
- name = current["name"]
1020
1063
  if version is not None:
1021
1064
  release = _select(
1022
1065
  _releases(current["repository_url"], current["subdirectory"]), version
@@ -1031,24 +1074,15 @@ def rollback(
1031
1074
  raise ValueError(
1032
1075
  "No previous installation; choose an approved older --version"
1033
1076
  )
1034
- release = _receipt(active.parent / _version(previous), name)
1077
+ release = _receipt(active.parent / _version(previous), current["name"])
1035
1078
  if (
1036
1079
  release.get("repository_url") != current["repository_url"]
1037
1080
  or release.get("subdirectory") != current["subdirectory"]
1038
- or release.get("name") != name
1081
+ or release.get("name") != current["name"]
1039
1082
  or not re.fullmatch(r"[0-9a-f]{40}", release.get("revision", ""))
1040
1083
  ):
1041
1084
  raise ValueError("Invalid previous package receipt")
1042
- return _admit(
1043
- current["repository_url"],
1044
- directory,
1045
- current["subdirectory"],
1046
- release["revision"],
1047
- vis_version,
1048
- release,
1049
- name,
1050
- active.name,
1051
- )
1085
+ return _replace_managed(directory, active, current, release, vis_version)
1052
1086
 
1053
1087
 
1054
1088
  def _sync_spec(spec):
@@ -178,6 +178,12 @@ def test_payload_free_validation():
178
178
  with pytest.raises(ValueError) as error:
179
179
  _contracts.validate("activity", "declaration", bad)
180
180
  assert "do-not-echo" not in str(error.value)
181
+ with pytest.raises(
182
+ ValueError, match=r": label does not match the pattern .* \(pattern\)"
183
+ ):
184
+ _contracts.validate(
185
+ "activity", "declaration", {"presenter": "tests", "label": "one\ntwo"}
186
+ )
181
187
  with pytest.raises(ValueError):
182
188
  _contracts.validate("view", "missing-definition", {})
183
189
 
@@ -1075,6 +1075,107 @@ def test_sync_pins_updates_refresh_and_rollback(releases):
1075
1075
  )
1076
1076
 
1077
1077
 
1078
+ def test_explicit_update_and_rollback_keep_sync_ownership(releases):
1079
+ metadata, target, _ = releases
1080
+ newer = metadata.pop(1)
1081
+ configured = {
1082
+ "vis-greeter": {"source": REPOSITORY, "subdirectory": "plugins/greeting"}
1083
+ }
1084
+ package.sync(configured, target, trust=True)
1085
+ metadata.append(newer)
1086
+ package.update(REPOSITORY, target, trust=True)
1087
+ updated = package.sync(configured, target, trust=True)[0]
1088
+ assert (updated["status"], updated.get("version")) == ("cached", "1.1.0")
1089
+ package.rollback(REPOSITORY, target, trust=True)
1090
+ restored = package.sync(configured, target, trust=True)[0]
1091
+ assert (restored["status"], restored.get("version")) == ("cached", "1.0.0")
1092
+
1093
+
1094
+ @pytest.mark.parametrize("operation", ["update", "rollback"])
1095
+ def test_managed_activation_restores_ownership_when_sync_receipt_write_fails(
1096
+ releases, monkeypatch, operation
1097
+ ):
1098
+ metadata, target, _ = releases
1099
+ newer = metadata.pop(1)
1100
+ configured = {
1101
+ "vis-greeter": {"source": REPOSITORY, "subdirectory": "plugins/greeting"}
1102
+ }
1103
+ package.sync(configured, target, trust=True)
1104
+ metadata.append(newer)
1105
+ if operation == "rollback":
1106
+ package.update(REPOSITORY, target, trust=True)
1107
+ pointer = target / "vis-greeter/current"
1108
+ previous_target = pointer.resolve()
1109
+ previous_records = (target / ".sync.json").read_bytes()
1110
+ receipt_path = target / "vis-greeter/1.0.0/receipt.json"
1111
+ previous_receipt = receipt_path.read_bytes()
1112
+
1113
+ def fail(*_):
1114
+ raise OSError("Fixture receipt write failure")
1115
+
1116
+ monkeypatch.setattr(package, "_save_sync_records", fail)
1117
+ with pytest.raises(OSError, match="Fixture receipt write failure"):
1118
+ getattr(package, operation)(REPOSITORY, target, trust=True)
1119
+ assert pointer.resolve() == previous_target
1120
+ assert (target / ".sync.json").read_bytes() == previous_records
1121
+ assert receipt_path.read_bytes() == previous_receipt
1122
+ assert package._sync_owned(pointer, package._sync_records(target)["vis-greeter"])
1123
+ assert package.sync(configured, target, trust=True)[0]["status"] == "cached"
1124
+
1125
+
1126
+ @pytest.mark.parametrize("operation", ["update", "rollback"])
1127
+ def test_managed_activation_refuses_an_externally_changed_sync_pointer(
1128
+ releases, operation
1129
+ ):
1130
+ metadata, target, _ = releases
1131
+ newer = metadata.pop(1)
1132
+ configured = {
1133
+ "vis-greeter": {"source": REPOSITORY, "subdirectory": "plugins/greeting"}
1134
+ }
1135
+ package.sync(configured, target, trust=True)
1136
+ metadata.append(newer)
1137
+ package.update(REPOSITORY, target, trust=True)
1138
+ pointer = target / "vis-greeter/current"
1139
+ pointer.unlink()
1140
+ pointer.symlink_to("1.0.0")
1141
+ before = (target / ".sync.json").read_bytes()
1142
+ kwargs = {"version": "1.1.0"} if operation == "update" else {}
1143
+ with pytest.raises(ValueError):
1144
+ getattr(package, operation)(REPOSITORY, target, trust=True, **kwargs)
1145
+ assert pointer.resolve().name == "1.0.0"
1146
+ assert (target / ".sync.json").read_bytes() == before
1147
+
1148
+
1149
+ @pytest.mark.parametrize("pin", ["version", "revision"])
1150
+ def test_explicit_update_does_not_override_a_configuration_pin(releases, pin):
1151
+ metadata, target, _ = releases
1152
+ spec = {"source": REPOSITORY, "subdirectory": "plugins/greeting"}
1153
+ spec[pin] = metadata[0][pin]
1154
+ configured = {"vis-greeter": spec}
1155
+ package.sync(configured, target, trust=True)
1156
+ package.update(REPOSITORY, target, trust=True)
1157
+ restored = package.sync(configured, target, trust=True)[0]
1158
+ assert (restored["status"], restored.get("version")) == ("updated", "1.0.0")
1159
+
1160
+
1161
+ def test_update_validates_sync_spec_before_changing_the_active_version(releases):
1162
+ metadata, target, _ = releases
1163
+ newer = metadata.pop(1)
1164
+ configured = {
1165
+ "vis-greeter": {"source": REPOSITORY, "subdirectory": "plugins/greeting"}
1166
+ }
1167
+ package.sync(configured, target, trust=True)
1168
+ metadata.append(newer)
1169
+ records = package._sync_records(target)
1170
+ records["vis-greeter"]["spec"] = {}
1171
+ package._save_sync_records(target, records)
1172
+ before = (target / ".sync.json").read_bytes()
1173
+ with pytest.raises(ValueError, match="source"):
1174
+ package.update(REPOSITORY, target, trust=True)
1175
+ assert (target / "vis-greeter/current").resolve().name == "1.0.0"
1176
+ assert (target / ".sync.json").read_bytes() == before
1177
+
1178
+
1078
1179
  def test_sync_latest_tracks_approved_stable_releases(releases):
1079
1180
  metadata, target, commands = releases
1080
1181
  newer = metadata.pop(1)
@@ -40,7 +40,7 @@ def test_goal_uses_slash_turn(budget):
40
40
  [
41
41
  ("", None),
42
42
  (" ", None),
43
- ("x" * 8193, None),
43
+ ("x" * 44001, None),
44
44
  (None, None),
45
45
  ("work", 0),
46
46
  ("work", -1),
@@ -68,6 +68,32 @@ def test_parent_nests_a_setting():
68
68
  vis.Setting(id="test_detail", label="Test detail", default=False, parent=" ")
69
69
 
70
70
 
71
+ # Regression for #322 and #323: a 300-character description is valid, and a
72
+ # longer one names the setting, the field, the rule and the limit.
73
+ def test_description_limit_and_actionable_error():
74
+ def mode(description):
75
+ return vis.Setting(
76
+ id="steering_mode",
77
+ label="Steering mode",
78
+ type="enum",
79
+ choices=["vibe", "control"],
80
+ default="vibe",
81
+ description=description,
82
+ )
83
+
84
+ assert mode("x" * 300).description == "x" * 300
85
+ with pytest.raises(ValueError) as error:
86
+ mode("x" * 301)
87
+ assert str(error.value) == (
88
+ "Invalid setting steering_mode: description is 301 characters; "
89
+ "maximum is 300 (maxLength). Shorten the description."
90
+ )
91
+ with pytest.raises(ValueError, match="one line without line breaks"):
92
+ mode("two\nlines")
93
+ with pytest.raises(ValueError, match="minimum is 1"):
94
+ mode("")
95
+
96
+
71
97
  def test_application_bridge_rejects_host_only_settings_before_connecting():
72
98
  declaration = vis.Extension(
73
99
  name="Settings test",
@@ -0,0 +1,89 @@
1
+ """Regression coverage for #324: one bare string for a list of strings is one item."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from pathlib import Path
7
+ from typing import Annotated, Literal, Optional
8
+
9
+ import blockether.vis.extension as vis
10
+
11
+
12
+ class Tools:
13
+ """The parameter shapes of the `py.*` and `clj.*` language tools."""
14
+
15
+ def lint_code(
16
+ self,
17
+ paths: Annotated[Sequence[str], "Files or directories to lint."] = (),
18
+ *,
19
+ cwd: str = "",
20
+ namespaces: Annotated[Sequence[str], "Test namespaces to run."] = (),
21
+ ) -> tuple:
22
+ return tuple(paths), tuple(namespaces)
23
+
24
+
25
+ def rebuilt(fn, *args, **kwargs):
26
+ return vis._call_arguments(fn, list(args), kwargs)
27
+
28
+
29
+ def test_bare_string_for_a_string_sequence_is_one_item():
30
+ # Regression for Blockether/vis#324: `py.lint_code("/src/app.py")` named one
31
+ # path for each character, and `/` made the lint walk the whole filesystem.
32
+ tools = Tools()
33
+ args, kwargs = rebuilt(tools.lint_code, "/src/app.py", namespaces="my.ns-test")
34
+ assert args == [["/src/app.py"]]
35
+ assert kwargs == {"namespaces": ["my.ns-test"]}
36
+ assert tools.lint_code(*args, **kwargs) == (("/src/app.py",), ("my.ns-test",))
37
+ assert rebuilt(tools.lint_code, paths="a.py")[1] == {"paths": ["a.py"]}
38
+
39
+
40
+ def test_lists_and_other_parameters_stay_as_they_arrived():
41
+ tools = Tools()
42
+ paths = ["a.py", "b.py"]
43
+ args, kwargs = rebuilt(tools.lint_code, paths, cwd="/src")
44
+ assert args[0] is paths
45
+ assert kwargs == {"cwd": "/src"}
46
+
47
+
48
+ def test_container_and_item_annotations_decide_the_shape():
49
+ def files(
50
+ paths: list[str], tags: set[str] = frozenset(), pair: tuple[str, ...] = ()
51
+ ):
52
+ return paths, tags, pair
53
+
54
+ def optional(paths: Optional[list[str]] = None, more: list[str] | None = None): # noqa: UP045
55
+ return paths, more
56
+
57
+ def literal(modes: list[Literal["fast", "full"]] = ()):
58
+ return modes
59
+
60
+ assert rebuilt(files, "a.py", tags="x", pair="y") == (
61
+ [["a.py"]],
62
+ {"tags": {"x"}, "pair": ("y",)},
63
+ )
64
+ assert rebuilt(optional, "a.py", more="b.py") == ([["a.py"]], {"more": ["b.py"]})
65
+ assert rebuilt(literal, "fast") == ([["fast"]], {})
66
+ assert rebuilt(literal, "other") == (["other"], {})
67
+
68
+
69
+ def test_a_member_that_takes_the_string_as_it_is_wins():
70
+ def either(paths: list[str] | str = (), path: str | list[str] = ""):
71
+ return paths, path
72
+
73
+ def anything(
74
+ values: list = (), items: Sequence[object] = (), raw: list[bytes] = ()
75
+ ):
76
+ return values, items, raw
77
+
78
+ assert rebuilt(either, "a.py", path="b.py") == (["a.py"], {"path": "b.py"})
79
+ assert rebuilt(anything, "ab", items="cd", raw="ef") == (
80
+ ["ab"],
81
+ {"items": "cd", "raw": "ef"},
82
+ )
83
+
84
+
85
+ def test_path_items_take_a_bare_string_unchanged():
86
+ def walk(paths: list[str | Path] = ()):
87
+ return paths
88
+
89
+ assert rebuilt(walk, "src") == ([["src"]], {})
File without changes
File without changes