mostlyright-data 0.26.0__tar.gz → 0.26.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 (118) hide show
  1. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/PKG-INFO +1 -1
  2. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/pyproject.toml +1 -1
  3. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/skills/mr-data-build/references/readers.md +1 -0
  4. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/skills/mr-data-build/references/sources.md +5 -1
  5. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/src/mostlyright/data_harness/thin/recipe.py +29 -38
  6. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/src/mostlyright/data_harness/thin/recipe_explain.py +114 -52
  7. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/src/mostlyright/data_harness/thin/recipe_lint.py +71 -0
  8. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/src/mostlyright/data_harness/thin/v4.py +1 -1
  9. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/.gitignore +0 -0
  10. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/README.md +0 -0
  11. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/scripts/hatch_build.py +0 -0
  12. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/skills/mr-data-build/SKILL.md +0 -0
  13. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/skills/mr-data-build/agents/openai.yaml +0 -0
  14. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/skills/mr-data-build/references/1-open-the-page-and-the-link-to-it-in-the-first-message.md +0 -0
  15. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/skills/mr-data-build/references/2-brief-two-to-four-questions-each-with-a-recommended-answer.md +0 -0
  16. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/skills/mr-data-build/references/3-probe-read-a-source-before-committing-to-it.md +0 -0
  17. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/skills/mr-data-build/references/4-decide-say-what-you-chose-what-you-refused-and-ask-one-question.md +0 -0
  18. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/skills/mr-data-build/references/5-draft-one-recipe-document-one-call.md +0 -0
  19. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/skills/mr-data-build/references/6-build-one-run-sized-to-acquire-every-measured-source-whole.md +0 -0
  20. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/skills/mr-data-build/references/7-interrogate-ask-the-run-what-it-actually-delivered.md +0 -0
  21. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/skills/mr-data-build/references/8-fix-revise-the-document-and-register-it-again.md +0 -0
  22. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/skills/mr-data-build/references/9-present-only-what-survived-inspection-with-caveats.md +0 -0
  23. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/skills/mr-data-build/references/agent-protocol.md +0 -0
  24. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/skills/mr-data-build/references/autonomous-delivery.md +0 -0
  25. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/skills/mr-data-build/references/before-the-first-tool-call.md +0 -0
  26. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/skills/mr-data-build/references/boundaries.md +0 -0
  27. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/skills/mr-data-build/references/cloud-authentication-preflight.md +0 -0
  28. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/skills/mr-data-build/references/commands.md +0 -0
  29. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/skills/mr-data-build/references/cross-repository-protocol-reference.md +0 -0
  30. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/skills/mr-data-build/references/installation-parity.md +0 -0
  31. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/skills/mr-data-build/references/live-run.md +0 -0
  32. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/skills/mr-data-build/references/making-a-table-incremental.md +0 -0
  33. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/skills/mr-data-build/references/narrating-the-run.md +0 -0
  34. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/skills/mr-data-build/references/not-hosted-yet.md +0 -0
  35. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/skills/mr-data-build/references/one-install.md +0 -0
  36. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/skills/mr-data-build/references/prediction-labels.md +0 -0
  37. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/skills/mr-data-build/references/promote.md +0 -0
  38. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/skills/mr-data-build/references/receipts.md +0 -0
  39. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/skills/mr-data-build/references/recording-a-stream-venue.md +0 -0
  40. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/skills/mr-data-build/references/recovering-an-import-failure.md +0 -0
  41. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/skills/mr-data-build/references/reference-pages.md +0 -0
  42. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/skills/mr-data-build/references/required-protocol.md +0 -0
  43. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/skills/mr-data-build/references/source-credentials.md +0 -0
  44. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/skills/mr-data-build/references/the-one-thing-to-say-about-the-skill-itself.md +0 -0
  45. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/skills/mr-data-build/references/transforms.md +0 -0
  46. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/skills/mr-data-build/references/user-communication-contract.md +0 -0
  47. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/skills/mr-data-build/references/writing-a-decision-record.md +0 -0
  48. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/skills/mr-data-build/scripts/write_research_notebook.py +0 -0
  49. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/src/mostlyright/data_harness/__init__.py +0 -0
  50. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/src/mostlyright/data_harness/agent_protocol.py +0 -0
  51. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/src/mostlyright/data_harness/canonical.py +0 -0
  52. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/src/mostlyright/data_harness/formats.py +0 -0
  53. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/src/mostlyright/data_harness/hosted_crawler_protocol.py +0 -0
  54. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/src/mostlyright/data_harness/key_seam.py +0 -0
  55. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/src/mostlyright/data_harness/page_coverage.py +0 -0
  56. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/src/mostlyright/data_harness/part_check_evidence.py +0 -0
  57. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/src/mostlyright/data_harness/session_probes.py +0 -0
  58. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/src/mostlyright/data_harness/skill_assets.py +0 -0
  59. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/src/mostlyright/data_harness/table_manifest.py +0 -0
  60. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/src/mostlyright/data_harness/thin/__init__.py +0 -0
  61. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/src/mostlyright/data_harness/thin/acquire.py +0 -0
  62. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/src/mostlyright/data_harness/thin/acquire_cancel.py +0 -0
  63. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/src/mostlyright/data_harness/thin/activity.py +0 -0
  64. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/src/mostlyright/data_harness/thin/approvals.py +0 -0
  65. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/src/mostlyright/data_harness/thin/categories.py +0 -0
  66. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/src/mostlyright/data_harness/thin/commands.py +0 -0
  67. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/src/mostlyright/data_harness/thin/dataset-categories-v1.json +0 -0
  68. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/src/mostlyright/data_harness/thin/download.py +0 -0
  69. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/src/mostlyright/data_harness/thin/narrative.py +0 -0
  70. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/src/mostlyright/data_harness/thin/parity.py +0 -0
  71. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/src/mostlyright/data_harness/thin/probe.py +0 -0
  72. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/src/mostlyright/data_harness/thin/progress_vocabulary.py +0 -0
  73. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/src/mostlyright/data_harness/thin/propose.py +0 -0
  74. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/src/mostlyright/data_harness/thin/recipe_brief.py +0 -0
  75. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/src/mostlyright/data_harness/thin/recipe_forecast.py +0 -0
  76. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/src/mostlyright/data_harness/thin/research.py +0 -0
  77. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/src/mostlyright/data_harness/thin/router.py +0 -0
  78. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/src/mostlyright/data_harness/thin/run_diagnose.py +0 -0
  79. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/src/mostlyright/data_harness/thin/runs.py +0 -0
  80. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/src/mostlyright/data_harness/thin/session.py +0 -0
  81. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/src/mostlyright/data_harness/thin/stream.py +0 -0
  82. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/src/mostlyright/data_harness/thin/stream_venue.py +0 -0
  83. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/src/mostlyright/data_harness/thin/studio_capabilities.py +0 -0
  84. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/src/mostlyright/data_harness/thin/transport.py +0 -0
  85. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/src/mostlyright/data_harness/thin/user_agent.py +0 -0
  86. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/src/mostlyright/data_harness/thin/v4_artifacts.py +0 -0
  87. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/src/mostlyright/data_harness/thin/v4_catalog.py +0 -0
  88. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/src/mostlyright/data_harness/thin/v4_connected.py +0 -0
  89. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/src/mostlyright/data_harness/thin/v4_connections.py +0 -0
  90. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/src/mostlyright/data_harness/thin/v4_dataset_covers.py +0 -0
  91. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/src/mostlyright/data_harness/thin/v4_datasets.py +0 -0
  92. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/src/mostlyright/data_harness/thin/v4_handoff.py +0 -0
  93. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/src/mostlyright/data_harness/thin/v4_narrative.py +0 -0
  94. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/src/mostlyright/data_harness/thin/v4_public.py +0 -0
  95. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/src/mostlyright/data_harness/thin/v4_query.py +0 -0
  96. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/src/mostlyright/data_harness/thin/v4_reader.py +0 -0
  97. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/src/mostlyright/data_harness/thin/v4_runs.py +0 -0
  98. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/src/mostlyright/data_harness/thin/v4_secrets.py +0 -0
  99. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/src/mostlyright/data_harness/thin/v4_stream.py +0 -0
  100. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/src/mostlyright/data_harness/thin/v4_tables.py +0 -0
  101. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/src/mostlyright/data_harness/thin/vocabulary.py +0 -0
  102. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/src/mostlyright/data_harness/ux/__init__.py +0 -0
  103. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/src/mostlyright/data_harness/ux/attendance.py +0 -0
  104. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/src/mostlyright/data_harness/ux/clarification.py +0 -0
  105. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/src/mostlyright/data_harness/ux/cloud_auth.py +0 -0
  106. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/src/mostlyright/data_harness/ux/commands/__init__.py +0 -0
  107. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/src/mostlyright/data_harness/ux/commands/auth.py +0 -0
  108. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/src/mostlyright/data_harness/ux/commands/clarify.py +0 -0
  109. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/src/mostlyright/data_harness/ux/commands/login.py +0 -0
  110. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/src/mostlyright/data_harness/ux/commands/whoami.py +0 -0
  111. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/src/mostlyright/data_harness/ux/credential_native.py +0 -0
  112. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/src/mostlyright/data_harness/ux/credential_store.py +0 -0
  113. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/src/mostlyright/data_harness/ux/credentials.py +0 -0
  114. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/src/mostlyright/data_harness/ux/login.py +0 -0
  115. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/src/mostlyright/data_harness/ux/path_kind.py +0 -0
  116. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/src/mostlyright/data_harness/ux/plain_file.py +0 -0
  117. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/src/mostlyright/data_harness/ux/remediation.py +0 -0
  118. {mostlyright_data-0.26.0 → mostlyright_data-0.26.1}/src/mostlyright/data_harness/ux/render.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mostlyright-data
3
- Version: 0.26.0
3
+ Version: 0.26.1
4
4
  Summary: Mostly Right hosted CLI for reviewed datasets
5
5
  Project-URL: Homepage, https://mostlyright.md/
6
6
  Project-URL: Documentation, https://mostlyright.md/docs/guides/cli/
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mostlyright-data"
3
- version = "0.26.0"
3
+ version = "0.26.1"
4
4
  description = "Mostly Right hosted CLI for reviewed datasets"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -90,6 +90,7 @@ decoder. Zero matches and multiple matches both refuse.
90
90
  | `delimited_text@2.0.0` | Explicit delimiter, encoding, quoting, header presence, skip_rows, skip_blank_rows, and indexed columns with output names and optional expected_header; unselected_columns requires empty cells unless explicitly set to allow. |
91
91
  | `json.tabular@1.0.0` | `columns` with output names and RFC 6901 pointers; optional `document_format` (`json` or `ndjson`), `records_pointer`, and sequential `expand` pointers. Columns are required unless they state `required: false`. |
92
92
  | `json.tabular@1.1.0` | Same; also accepts a response served as `text/plain` (common for public agency JSON and NDJSON). |
93
+ | `json.tabular@1.2.0` | Same as 1.1.0. An `expand` entry may be `{"pointer": "/x", "required": false}`. A record whose array there is null, missing or too short adds no rows instead of refusing the document. A present member that is not an array still refuses. |
93
94
  | `xml.tabular@1.0.0` | Absolute record element path, declared namespace prefixes, and relative field selectors ending in `text()` or `@attribute`; fields are required unless `required: false`. |
94
95
  | `html.tabular@1.0.0` | Absolute record element path and relative text/attribute field selectors; fields are required unless `required: false`. |
95
96
  | `html.web_extract@1.0.0` | `mode: dom` with closed CSS-like record/field selectors, or `mode: json` with an inert JSON script selector and RFC 6901 field pointers. |
@@ -190,6 +190,8 @@ Write the request by these rules.
190
190
  - `method` is `GET` or `POST`. A `POST` states `body_template`, and a `GET` states none.
191
191
  - Each `{name}` placeholder takes the upstream row's cell in the column of that name.
192
192
  - Every placeholder is a declared parameter, and every parameter is used.
193
+ - A literal brace is written twice, `{{` or `}}`. Any other brace is refused. A SPARQL query can
194
+ ride in a query value this way.
193
195
  - Every parameter and every entry of `carry_columns` is a column of the upstream source.
194
196
  - A header value is a literal. The worker writes `Content-Type` itself.
195
197
  - Every literal the request sends is printable ASCII, with no trailing newline.
@@ -251,7 +253,9 @@ and a redirect. A keyless 401 or 403 is one of them. Tell the user each run fail
251
253
  and one sentence.
252
254
 
253
255
  When the upstream source has a request window, the collection inherits it. Name the window's
254
- `merge.partition.column` in `carry_columns`, or registration refuses the recipe. A refresh then
256
+ `merge.partition.column` in `carry_columns`, or registration refuses the recipe. A per-row
257
+ request collection over another per-row request collection inherits the same window when every
258
+ hop carries that column. `mr-data recipe explain` names each hop that drops it. A refresh then
255
259
  sends requests only for rows in the refreshed partitions. A request the predecessor answered
256
260
  successfully is never sent again, whatever the row's carried cells or partition now are. A full
257
261
  build leaves out an upstream row whose partition cell does not read. Read this source alone in the
@@ -478,55 +478,45 @@ def _named_source(source: Mapping[str, Any], index: int) -> str:
478
478
  def _inherits_a_direct_window(source: Mapping[str, Any], sources: Sequence[Any]) -> bool:
479
479
  """Whether this collection refreshes with a direct request window of the same recipe.
480
480
 
481
- Studio's ``_inherits_a_direct_window``, read the way it reads it. The discovery is
482
- ``source_rows`` and states ``request``, so the collection sends one request per upstream row.
483
- The upstream it names is a direct window of this plan: not a stream, not ``closed``, not a
484
- collection, with a ``window`` that carries ``request`` and no ``snapshot`` and replaces whole
485
- partitions. And ``request.carry_columns`` names that window's ``merge.partition.column``, so
486
- every member is keyed to its upstream row's partition and a refresh re-derives exactly the
487
- members of the partitions the window replaces. Every other collection restores a ledger that
488
- cannot join that proof.
481
+ Studio's ``_inherits_a_direct_window``, read the way it reads it. The collection is a per-row
482
+ request collection, a ``source_rows`` discovery that states ``request``, and its chain of
483
+ such collections up ``discovery.source`` (:func:`recipe_explain.inherited_window_chain`)
484
+ ends at a direct window of this plan: not a stream, not ``closed``, not a collection, with a
485
+ ``window`` that carries ``request`` and no ``snapshot`` and replaces whole partitions. And
486
+ every hop, this collection included, names that window's ``merge.partition.column`` in
487
+ ``request.carry_columns``, so every member is keyed to its upstream row's partition and a
488
+ refresh re-derives exactly the members of the partitions the window replaces. A chain that
489
+ loops, or that passes a ``url_column`` collection, inherits nothing.
489
490
 
490
491
  A name two sources share is nobody's upstream here. That document is malformed, and the
491
492
  refusal for it is the registration route's.
492
493
  """
493
494
 
494
- collection = source.get("collection")
495
- discovery = collection.get("discovery") if isinstance(collection, Mapping) else None
496
- if not isinstance(discovery, Mapping) or discovery.get("kind") != "source_rows":
497
- return False
498
- request = discovery.get("request")
499
- upstream_name = discovery.get("source")
500
- if not isinstance(request, Mapping) or not isinstance(upstream_name, str):
501
- return False
502
- upstreams = [
503
- candidate
495
+ counted: dict[str, int] = {}
496
+ for candidate in sources:
497
+ if isinstance(candidate, Mapping) and isinstance(candidate.get("name"), str):
498
+ counted[candidate["name"]] = counted.get(candidate["name"], 0) + 1
499
+ by_name = {
500
+ candidate["name"]: candidate
504
501
  for candidate in sources
505
- if isinstance(candidate, Mapping) and candidate.get("name") == upstream_name
506
- ]
507
- if len(upstreams) != 1:
502
+ if isinstance(candidate, Mapping)
503
+ and isinstance(candidate.get("name"), str)
504
+ and counted[candidate["name"]] == 1
505
+ }
506
+ chain = recipe_explain.inherited_window_chain(source, by_name)
507
+ if chain is None:
508
508
  return False
509
- (upstream,) = upstreams
509
+ root, hops = chain
510
+ upstream = by_name[root]
510
511
  if (
511
512
  upstream.get("source_class") == "stream"
512
513
  or upstream.get("closed") is True
513
514
  or isinstance(upstream.get("collection"), Mapping)
514
515
  ):
515
516
  return False
516
- window = upstream.get("window")
517
- merge = window.get("merge") if isinstance(window, Mapping) else None
518
- partition = merge.get("partition") if isinstance(merge, Mapping) else None
519
- column = partition.get("column") if isinstance(partition, Mapping) else None
520
- carried = request.get("carry_columns")
521
- return (
522
- isinstance(window, Mapping)
523
- and "snapshot" not in window
524
- and isinstance(window.get("request"), Mapping)
525
- and isinstance(merge, Mapping)
526
- and merge.get("materialization") == "partition_replace"
527
- and isinstance(column, str)
528
- and isinstance(carried, list)
529
- and column in carried
517
+ column = recipe_explain.window_partition_column(upstream)
518
+ return column is not None and all(
519
+ column in recipe_explain.row_request_carried(hop) for hop in hops
530
520
  )
531
521
 
532
522
 
@@ -730,8 +720,9 @@ def unrefreshable_plan_finding(
730
720
  "collection restores its ledger from the raw snapshot and cannot join the "
731
721
  "replacement-partition proof a refresh writes, so it refuses the whole table. The one "
732
722
  "collection a refresh admits sends one request per row of a direct request window "
733
- "in this recipe, through a `source_rows` discovery that states `request`, and names "
734
- "that window's `merge.partition.column` in `request.carry_columns`. "
723
+ "in this recipe, directly or through other such collections, each through a "
724
+ "`source_rows` discovery that states `request` and names that window's "
725
+ "`merge.partition.column` in `request.carry_columns`. "
735
726
  f"{UNREFRESHABLE_PLAN_TAIL}"
736
727
  )
737
728
  if "window" in held:
@@ -493,34 +493,57 @@ def declared_rebuild(document: Mapping[str, Any]) -> Mapping[str, Any] | None:
493
493
  return declaration
494
494
 
495
495
 
496
- def _inherits_a_direct_window(
497
- source: Mapping[str, Any],
498
- sources: Mapping[str, Mapping[str, Any]],
499
- direct_windows: Sequence[str],
500
- ) -> bool:
501
- """Whether this per-row request collection inherits a request window of this plan.
502
-
503
- Studio's rule: the upstream is a direct window of the same plan, and that window states
504
- ``request`` (not only ``snapshot``), replaces whole partitions, and has its partition column
505
- carried by the request. A snapshot window, a closed source and another collection are none.
496
+ def inherited_window_chain(
497
+ source: Mapping[str, Any], sources: Mapping[str, Mapping[str, Any]]
498
+ ) -> tuple[str, tuple[Mapping[str, Any], ...]] | None:
499
+ """The source a per-row request chain reads its rows from, and every hop on the way.
500
+
501
+ Studio's rule: walk ``discovery.source`` through per-row request collections, starting at
502
+ ``source``, until a source that is not one. Answers that source's name and every hop,
503
+ ``source`` first, or ``None`` when ``source`` is no per-row request collection, a hop names an
504
+ undeclared source, or the chain loops back on itself.
506
505
  """
507
506
 
508
- collection = source.get("collection")
509
- discovery = collection.get("discovery") if isinstance(collection, Mapping) else None
510
- request = discovery.get("request") if isinstance(discovery, Mapping) else None
511
- if (
512
- not isinstance(discovery, Mapping)
513
- or discovery.get("kind") != "source_rows"
514
- or not isinstance(request, Mapping)
515
- or discovery.get("source") not in direct_windows
516
- ):
517
- return False
518
- upstream = sources.get(str(discovery["source"]))
519
- window = upstream.get("window") if isinstance(upstream, Mapping) else None
507
+ hops: list[Mapping[str, Any]] = []
508
+ visited: set[str] = set()
509
+ current = source
510
+ while True:
511
+ discovery = _row_discovery(current)
512
+ request = discovery.get("request") if discovery is not None else None
513
+ upstream = discovery.get("source") if discovery is not None else None
514
+ if not isinstance(request, Mapping) or not isinstance(upstream, str):
515
+ return None
516
+ hops.append(current)
517
+ following = sources.get(upstream)
518
+ if following is None or upstream in visited:
519
+ return None
520
+ visited.add(upstream)
521
+ following_discovery = _row_discovery(following)
522
+ if following_discovery is None or not isinstance(
523
+ following_discovery.get("request"), Mapping
524
+ ):
525
+ return upstream, tuple(hops)
526
+ current = following
527
+
528
+
529
+ def row_request_carried(source: Mapping[str, Any]) -> tuple[str, ...]:
530
+ """The columns a per-row request collection carries, or none for any other source."""
531
+
532
+ discovery = _row_discovery(source)
533
+ request = discovery.get("request") if discovery is not None else None
534
+ carried = request.get("carry_columns") if isinstance(request, Mapping) else None
535
+ if not isinstance(carried, list):
536
+ return ()
537
+ return tuple(name for name in carried if isinstance(name, str))
538
+
539
+
540
+ def window_partition_column(source: Mapping[str, Any]) -> str | None:
541
+ """The partition column of a request window that replaces whole partitions, else ``None``."""
542
+
543
+ window = source.get("window")
520
544
  merge = window.get("merge") if isinstance(window, Mapping) else None
521
545
  partition = merge.get("partition") if isinstance(merge, Mapping) else None
522
- carried = request.get("carry_columns")
523
- return (
546
+ if (
524
547
  isinstance(window, Mapping)
525
548
  and "snapshot" not in window
526
549
  and isinstance(window.get("request"), Mapping)
@@ -528,9 +551,30 @@ def _inherits_a_direct_window(
528
551
  and merge.get("materialization") == "partition_replace"
529
552
  and isinstance(partition, Mapping)
530
553
  and isinstance(partition.get("column"), str)
531
- and isinstance(carried, list)
532
- and partition["column"] in carried
533
- )
554
+ ):
555
+ return str(partition["column"])
556
+ return None
557
+
558
+
559
+ def _inherits_a_direct_window(
560
+ source: Mapping[str, Any],
561
+ sources: Mapping[str, Mapping[str, Any]],
562
+ direct_windows: Sequence[str],
563
+ ) -> bool:
564
+ """Whether this per-row request collection inherits a request window of this plan.
565
+
566
+ Studio's rule: the end of the per-row request chain (:func:`inherited_window_chain`) is a
567
+ direct window of the same plan that states ``request`` (not only ``snapshot``) and replaces
568
+ whole partitions, and every hop, this collection included, carries its partition column. A
569
+ snapshot window, a closed source, a ``url_column`` collection and a loop are none.
570
+ """
571
+
572
+ chain = inherited_window_chain(source, sources)
573
+ if chain is None or chain[0] not in direct_windows:
574
+ return False
575
+ root, hops = chain
576
+ column = window_partition_column(sources[root])
577
+ return column is not None and all(column in row_request_carried(hop) for hop in hops)
534
578
 
535
579
 
536
580
  def _layout_is_consistent(
@@ -573,9 +617,10 @@ def _not_inheriting(
573
617
  upstream = discovery.get("source") if discovery is not None else None
574
618
  if isinstance(upstream, str) and upstream in sources:
575
619
  detail = (
576
- f"{name} derives its rows from {upstream}, which is not a request window "
577
- f"replacing whole partitions whose partition column {name} carries, so a refresh "
578
- f"cannot tell which of its members changed"
620
+ f"{name} derives its rows from {upstream}, which is neither a request window "
621
+ f"replacing whole partitions nor a per-row request collection inheriting one, with "
622
+ f"that window's partition column carried by {name} and every collection between, so "
623
+ f"a refresh cannot tell which of its members changed"
579
624
  )
580
625
  else:
581
626
  detail = (
@@ -927,37 +972,51 @@ def _rebuild(facts: _Facts) -> list[dict[str, Any]]:
927
972
 
928
973
 
929
974
  def _collection_suggestions(names: Sequence[str], facts: _Facts) -> list[dict[str, Any]]:
975
+ """What would let each collection inherit a request window of its own table.
976
+
977
+ A per-row request chain inherits the window its first non-collection upstream states, so the
978
+ window and the missing carries are judged at the end of the chain, not one hop up.
979
+ """
980
+
981
+ by_name = {name: facts.source(name) for name in facts.indices}
930
982
  suggestions: list[dict[str, Any]] = []
931
983
  for name in names:
932
- discovery = _row_discovery(facts.source(name)) or {}
984
+ source = facts.source(name)
985
+ discovery = _row_discovery(source) or {}
933
986
  upstream_name = discovery.get("source")
987
+ chain = inherited_window_chain(source, by_name)
988
+ if chain is not None:
989
+ upstream_name = chain[0]
934
990
  upstream = facts.source(upstream_name) if isinstance(upstream_name, str) else {}
935
- window = upstream.get("window")
936
- merge = window.get("merge") if isinstance(window, Mapping) else None
937
- partition = merge.get("partition") if isinstance(merge, Mapping) else None
938
- column = partition.get("column") if isinstance(partition, Mapping) else None
939
- if (
940
- isinstance(discovery.get("request"), Mapping)
941
- and isinstance(window, Mapping)
942
- and isinstance(window.get("request"), Mapping)
943
- and isinstance(merge, Mapping)
944
- and merge.get("materialization") == "partition_replace"
945
- and isinstance(column, str)
946
- ):
947
- suggestions.append(
948
- _suggestion(
949
- "carry_partition_column",
950
- f"add {column} to {name}'s request.carry_columns so its rows key to the "
951
- f"partitions of {upstream_name}",
952
- facts.pointer(name, "/collection/discovery/request/carry_columns"),
991
+ column = window_partition_column(upstream)
992
+ missing_carry = False
993
+ if chain is not None and column is not None:
994
+ for hop in chain[1]:
995
+ if column in row_request_carried(hop):
996
+ continue
997
+ missing_carry = True
998
+ hop_name = str(hop.get("name"))
999
+ suggestions.append(
1000
+ _suggestion(
1001
+ "carry_partition_column",
1002
+ f"add {column} to {hop_name}'s request.carry_columns so its rows key to "
1003
+ f"the partitions of {upstream_name}",
1004
+ facts.pointer(hop_name, "/collection/discovery/request/carry_columns"),
1005
+ )
953
1006
  )
1007
+ if not missing_carry and isinstance(upstream_name, str) and upstream:
1008
+ detail = (
1009
+ f"let {upstream_name} refresh as a request window of its own, neither "
1010
+ f"closed nor a page collection, so {name} re-derives only the rows of the "
1011
+ "refreshed partitions"
1012
+ if chain is not None and column is not None
1013
+ else f"make {upstream_name} a request window that replaces whole partitions, "
1014
+ f"so {name} re-derives only the rows of the refreshed partitions"
954
1015
  )
955
- elif isinstance(upstream_name, str) and upstream:
956
1016
  suggestions.append(
957
1017
  _suggestion(
958
1018
  "make_upstream_a_request_window",
959
- f"make {upstream_name} a request window that replaces whole partitions, "
960
- f"so {name} re-derives only the rows of the refreshed partitions",
1019
+ detail,
961
1020
  facts.pointer(upstream_name, "/window"),
962
1021
  date_pair_parameters(upstream),
963
1022
  )
@@ -1492,11 +1551,14 @@ __all__ = [
1492
1551
  "explain_exit_code",
1493
1552
  "explain_table_at_studio",
1494
1553
  "gated_grammar",
1554
+ "inherited_window_chain",
1495
1555
  "mirror_findings",
1496
1556
  "registration_gate",
1497
1557
  "route_unavailable",
1558
+ "row_request_carried",
1498
1559
  "row_request_parameter_names",
1499
1560
  "served_capabilities",
1500
1561
  "snapshot_sources",
1501
1562
  "source_action",
1563
+ "window_partition_column",
1502
1564
  ]
@@ -158,6 +158,12 @@ COHERENCE_CREDENTIAL_MODES: frozenset[str] = frozenset({"none", "opaque_referenc
158
158
  #: ``request`` instead of ``url_column``, on a source declared through this adapter.
159
159
  ROW_REQUEST_ADAPTER_ID = "public.https.collection@2.0.0"
160
160
 
161
+ #: One per-row template token, read left to right: an escaped brace, a ``{name}`` placeholder, a
162
+ #: lone brace, or a run of text with no brace. The registration route and the worker read every
163
+ #: per-row template with this same expression, and no per-row template pattern of
164
+ #: ``recipe.schema.json`` admits a string in which a lone brace is read.
165
+ ROW_TEMPLATE_TOKEN = re.compile(r"\{\{|\}\}|\{([A-Za-z][A-Za-z0-9_]{0,63})\}|[{}]|[^{}]+")
166
+
161
167
  #: ``recipe.schema.json#/$defs/collection_revisit``'s bounds on an ``interval``, which the
162
168
  #: collection lane's ``MIN_REVISIT_SECONDS`` and ``MAX_REVISIT_SECONDS`` also hold: an hour to a
163
169
  #: year. A per-row request admits ``interval`` since Studio 4.14.0 (``CONTRACT.md`` §1.1).
@@ -226,6 +232,7 @@ def lint_document(document: Mapping[str, Any]) -> list[Finding]:
226
232
  findings.extend(_sources(document.get("sources")))
227
233
  findings.extend(_row_filters(document.get("sources")))
228
234
  findings.extend(_row_request_credentials(document.get("sources")))
235
+ findings.extend(_row_request_templates(document.get("sources")))
229
236
  findings.extend(_units(document.get("units")))
230
237
  findings.extend(_coherence(document, declared=declared))
231
238
  findings.extend(_checks(document.get("checks"), declared=declared, grain=_grain(document)))
@@ -758,6 +765,68 @@ def _row_request_credentials(sources: Any) -> list[Finding]:
758
765
  return findings
759
766
 
760
767
 
768
+ def row_template_is_well_formed(text: str) -> bool:
769
+ """Whether every brace in one per-row template is doubled or part of a ``{name}`` placeholder.
770
+
771
+ ``{{`` is a literal ``{`` and ``}}`` a literal ``}``, read left to right, so ``{{{id}}}`` is a
772
+ literal brace, the ``id`` placeholder and a literal brace.
773
+ """
774
+
775
+ return all(match.group(0) not in ("{", "}") for match in ROW_TEMPLATE_TOKEN.finditer(text))
776
+
777
+
778
+ def _row_request_templates(sources: Any) -> list[Finding]:
779
+ """The brace grammar of every per-row request template, held to the schema's.
780
+
781
+ A per-row request renders ``{name}`` from the upstream row in its path, in each query value
782
+ and in each string of its body. A literal brace is written twice, and any other brace is the
783
+ schema's refusal, so each finding carries :data:`DOCUMENT_INVALID`. Header values, query names
784
+ and body keys take no placeholder and are not read here.
785
+ """
786
+
787
+ if not isinstance(sources, list):
788
+ return []
789
+ findings: list[Finding] = []
790
+
791
+ def check(pointer: str, text: Any) -> None:
792
+ if isinstance(text, str) and not row_template_is_well_formed(text):
793
+ findings.append(
794
+ Finding(
795
+ DOCUMENT_INVALID,
796
+ pointer,
797
+ "holds a brace that is neither doubled nor part of a placeholder; a "
798
+ "placeholder is a parameter name in braces, and a literal brace is written "
799
+ "twice, {{ or }}",
800
+ )
801
+ )
802
+
803
+ def walk(pointer: str, value: Any) -> None:
804
+ if isinstance(value, str):
805
+ check(pointer, value)
806
+ elif isinstance(value, list):
807
+ for position, item in enumerate(value):
808
+ walk(f"{pointer}/{position}", item)
809
+ elif isinstance(value, Mapping):
810
+ for key, item in value.items():
811
+ walk(f"{pointer}/{_escape(str(key))}", item)
812
+
813
+ for index, source in enumerate(sources):
814
+ if not isinstance(source, Mapping):
815
+ continue
816
+ _stated, request = _row_request(source)
817
+ if request is None:
818
+ continue
819
+ at = f"/sources/{index}/collection/discovery/request"
820
+ check(f"{at}/path_template", request.get("path_template"))
821
+ static_query = request.get("static_query")
822
+ for position, pair in enumerate(static_query if isinstance(static_query, list) else ()):
823
+ if isinstance(pair, Mapping):
824
+ check(f"{at}/static_query/{position}/value", pair.get("value"))
825
+ if "body_template" in request:
826
+ walk(f"{at}/body_template", request["body_template"])
827
+ return findings
828
+
829
+
761
830
  def _units(units: Any) -> list[Finding]:
762
831
  """Each declared unit's own two members. Whether the column exists is :func:`_coherence`'s."""
763
832
 
@@ -1430,8 +1499,10 @@ __all__ = [
1430
1499
  "MIN_BINS",
1431
1500
  "MIN_REVISIT_SECONDS",
1432
1501
  "PRESENTATION_BUCKETS",
1502
+ "ROW_TEMPLATE_TOKEN",
1433
1503
  "Finding",
1434
1504
  "lint_document",
1435
1505
  "lint_refusal",
1506
+ "row_template_is_well_formed",
1436
1507
  "snapshot_horizon_notes",
1437
1508
  ]
@@ -138,7 +138,7 @@ from mostlyright.data_harness.thin.transport import ThinLaneError
138
138
  #: ``JOB_INVALID`` outright, which in production was every refresh a collection epoch was offered
139
139
  #: for rather than only the collection runs. This package must not reach a Studio older than the
140
140
  #: commit it pins; ``docs/V4-WORKER-PROTOCOL.md`` states it beside the layout's own ordering rule.
141
- PINNED_V4_OPENAPI_SOURCE_SHA256 = "f14b52c537caac9cb065c5aa6399fcb7077e2eaafe6b42f981189059f1a260b2"
141
+ PINNED_V4_OPENAPI_SOURCE_SHA256 = "afe1b4d7af60c5296f44e00a6bd84dc11a852fcbf4228bbc63a4c2d2b3b00829"
142
142
  PINNED_V4_CONTRACT_VERSION = "4.14.0"
143
143
 
144
144
  #: The ``schema_version`` every command body that predates 4.14.0 stamps: ``dataset activity``,