agent-first-data 0.34.0__tar.gz → 0.35.1__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 (19) hide show
  1. {agent_first_data-0.34.0 → agent_first_data-0.35.1}/PKG-INFO +3 -3
  2. {agent_first_data-0.34.0 → agent_first_data-0.35.1}/README.md +2 -2
  3. {agent_first_data-0.34.0 → agent_first_data-0.35.1}/agent_first_data/cli.py +22 -4
  4. {agent_first_data-0.34.0 → agent_first_data-0.35.1}/agent_first_data/format.py +87 -26
  5. {agent_first_data-0.34.0 → agent_first_data-0.35.1}/agent_first_data.egg-info/PKG-INFO +3 -3
  6. {agent_first_data-0.34.0 → agent_first_data-0.35.1}/pyproject.toml +1 -1
  7. {agent_first_data-0.34.0 → agent_first_data-0.35.1}/tests/test_cli.py +25 -0
  8. {agent_first_data-0.34.0 → agent_first_data-0.35.1}/agent_first_data/__init__.py +0 -0
  9. {agent_first_data-0.34.0 → agent_first_data-0.35.1}/agent_first_data/assets/cli-help-v2.schema.json +0 -0
  10. {agent_first_data-0.34.0 → agent_first_data-0.35.1}/agent_first_data/assets/cli-spec-v1.schema.json +0 -0
  11. {agent_first_data-0.34.0 → agent_first_data-0.35.1}/agent_first_data/assets/protocol-v1.schema.json +0 -0
  12. {agent_first_data-0.34.0 → agent_first_data-0.35.1}/agent_first_data/assets/registry.json +0 -0
  13. {agent_first_data-0.34.0 → agent_first_data-0.35.1}/agent_first_data.egg-info/SOURCES.txt +0 -0
  14. {agent_first_data-0.34.0 → agent_first_data-0.35.1}/agent_first_data.egg-info/dependency_links.txt +0 -0
  15. {agent_first_data-0.34.0 → agent_first_data-0.35.1}/agent_first_data.egg-info/top_level.txt +0 -0
  16. {agent_first_data-0.34.0 → agent_first_data-0.35.1}/setup.cfg +0 -0
  17. {agent_first_data-0.34.0 → agent_first_data-0.35.1}/tests/test_format.py +0 -0
  18. {agent_first_data-0.34.0 → agent_first_data-0.35.1}/tests/test_no_stderr_policy.py +0 -0
  19. {agent_first_data-0.34.0 → agent_first_data-0.35.1}/tests/test_requires_python_floor.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-first-data
3
- Version: 0.34.0
3
+ Version: 0.35.1
4
4
  Summary: A naming convention that lets AI agents understand your data without being told what it means, plus a CLI and library for reading Markdown structure and safely editing structured JSON, TOML, YAML, dotenv, and INI documents.
5
5
  License-Expression: MIT
6
6
  Project-URL: Repository, https://github.com/agentfirstkit/agent-first-data
@@ -33,11 +33,11 @@ Useful names use Python casing: `render` (the single value x format x options ->
33
33
  ## Behavior Notes
34
34
 
35
35
  - Default redaction replaces every `_secret` or configured secret-name subtree with `***`, including objects and arrays.
36
- - `_url` fields scrub userinfo passwords and secret-named query parameters; surrounding whitespace is trimmed and internal whitespace redacts the whole field.
36
+ - `_url` fields scrub userinfo passwords and secret-named query parameters; surrounding whitespace is trimmed, and internal whitespace or a `://` that is not the start of a clean URL redacts the whole field, where whitespace is the explicit Unicode `White_Space` set the spec enumerates, not the language's native predicate.
37
37
  - `OutputOptions.url_names` applies URL treatment to exact legacy field names
38
38
  and recurses through collections. `redact_urls_in_text` is explicit and scans
39
39
  only complete scheme URLs, never arbitrary prose secrets.
40
- - YAML keeps original keys and values (structure-preserving, like JSON), sorting keys by UTF-16 code unit order and quoting/escaping unsafe keys and string scalars. Plain strips formatting suffixes, formats values, sorts the same way, and renders nested objects/arrays as canonical JSON.
40
+ - YAML keeps original keys and values (structure-preserving, like JSON), sorting keys by UTF-16 code unit order and quoting/escaping unsafe keys and string scalars. Plain strips formatting suffixes, formats values, sorts the same way, and renders nested objects/arrays as canonical JSON; it quotes a value only when it contains ASCII space, tab, newline, carriage return, form feed, vertical tab, NBSP, `=`, `"`, or `\`, and escapes `\`, `"`, newline, carriage return, tab, form feed, and vertical tab inside the quotes.
41
41
  - Logging records use `kind:"log"` with a nested `log` payload and a separate `level` field, so error-level logs are not terminal protocol errors.
42
42
  - `build_cli_error(message, hint?)` returns a strict-ready CLI error with `error.retryable:false` and `trace:{}`.
43
43
  - Do not add a raw version/help pre-parser. Until the Python `CliSpec` compiler lands, applications should keep lifecycle parsing in their own parser rather than claim AFDATA closed-world help compatibility.
@@ -24,11 +24,11 @@ Useful names use Python casing: `render` (the single value x format x options ->
24
24
  ## Behavior Notes
25
25
 
26
26
  - Default redaction replaces every `_secret` or configured secret-name subtree with `***`, including objects and arrays.
27
- - `_url` fields scrub userinfo passwords and secret-named query parameters; surrounding whitespace is trimmed and internal whitespace redacts the whole field.
27
+ - `_url` fields scrub userinfo passwords and secret-named query parameters; surrounding whitespace is trimmed, and internal whitespace or a `://` that is not the start of a clean URL redacts the whole field, where whitespace is the explicit Unicode `White_Space` set the spec enumerates, not the language's native predicate.
28
28
  - `OutputOptions.url_names` applies URL treatment to exact legacy field names
29
29
  and recurses through collections. `redact_urls_in_text` is explicit and scans
30
30
  only complete scheme URLs, never arbitrary prose secrets.
31
- - YAML keeps original keys and values (structure-preserving, like JSON), sorting keys by UTF-16 code unit order and quoting/escaping unsafe keys and string scalars. Plain strips formatting suffixes, formats values, sorts the same way, and renders nested objects/arrays as canonical JSON.
31
+ - YAML keeps original keys and values (structure-preserving, like JSON), sorting keys by UTF-16 code unit order and quoting/escaping unsafe keys and string scalars. Plain strips formatting suffixes, formats values, sorts the same way, and renders nested objects/arrays as canonical JSON; it quotes a value only when it contains ASCII space, tab, newline, carriage return, form feed, vertical tab, NBSP, `=`, `"`, or `\`, and escapes `\`, `"`, newline, carriage return, tab, form feed, and vertical tab inside the quotes.
32
32
  - Logging records use `kind:"log"` with a nested `log` payload and a separate `level` field, so error-level logs are not terminal protocol errors.
33
33
  - `build_cli_error(message, hint?)` returns a strict-ready CLI error with `error.retryable:false` and `trace:{}`.
34
34
  - Do not add a raw version/help pre-parser. Until the Python `CliSpec` compiler lands, applications should keep lifecycle parsing in their own parser rather than claim AFDATA closed-world help compatibility.
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import enum
6
+ import os
6
7
  import sys
7
8
  from typing import Any, Iterator
8
9
 
@@ -298,10 +299,27 @@ class CliEmitter:
298
299
  # mode (no diagnostic sink) keeps every event on the single writer.
299
300
  use_diagnostic = kind != "result" and self._diagnostic is not None
300
301
  sink = self._diagnostic if use_diagnostic else self._writer
301
- sink.write(render(envelope, self._format, options=self._output_options) + "\n")
302
- flush = getattr(sink, "flush", None)
303
- if flush is not None:
304
- flush()
302
+ try:
303
+ sink.write(render(envelope, self._format, options=self._output_options) + "\n")
304
+ flush = getattr(sink, "flush", None)
305
+ if flush is not None:
306
+ flush()
307
+ except BrokenPipeError:
308
+ # A failed flush leaves buffered bytes for interpreter shutdown to
309
+ # retry. Redirect the closed descriptor before finish returns 0.
310
+ fileno = getattr(sink, "fileno", None)
311
+ if fileno is not None:
312
+ try:
313
+ descriptor = fileno()
314
+ except (OSError, ValueError):
315
+ descriptor = None
316
+ if descriptor is not None:
317
+ null_descriptor = os.open(os.devnull, os.O_WRONLY)
318
+ try:
319
+ os.dup2(null_descriptor, descriptor)
320
+ finally:
321
+ os.close(null_descriptor)
322
+ raise
305
323
  if kind in ("result", "error"):
306
324
  self._terminal_emitted = True
307
325
 
@@ -1117,7 +1117,7 @@ def _redact_url_value(value: Any, context: _RedactionContext, depth: int) -> Any
1117
1117
  # ═══════════════════════════════════════════
1118
1118
 
1119
1119
 
1120
- def _redact_url_in_str(s: str, context: _RedactionContext) -> str | None:
1120
+ def _redact_url_in_str(s: str, context: _RedactionContext, depth: int = 0) -> str | None:
1121
1121
  """Redact secret components of a single URL string.
1122
1122
 
1123
1123
  Returns the redacted string when ``s`` is a processable URL, or None when it
@@ -1127,6 +1127,9 @@ def _redact_url_in_str(s: str, context: _RedactionContext) -> str | None:
1127
1127
  # Fast path + precondition: a single, whitespace-free, scheme-prefixed URL.
1128
1128
  if "://" not in s or not _is_single_url(s):
1129
1129
  return None
1130
+ # Bound recursive redirect inspection, failing closed before stack growth.
1131
+ if depth >= 16:
1132
+ return REDACTED_MARKER
1130
1133
  scheme_sep = s.find("://")
1131
1134
  scheme = s[:scheme_sep]
1132
1135
  rest = s[scheme_sep + 3 :]
@@ -1137,9 +1140,29 @@ def _redact_url_in_str(s: str, context: _RedactionContext) -> str | None:
1137
1140
  if c in "/?#":
1138
1141
  auth_end = i
1139
1142
  break
1143
+ # An unencoded '?' or '#' inside a password ends that authority early and
1144
+ # leaves the rest of the password in the query or fragment. When a ':'
1145
+ # precedes that early end and an '@' follows it before the first '/', the
1146
+ # userinfo runs to that '@' — how libpq reads such a connection string.
1147
+ first_slash = rest.find("/")
1148
+ if first_slash == -1:
1149
+ first_slash = len(rest)
1150
+ at = rest.rfind("@", 0, first_slash)
1151
+ if ":" in rest[:auth_end] and at > auth_end:
1152
+ auth_end = len(rest)
1153
+ for i in range(at, len(rest)):
1154
+ if rest[i] in "/?#":
1155
+ auth_end = i
1156
+ break
1140
1157
  authority = rest[:auth_end]
1141
1158
  remainder = rest[auth_end:]
1142
1159
 
1160
+ # An unencoded '/' inside a password ends the authority at a "port" that
1161
+ # is not a number, with the '@' somewhere after it. Where the password
1162
+ # ends cannot be known, so the whole value is replaced.
1163
+ if "@" not in authority and _has_non_numeric_port(authority) and "@" in remainder:
1164
+ return REDACTED_MARKER
1165
+
1143
1166
  new_authority = _redact_userinfo_password(authority)
1144
1167
 
1145
1168
  # `remainder` is `path[?query][#fragment]`; '#' ends the query, so split the
@@ -1147,7 +1170,7 @@ def _redact_url_in_str(s: str, context: _RedactionContext) -> str | None:
1147
1170
  before_fragment, hash_sep, fragment = remainder.partition("#")
1148
1171
  path, question_sep, query = before_fragment.partition("?")
1149
1172
  if question_sep:
1150
- new_before_fragment = f"{path}?{_redact_query(query, context)}"
1173
+ new_before_fragment = f"{path}?{_redact_query(query, context, depth)}"
1151
1174
  else:
1152
1175
  new_before_fragment = before_fragment
1153
1176
  # A fragment gets the same treatment as the query: `k=v&k=v` after the '#'
@@ -1155,7 +1178,19 @@ def _redact_url_in_str(s: str, context: _RedactionContext) -> str | None:
1155
1178
  # secret-named fragment parameter must not survive where the identically
1156
1179
  # named query parameter would not. A fragment that is not in that shape has
1157
1180
  # no '=' in its segments and passes through byte-for-byte.
1158
- new_fragment = f"#{_redact_query(fragment, context)}" if hash_sep else ""
1181
+ # A hash route (`#/cb?k=v`) carries its parameters after its first '?',
1182
+ # while a form reader keeps that '?' inside a value; redact both readings:
1183
+ # the fragment as parameters first, so a secret value absorbs its '?',
1184
+ # then whatever follows the first '?' that survives.
1185
+ if not hash_sep:
1186
+ new_fragment = ""
1187
+ else:
1188
+ params = _redact_query(fragment, context, depth)
1189
+ route, route_sep, route_query = params.partition("?")
1190
+ if route_sep:
1191
+ new_fragment = f"#{route}?{_redact_query(route_query, context, depth)}"
1192
+ else:
1193
+ new_fragment = f"#{params}"
1159
1194
 
1160
1195
  return f"{scheme}://{new_authority}{new_before_fragment}{new_fragment}"
1161
1196
 
@@ -1273,17 +1308,19 @@ def _redact_url_field_value(s: str, context: _RedactionContext) -> str:
1273
1308
  redacted = _redact_url_in_str(s, context)
1274
1309
  if redacted is not None:
1275
1310
  return redacted
1276
- trimmed = s.strip()
1311
+ trimmed = s.strip("".join(_SPAN_TERMINATOR_WHITESPACE))
1277
1312
  if trimmed != s:
1278
1313
  redacted = _redact_url_in_str(trimmed, context)
1279
1314
  if redacted is not None:
1280
1315
  return redacted
1281
1316
  # Fail closed: a _url value we could not parse as a clean scheme-prefixed
1282
- # URL, yet which carries a credential sigil ('@' userinfo) or internal
1283
- # whitespace, is redacted wholesale rather than passed through. A schemeless
1284
- # connection string like user:pass@host/db has no scheme anchor for the
1285
- # surgical span logic above, so blanket redaction is the safe default.
1286
- if any(c.isspace() for c in s) or "@" in s:
1317
+ # URL, yet which carries a credential sigil ('@' userinfo), internal
1318
+ # whitespace, or a '://' scheme separator, is redacted wholesale rather than
1319
+ # passed through. A schemeless connection string like user:pass@host/db
1320
+ # has no scheme anchor for the surgical span logic above, and a URL behind
1321
+ # a non-whitespace prefix (a BOM, a zero-width space, a label) has one the
1322
+ # span logic cannot reach, so blanket redaction is the safe default.
1323
+ if any(_is_span_terminator_whitespace(c) for c in s) or "@" in s or "://" in s:
1287
1324
  return REDACTED_MARKER
1288
1325
  return s
1289
1326
 
@@ -1304,7 +1341,7 @@ def _redact_userinfo_password(authority: str) -> str:
1304
1341
  return f"{authority[:colon]}:{REDACTED_MARKER}{authority[at:]}"
1305
1342
 
1306
1343
 
1307
- def _redact_query(query: str, context: _RedactionContext) -> str:
1344
+ def _redact_query(query: str, context: _RedactionContext, depth: int) -> str:
1308
1345
  """Redact the values of secret-named query parameters.
1309
1346
 
1310
1347
  Preserves the raw bytes of every other segment (keys, benign values,
@@ -1322,11 +1359,33 @@ def _redact_query(query: str, context: _RedactionContext) -> str:
1322
1359
  name = unquote_plus(raw_key)
1323
1360
  if context.is_secret_key(name):
1324
1361
  segments.append(f"{raw_key}={REDACTED_MARKER}")
1362
+ continue
1363
+ # A redirect target carries its own parameters: redact a nested URL in
1364
+ # place, or the whole value when it arrived encoded.
1365
+ raw_value = segment[eq + 1 :]
1366
+ value = unquote_plus(raw_value)
1367
+ redacted = _redact_url_in_str(value, context, depth + 1)
1368
+ if redacted is not None and redacted != value:
1369
+ if value == raw_value:
1370
+ segments.append(f"{raw_key}={redacted}")
1371
+ else:
1372
+ segments.append(f"{raw_key}={REDACTED_MARKER}")
1325
1373
  else:
1326
1374
  segments.append(segment)
1327
1375
  return "&".join(segments)
1328
1376
 
1329
1377
 
1378
+ def _has_non_numeric_port(authority: str) -> bool:
1379
+ """True when the authority's text after its last ':' is not a port number.
1380
+
1381
+ A bracketed IPv6 literal without a port ends in ']' and has none.
1382
+ """
1383
+ colon = authority.rfind(":")
1384
+ if colon == -1 or authority.endswith("]"):
1385
+ return False
1386
+ return not all("0" <= c <= "9" for c in authority[colon + 1 :])
1387
+
1388
+
1330
1389
  def _is_single_url(s: str) -> bool:
1331
1390
  """True when ``s`` is a single bare URL, not a URL embedded in prose.
1332
1391
 
@@ -1423,20 +1482,23 @@ def _as_int(value: Any) -> int | None:
1423
1482
  return None
1424
1483
 
1425
1484
 
1426
- def _as_non_neg_int(value: Any) -> int | None:
1427
- n = _as_int(value)
1428
- if n is not None and n >= 0:
1485
+ def _raw_as_int(value: Any) -> int | None:
1486
+ if isinstance(value, _RawNumber):
1487
+ if re.fullmatch(r"-?\d+", value.literal):
1488
+ return int(value.literal)
1489
+ return _as_int(float(value.literal))
1490
+ return _as_int(value)
1491
+
1492
+
1493
+ def _as_u64(value: Any) -> int | None:
1494
+ n = _raw_as_int(value)
1495
+ if n is not None and 0 <= n <= 18_446_744_073_709_551_615:
1429
1496
  return n
1430
1497
  return None
1431
1498
 
1432
1499
 
1433
1500
  def _as_i64(value: Any) -> int | None:
1434
- if isinstance(value, _RawNumber):
1435
- if not re.fullmatch(r"-?\d+", value.literal):
1436
- return None
1437
- n = int(value.literal)
1438
- else:
1439
- n = _as_int(value)
1501
+ n = _raw_as_int(value)
1440
1502
  if n is not None and -9_223_372_036_854_775_808 <= n <= 9_223_372_036_854_775_807:
1441
1503
  return n
1442
1504
  return None
@@ -1575,7 +1637,7 @@ def _try_process_field(key: str, value: Any) -> tuple[str, str] | None:
1575
1637
  return None
1576
1638
  stripped = _strip_suffix_ci(key, "_bytes")
1577
1639
  if stripped is not None:
1578
- n = _as_non_neg_int(numeric)
1640
+ n = _as_u64(value)
1579
1641
  if n is not None:
1580
1642
  return stripped, _format_bytes_human(n)
1581
1643
  return None
@@ -1840,11 +1902,10 @@ def _yaml_key(key: str) -> str:
1840
1902
  def _is_ambiguous_yaml_key(key: str) -> bool:
1841
1903
  if key.lower() in {"true", "false", "null", "~", ".nan", ".inf", "+.inf", "-.inf"}:
1842
1904
  return True
1843
- try:
1844
- float(key)
1845
- return True
1846
- except ValueError:
1847
- return False
1905
+ unsigned = key[1:] if key.startswith(("+", "-")) else key
1906
+ return unsigned.lower() in {"inf", "infinity", "nan"} or re.fullmatch(
1907
+ r"[-+]?(?:[0-9]+\.?[0-9]*|\.[0-9]+)(?:[eE][-+]?[0-9]+)?", key
1908
+ ) is not None
1848
1909
 
1849
1910
 
1850
1911
  def _yaml_scalar(value: Any) -> str:
@@ -1945,7 +2006,7 @@ def _plain_scalar_raw(value: Any) -> str:
1945
2006
  def _quote_logfmt_value(value: str) -> str:
1946
2007
  if value == "":
1947
2008
  return ""
1948
- needs_quote = any(c.isspace() or c in '="\\' for c in value)
2009
+ needs_quote = any(c in ' \t\n\r\f\v\u00a0="\\' for c in value)
1949
2010
  if not needs_quote:
1950
2011
  return value
1951
2012
  escaped = (
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-first-data
3
- Version: 0.34.0
3
+ Version: 0.35.1
4
4
  Summary: A naming convention that lets AI agents understand your data without being told what it means, plus a CLI and library for reading Markdown structure and safely editing structured JSON, TOML, YAML, dotenv, and INI documents.
5
5
  License-Expression: MIT
6
6
  Project-URL: Repository, https://github.com/agentfirstkit/agent-first-data
@@ -33,11 +33,11 @@ Useful names use Python casing: `render` (the single value x format x options ->
33
33
  ## Behavior Notes
34
34
 
35
35
  - Default redaction replaces every `_secret` or configured secret-name subtree with `***`, including objects and arrays.
36
- - `_url` fields scrub userinfo passwords and secret-named query parameters; surrounding whitespace is trimmed and internal whitespace redacts the whole field.
36
+ - `_url` fields scrub userinfo passwords and secret-named query parameters; surrounding whitespace is trimmed, and internal whitespace or a `://` that is not the start of a clean URL redacts the whole field, where whitespace is the explicit Unicode `White_Space` set the spec enumerates, not the language's native predicate.
37
37
  - `OutputOptions.url_names` applies URL treatment to exact legacy field names
38
38
  and recurses through collections. `redact_urls_in_text` is explicit and scans
39
39
  only complete scheme URLs, never arbitrary prose secrets.
40
- - YAML keeps original keys and values (structure-preserving, like JSON), sorting keys by UTF-16 code unit order and quoting/escaping unsafe keys and string scalars. Plain strips formatting suffixes, formats values, sorts the same way, and renders nested objects/arrays as canonical JSON.
40
+ - YAML keeps original keys and values (structure-preserving, like JSON), sorting keys by UTF-16 code unit order and quoting/escaping unsafe keys and string scalars. Plain strips formatting suffixes, formats values, sorts the same way, and renders nested objects/arrays as canonical JSON; it quotes a value only when it contains ASCII space, tab, newline, carriage return, form feed, vertical tab, NBSP, `=`, `"`, or `\`, and escapes `\`, `"`, newline, carriage return, tab, form feed, and vertical tab inside the quotes.
41
41
  - Logging records use `kind:"log"` with a nested `log` payload and a separate `level` field, so error-level logs are not terminal protocol errors.
42
42
  - `build_cli_error(message, hint?)` returns a strict-ready CLI error with `error.retryable:false` and `trace:{}`.
43
43
  - Do not add a raw version/help pre-parser. Until the Python `CliSpec` compiler lands, applications should keep lifecycle parsing in their own parser rather than claim AFDATA closed-world help compatibility.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agent-first-data"
3
- version = "0.34.0"
3
+ version = "0.35.1"
4
4
  description = "A naming convention that lets AI agents understand your data without being told what it means, plus a CLI and library for reading Markdown structure and safely editing structured JSON, TOML, YAML, dotenv, and INI documents."
5
5
  license = "MIT"
6
6
  readme = "README.md"
@@ -1,5 +1,6 @@
1
1
  """Tests for agent_first_data CLI helpers."""
2
2
  import json
3
+ import sys
3
4
 
4
5
  import pytest
5
6
  from io import StringIO
@@ -500,3 +501,27 @@ def test_cli_render_version_renders_json():
500
501
  assert '"code":"version"' in out
501
502
  assert '"name":"agent-cli"' in out
502
503
  assert '"version":"1.2.3"' in out
504
+
505
+
506
+ @pytest.mark.skipif(
507
+ sys.platform == "win32",
508
+ reason="closed-pipe writes surface as OSError(EINVAL) on Windows, not "
509
+ "BrokenPipeError; the reader-closed contract is asserted on POSIX only",
510
+ )
511
+ def test_real_stdout_closed_pipe_has_clean_process_exit():
512
+ import os
513
+ import subprocess
514
+ import sys
515
+
516
+ reader, writer = os.pipe()
517
+ os.close(reader)
518
+ try:
519
+ child = subprocess.run(
520
+ [sys.executable, "-c", "from agent_first_data import CliEmitter, OutputFormat; "
521
+ "raise SystemExit(CliEmitter.finite(OutputFormat.JSON).finish_result({'ok': True}))"],
522
+ stdout=writer, stderr=subprocess.PIPE, timeout=10,
523
+ )
524
+ finally:
525
+ os.close(writer)
526
+ assert child.returncode == 0, child.stderr
527
+ assert child.stderr == b""