diff --git a/backlot/fidelity/baseline/google_drive.json b/backlot/fidelity/baseline/google_drive.json index 47d0e932d..930594524 100644 --- a/backlot/fidelity/baseline/google_drive.json +++ b/backlot/fidelity/baseline/google_drive.json @@ -561,19 +561,6 @@ "kind": "missing_param", "severity": "gap" }, - "commentsViewMode": { - "kind": "missing_param", - "severity": "gap", - "note": "One family with `GET /v1/presentations/{}?commentsViewMode` and `GET /v4/spreadsheets/{}?commentsViewMode`, all three marked Developer Preview. Google's own description (Docs): \"The comments view mode to apply to the document. This allows viewing the document with comments omitted or included. If one is not specified, COMMENTS_VIEW_MODE_OMITTED is used.\" Backlot declares it on Sheets alone (`routers.google_sheets._sheets_comments_view`). Measured against the three hosts on 2026-09-29 with a user token: Sheets and Slides answer each of the four enum values 200, and `comments_view_mode_omitted` in lower case as well, and refuse `BOGUS` and the empty value with 400 `Invalid value at 'comments_view_mode' (type.googleapis.com/google.apps.sheets.v4.CommentsViewMode), \"BOGUS\"` (`google.apps.slides.v1` on Slides). Docs refuses those two the same way under `google.apps.docs.v1` and answers `COMMENTS_VIEW_MODE_UNSPECIFIED` 200, but refuses the other three with 400 `Comments view mode may only be specified if tabs content is also requested.` unless `includeTabsContent=true` or a `fields` mask naming `tabs` comes with them. With tabs requested, `COMMENTS_VIEW_MODE_INCLUDED` is refused with `Comments may only be explicitly included if inline suggestions are also explicitly requested.` until `suggestionsViewMode=SUGGESTIONS_INLINE` joins it, and `COMMENTS_VIEW_MODE_DEFAULT_FOR_CURRENT_ACCESS` beside `suggestionsViewMode=PREVIEW_WITHOUT_SUGGESTIONS` with `Comments may not be requested when previewing suggestions.` Those two Docs parameters are gaps in this file as well: this server reads neither, so the rules that tie them to this one have nothing to act on." - }, - "fields": { - "kind": "missing_param", - "severity": "gap" - }, - "includeTabsContent": { - "kind": "missing_param", - "severity": "gap" - }, "key": { "kind": "missing_param", "severity": "gap" @@ -586,10 +573,6 @@ "kind": "missing_param", "severity": "gap" }, - "suggestionsViewMode": { - "kind": "missing_param", - "severity": "gap" - }, "uploadType": { "kind": "missing_param", "severity": "gap" @@ -617,7 +600,7 @@ "commentsViewMode": { "kind": "missing_param", "severity": "gap", - "note": "Same family as `GET /v1/documents/{}?commentsViewMode`, whose note carries the measurement on all three hosts." + "note": "One family with `GET /v1/documents/{}?commentsViewMode` and `GET /v4/spreadsheets/{}?commentsViewMode`, all three marked Developer Preview. Google's own description (Docs): \"The comments view mode to apply to the document. This allows viewing the document with comments omitted or included. If one is not specified, COMMENTS_VIEW_MODE_OMITTED is used.\" Backlot reads it on Sheets (`routers.google_sheets._sheets_comments_view`) and Docs (`routers.google_docs.docs_get`). Measured against the three hosts on 2026-09-29 with a user token: Sheets and Slides answer each of the four enum values 200, and `comments_view_mode_omitted` in lower case as well, and refuse `BOGUS` and the empty value with 400 `Invalid value at 'comments_view_mode' (type.googleapis.com/google.apps.slides.v1.CommentsViewMode), \"BOGUS\"`, where Docs refuses all but `COMMENTS_VIEW_MODE_UNSPECIFIED` unless the tabs are requested." }, "fields": { "kind": "missing_param", @@ -1516,10 +1499,8 @@ "type": "string" }, "tabs": { - "status": "missing", - "type": "[docs.Tab]", - "kind": "missing_field", - "severity": "gap" + "status": "implemented", + "type": "[docs.Tab]" }, "title": { "status": "implemented", @@ -1643,63 +1624,85 @@ }, "docs.DocumentTab": { "type_kind": "object", - "status": "missing", + "status": "implemented", "fields": { "body": { - "status": "missing", + "status": "implemented", "type": "docs.Body" }, "commentAnchors": { "status": "missing", - "type": "map" + "type": "map", + "kind": "missing_field", + "severity": "gap" }, "documentStyle": { - "status": "missing", + "status": "implemented", "type": "docs.DocumentStyle" }, "dropdownDefinitions": { "status": "missing", - "type": "map" + "type": "map", + "kind": "missing_field", + "severity": "gap" }, "footers": { "status": "missing", - "type": "map" + "type": "map", + "kind": "missing_field", + "severity": "gap" }, "footnotes": { "status": "missing", - "type": "map" + "type": "map", + "kind": "missing_field", + "severity": "gap" }, "headers": { "status": "missing", - "type": "map" + "type": "map", + "kind": "missing_field", + "severity": "gap" }, "inlineObjects": { "status": "missing", - "type": "map" + "type": "map", + "kind": "missing_field", + "severity": "gap" }, "lists": { "status": "missing", - "type": "map" + "type": "map", + "kind": "missing_field", + "severity": "gap" }, "namedRanges": { "status": "missing", - "type": "map" + "type": "map", + "kind": "missing_field", + "severity": "gap" }, "namedStyles": { - "status": "missing", + "status": "implemented", "type": "docs.NamedStyles" }, "positionedObjects": { "status": "missing", - "type": "map" + "type": "map", + "kind": "missing_field", + "severity": "gap" }, "suggestedDocumentStyleChanges": { "status": "missing", - "type": "map" + "type": "map", + "kind": "missing_field", + "severity": "gap" }, "suggestedNamedStylesChanges": { "status": "missing", - "type": "map" + "type": "map", + "kind": "missing_field", + "severity": "gap" } } }, @@ -2637,48 +2640,56 @@ }, "docs.Tab": { "type_kind": "object", - "status": "missing", + "status": "implemented", "fields": { "childTabs": { "status": "missing", - "type": "[docs.Tab]" + "type": "[docs.Tab]", + "kind": "missing_field", + "severity": "gap" }, "documentTab": { - "status": "missing", + "status": "implemented", "type": "docs.DocumentTab" }, "tabProperties": { - "status": "missing", + "status": "implemented", "type": "docs.TabProperties" } } }, "docs.TabProperties": { "type_kind": "object", - "status": "missing", + "status": "implemented", "fields": { "iconEmoji": { "status": "missing", - "type": "string" + "type": "string", + "kind": "missing_field", + "severity": "gap" }, "index": { - "status": "missing", + "status": "implemented", "type": "integer" }, "nestingLevel": { "status": "missing", - "type": "integer" + "type": "integer", + "kind": "missing_field", + "severity": "gap" }, "parentTabId": { "status": "missing", - "type": "string" + "type": "string", + "kind": "missing_field", + "severity": "gap" }, "tabId": { - "status": "missing", + "status": "implemented", "type": "string" }, "title": { - "status": "missing", + "status": "implemented", "type": "string" } } diff --git a/backlot/fidelity/google_discovery_diff.py b/backlot/fidelity/google_discovery_diff.py index b9516247b..397d779e2 100644 --- a/backlot/fidelity/google_discovery_diff.py +++ b/backlot/fidelity/google_discovery_diff.py @@ -319,7 +319,7 @@ def types_measure(spec: TypesTarget, doc: Mapping[str, Any], *, timeout: float) Every one of them is listed whether or not Backlot serves it, where a GraphQL source lists none of the vendor's types Backlot does not declare: a `fields` mask is read against the whole of - each (`backlot.routers.google_sheets._gmask_select`), so the baseline has to carry it offline. + each (`backlot.routers.google._gmask_select`), so the baseline has to carry it offline. A field the vendor does not declare is `extra`, and breaking.""" types = declared_types(doc) answers = {o.id: o.response for o in from_google_discovery(doc).values() if o.response} diff --git a/backlot/fidelity/google_walks.py b/backlot/fidelity/google_walks.py index 720e7d4d6..a5a0d318f 100644 --- a/backlot/fidelity/google_walks.py +++ b/backlot/fidelity/google_walks.py @@ -11,7 +11,7 @@ then writes to the grid, copies a sheet and creates a workbook, as its owner. `docs` reads a document stating a title, a heading and every run mark a corpus states, and a prose -document beside it. +document beside it, flat and as their tabs, as their owner. """ from __future__ import annotations @@ -89,11 +89,18 @@ def docs(timeout: float) -> list[tuple[str, Any]]: - """Each document read through `documents.get`, as the service account.""" + """Each document read through `documents.get`, flat and as its tabs, as its owner: a reader is + served no `revisionId`.""" import backlot + from backlot.server import meta_users with backlot.serve(DOCS_CORPUS, telemetry=False) as server: - headers = {"Authorization": f"Bearer {server.token}"} + owner = next( + u["token"] + for u in meta_users(server.base_url, timeout)["users"] + if u["email"] == DOCS_CORPUS[0]["author_email"] + ) + headers = {"Authorization": f"Bearer {owner}"} with httpx.Client(base_url=server.base_url, headers=headers, timeout=timeout) as client: files = _ok( client.get( @@ -102,8 +109,9 @@ def docs(timeout: float) -> list[tuple[str, Any]]: ) )["files"] return [ - ("docs.documents.get", _ok(client.get(f"/docs/v1/documents/{f['id']}"))) + ("docs.documents.get", _ok(client.get(f"/docs/v1/documents/{f['id']}", params=q))) for f in files + for q in ({}, {"includeTabsContent": "true"}) ] diff --git a/backlot/routers/google.py b/backlot/routers/google.py index 65c5a369c..7f572979c 100644 --- a/backlot/routers/google.py +++ b/backlot/routers/google.py @@ -1,6 +1,7 @@ -"""Google APIs: Gmail (``/gmail/v1``), Drive (``/drive/v3``), and the Workspace editor APIs — -Docs (``/docs/v1``), Sheets (``/sheets/v4``, in `routers.google_sheets`), Slides (``/slides/v1``) — -for clients that read native docs structurally instead of via Drive export. +"""Google APIs: Gmail (``/gmail/v1``), Drive (``/drive/v3``), and the Workspace editor APIs — Docs +(``/docs/v1``, in `routers.google_docs`), Sheets (``/sheets/v4``, in `routers.google_sheets`), +Slides (``/slides/v1``) — for clients that read native docs structurally instead of via Drive +export. Client base-URL override: point the Gmail client at ``http:///gmail`` and the Drive client at ``http:///drive`` (google-api-python-client ``api_endpoint``). @@ -35,7 +36,6 @@ from backlot import ( auth, collation, - gdocs, gsheets, protojson, store, @@ -4416,14 +4416,14 @@ def _editor_doc(request: Request, file_id: str, *, expect: str): raise gerr.invalid_argument(EDITOR_INVALID_ARG) -def _a1_enum_error(field: str, enum: protojson.Enum, value: str) -> str: +def _enum_error(field: str, enum: protojson.Enum, value: str) -> str: """Google's own wording for a bad read enum — it names the proto field and message type, e.g. ``Invalid value at 'major_dimension' (…sheets.v4.Dimension), "DIAGONAL"``. Measured, because a client that matches on the message needs the real one.""" return f"Invalid value at '{field}' ({enum.type_url}), \"{value}\"" -def _sheets_bool_value(raw: str | None, field: str) -> bool: +def _query_bool(raw: str | None, field: str) -> bool: """One of the boolean query parameters, read as a JSON body's string is (``protojson.to_bool``). Measured: `1`, `t`, `y` and `yes` mean true and `0`, `f`, `n` and `no` mean false, whatever the case of their ASCII letters, while `on`/`off`, a padded `" true"`, `2`, `01` and `1.0` are @@ -4439,7 +4439,7 @@ def _sheets_bool_value(raw: str | None, field: str) -> bool: ) from None -def _sheets_enum_value(raw: str, field: str, enum: protojson.Enum) -> str: +def _query_enum(raw: str, field: str, enum: protojson.Enum) -> str: """One of the read enums from the query string, as its canonical name. Read the way a JSON body's string is (``protojson.enum_from_string``), measured on the query @@ -4452,83 +4452,235 @@ def _sheets_enum_value(raw: str, field: str, enum: protojson.Enum) -> str: try: return enum.names[protojson.enum_from_string(raw, enum)] except protojson.ConversionError: - raise gerr.invalid_field_value(field, _a1_enum_error(field, enum, raw)) from None - - -# `documents.get`'s two enums, names in number order. Measured 2026-10-09: `suggestionsViewMode` -# reads `0` through `3`, `+1` and `01` (the value numbered 1) and a name in any ASCII case, and -# refuses `4`, `-1`, `1.0`, a padded `" 1"`, empty and `NOPE`. `commentsViewMode` reads the same -# way (2026-10-14: `3`, `+1` and `comments_view_mode_omitted` read, `4` and `-1` refused). -# `includeTabsContent` is the boolean `_sheets_bool_value` reads (2026-10-14: `no`, `n`, `y`, `T` -# and `F` read, `on`, `off`, `2` and `tru` refused). A value that cannot be read is a 400 before -# the document is looked up. -_DOCS_VIEW = "type.googleapis.com/google.apps.docs.v1" -_DOCS_SUGGESTIONS_VIEW = protojson.Enum( - f"{_DOCS_VIEW}.SuggestionsViewMode", - ( - "DEFAULT_FOR_CURRENT_ACCESS", - "SUGGESTIONS_INLINE", - "PREVIEW_SUGGESTIONS_ACCEPTED", - "PREVIEW_WITHOUT_SUGGESTIONS", - ), -) -_DOCS_COMMENTS_VIEW = protojson.Enum( - f"{_DOCS_VIEW}.CommentsViewMode", - ( - "COMMENTS_VIEW_MODE_UNSPECIFIED", - "COMMENTS_VIEW_MODE_DEFAULT_FOR_CURRENT_ACCESS", - "COMMENTS_VIEW_MODE_OMITTED", - "COMMENTS_VIEW_MODE_INCLUDED", - ), -) + raise gerr.invalid_field_value(field, _enum_error(field, enum, raw)) from None -@router.get("/docs/v1/documents/{document_id}") -async def docs_get(document_id: str, request: Request): - """The document as Docs structural elements: its stored body, or one ``paragraph`` per line of - its text when it states none (`backlot.gdocs`).""" - # The credential, then the typed values, then the lookup -- `_typed_query` records the order. - _require(request) - # Validated and then unused: the body is the same whatever a value that is read selects, where - # real's changes with it. - _typed_query( - request, - { - "suggestionsViewMode": lambda raw: _sheets_enum_value( - raw, "suggestions_view_mode", _DOCS_SUGGESTIONS_VIEW - ), - "includeTabsContent": lambda raw: _sheets_bool_value(raw, "include_tabs_content"), - "commentsViewMode": lambda raw: _sheets_enum_value( - raw, "comments_view_mode", _DOCS_COMMENTS_VIEW - ), - }, - ) - row = _editor_doc(request, document_id, expect="document") - body = store.gdrive_doc_body_for(auth.conn(request), document_id) - if body is None: - body = gdocs.from_text(row["content"] or "") - # Real's order for an untabbed read (docs.googleapis.com, 2026-10-16, twelve Docs, six owned by - # a Workspace member and six by a consumer account), the same under each `suggestionsViewMode`, - # `includeTabsContent=false` and a `fields` mask naming the members in another order - # (`documentId,title`, `revisionId,body,title`). Real also serves `headers` and `footers` after - # `body`, `lists` after `namedStyles` and `inlineObjects` before `documentId` when a Doc has - # them; this server serves none of the four. +# --- the `fields` mask the Sheets and Docs reads apply -------------------------------------------- + + +def _google_types() -> dict[str, dict[str, str | None]]: + """The messages a Sheets or Docs read answers with and every message they reach, each as its + fields and the message each holds (`None` for a scalar, an enum, a map or a list of lists): the + `types` `backlot diff` records in the `google_drive` baseline from the discovery documents. A + field only Backlot serves is not one a mask may name.""" + types = json.loads((_BASELINES / "google_drive.json").read_text(encoding="utf-8"))["types"] + + def held(type_: str) -> str | None: + name = type_.strip("[]") + return name if name in types else None + return { - "title": row["title"], - "body": {"content": gdocs.indexed(body)}, - "documentStyle": gdocs.document_style(), - "namedStyles": gdocs.named_styles(), - "revisionId": synth._digest(document_id)[:24], - "suggestionsViewMode": "SUGGESTIONS_INLINE", - "documentId": document_id, - # Last, where real serves it. Real's value for a read that asks for no comments, whatever - # `suggestionsViewMode` and `includeTabsContent` select (measured 2026-10-09 and - # 2026-10-15); `commentsViewMode=COMMENTS_VIEW_MODE_INCLUDED` beside the tabs and inline - # suggestions is answered `COMMENTS_VIEW_MODE_INCLUDED` there and this value here. - "commentsViewMode": "COMMENTS_VIEW_MODE_OMITTED", + name: {f: held(m["type"]) for f, m in entry.get("fields", {}).items() if "type" in m} + for name, entry in types.items() } +_GOOGLE_FIELDS = _google_types() + + +def _gmask_paths(mask: str) -> tuple[list[tuple[str, ...]], str]: + """The paths a ``fields`` mask names, in its order, each as its names, and the bracket it leaves + unpaired (``""`` for none). + + Measured grammar: ``.`` and ``/`` both descend, ``a(b,c)`` groups a sub-selection, ``,`` + separates siblings and a trailing comma is tolerated. A name is matched case-sensitively and is + not trimmed -- `` spreadsheetId`` with a leading space 400s. An empty group names nothing, not + even the name before it: measured 2026-10-22, `a()` answered the whole spreadsheet with no + refusal for `a`, and `spreadsheetId,sheets()` the `spreadsheetId` alone. + + A bracket left unpaired is refused after the paths before it, a ``)`` where it stands and a + ``(`` at the end (`gerr.unbalanced_field_mask`): measured 2026-10-22, `a)` and + `bogus)spreadsheetId` named `a` and `bogus`, `spreadsheetId)bogus` was the `)` refusal, and + `sheets(properties/title,bogus` named `sheets.bogus` where `sheets(properties/title` was the + `(` one.""" + paths: list[tuple[str, ...]] = [] + # the prefix of each open group + stack: list[tuple[str, ...]] = [] + prefix: tuple[str, ...] = () + token = "" + + def names(text: str) -> tuple[str, ...]: + return tuple(text.replace("/", ".").split(".")) + + for ch in mask: + if ch == "(": + stack.append(prefix) + prefix += names(token) if token else () + token = "" + elif ch == ")": + if token: + paths.append(prefix + names(token)) + if not stack: + return paths, ")" + prefix, token = stack.pop(), "" + elif ch == ",": + if token: + paths.append(prefix + names(token)) + token = "" + else: + token += ch + if token: + paths.append(prefix + names(token)) + return paths, "(" if stack else "" + + +def _gmask_resolve(path: tuple[str, ...], message: str | None) -> dict | None: + """What ``path`` selects in ``message`` (`None` for a field that holds no message), or ``None`` + when it names nothing there; ``{}`` is all of it. + + A ``*`` stands for one name. Measured 2026-10-22: `sheets.*.title` is `sheets.properties.title`, + `*.*.title` the same, `*.*.rowCount` refused as naming nothing (the row count is three names + under `sheets`), and `*.data` is `sheets.data`. A ``*`` that ends a path selects all of what it + is under, a field holding no message included, and so does a run of them: `spreadsheetId.*`, + `spreadsheetId.*.*`, a map's `criteria.*` and `*.*.*` answered, where `spreadsheetId.*.x` names + nothing.""" + if not path: + return {} + head, rest = path[0], path[1:] + if message is None: + return {} if all(name == "*" for name in path) else None + fields = _GOOGLE_FIELDS[message] + if head == "*": + if not rest: + return {} + out = {} + for name, held in fields.items(): + sub = _gmask_resolve(rest, held) + if sub is not None: + out[name] = sub + return out or None + if head not in fields: + return None + sub = _gmask_resolve(rest, fields[head]) + return None if sub is None else {head: sub} + + +# The messages a mask selects whole once any path names them whole, a narrower path beside it +# notwithstanding. Measured 2026-10-27: `values(userEnteredValue,userEnteredValue.formulaValue)` +# answered a string cell's `stringValue`, and so did the same for `effectiveValue`, an +# `errorValue`'s `message` beside `errorValue.type`, a `backgroundColorStyle`'s `rgbColor` beside +# `backgroundColorStyle.themeColor`, and `themeColors` whole beside `themeColors.colorType`. +_GMASK_WHOLE = frozenset( + {"sheets.ExtendedValue", "sheets.ErrorValue", "sheets.ColorStyle", "sheets.ThemeColorPair"} +) + + +def _gmask_merge(into: dict, selection: dict, message: str | None) -> None: + """Merge one path's selection into the tree, a narrower selection replacing a wider one: + measured 2026-10-22, `spreadsheetId,*` answered `spreadsheetId` alone, and + `sheets,sheets.properties.title` and `sheets.*,sheets.properties.title` the title alone, and + on 2026-10-27 `effectiveFormat,effectiveFormat.numberFormat` and + `properties(defaultFormat,defaultFormat.numberFormat)` the narrower path alone, in either + order. A message in :data:`_GMASK_WHOLE` is the exception, kept whole.""" + fields = _GOOGLE_FIELDS.get(message, {}) if message else {} + for name, sub in selection.items(): + held = fields.get(name) + if held in _GMASK_WHOLE and (sub == {} or into.get(name) == {}): + into[name] = {} + else: + _gmask_merge(into.setdefault(name, {}), sub, held) + + +def _gmask_named(selection: dict, path: tuple[str, ...]) -> dict: + """The part of one path's ``selection`` the path names: down to its last name that is not + ``*``, so `tabs.documentTab.*` names `tabs.documentTab` and nothing under it, `*.commentId` + names `comments.commentId`, and `*.*` names nothing.""" + depth = max((i + 1 for i, name in enumerate(path) if name != "*"), default=0) + + def cut(tree: dict, levels: int) -> dict: + return {name: cut(sub, levels - 1) if levels > 1 else {} for name, sub in tree.items()} + + return cut(selection, depth) if depth else {} + + +def _gmask_select(mask: str | None, message: str) -> tuple[dict | None, dict]: + """The selection tree a ``fields`` mask makes of a ``message`` response, ``{}`` at a leaf + meaning all of it, and the fields its paths name (`_gmask_named`), merged. ``None`` for no + mask, which an empty one, an empty group and a ``*`` alone each are: measured 2026-10-22, + `fields=*` answered what no mask does, `includeGridData` deciding the grid. + + A path that names nothing is refused, the first such in the mask's order (measured on + `sheets.properties.title,bogus2,sheets.bogus3`: `bogus2`), so a declared field the response + holds no value for answers 200 without it rather than the 400 a field it does not declare gets. + Checked against the whole schema, not against what this module builds: measured 2026-10-22, + `namedRanges`, `sheets(merges,conditionalFormats)` and a cell's `userEnteredFormat` each + answered 200 on a spreadsheet holding none. A bracket left unpaired is refused after the paths + before it (`_gmask_paths`). + + What a path names is what the editors' own refusals read: measured 2026-10-22 on Sheets, + `comments`, `comments.*`, `*.commentId` and `*.headPost` were refused for asking after comments + that `commentsViewMode` did not request, and `*.*`, which reaches `comments` through ``*`` + alone, was answered.""" + if not mask: + return None, {} + tree: dict = {} + named: dict = {} + paths, unpaired = _gmask_paths(mask) + for path in paths: + selection = _gmask_resolve(path, message) + if selection is None: + raise gerr.bad_field_mask(mask, ".".join(path)) + _gmask_merge(named, _gmask_named(selection, path), message) + _gmask_merge(tree, selection, message) + if unpaired: + raise gerr.unbalanced_field_mask(mask, unpaired) + return (tree or None), named + + +def _gmask_apply(tree: dict, value): + """Project ``value`` through a selection tree, mapping over a list rather than indexing it — + which is what lets ``sheets.properties.title`` reach into every sheet — and keeping the order + ``value`` has: measured 2026-10-22, `*.title,spreadsheetId` answered `spreadsheetId` first.""" + if not tree: + return value + if isinstance(value, list): + return [_gmask_apply(tree, v) for v in value] + if not isinstance(value, dict): + return value + out = {name: _gmask_apply(tree[name], v) for name, v in value.items() if name in tree} + if "rowData" in out: + _gmask_trim_rows(out) + return out + + +_CELL_FORMATS = frozenset({"userEnteredFormat", "effectiveFormat"}) + + +def _gmask_hollow(value) -> bool: + """Whether a projected message holds nothing but empty messages.""" + return isinstance(value, dict) and all(_gmask_hollow(v) for v in value.values()) + + +def _gmask_trim_rows(block: dict) -> None: + """Cut a projected `GridData` block's rows the way `google_sheets._sheets_grid_data` cuts + them, a cell the mask left empty counting as an empty cell. Measured 2026-10-22 over `D1:F4` + holding `a` in D1 and `b` in F2: `rowData.values.userEnteredValue` answered + `[{"values": [{…}]}, {"values": [{}, {}, {…}]}]`, and `rowData.values.userEnteredFormat`, which + no cell held, no `rowData` at all. + + A cell's format the mask leaves holding nothing is left out, where every other message is kept + empty. Measured 2026-10-27: `values(userEnteredValue,effectiveFormat.numberFormat)` served no + `effectiveFormat` on a string, a number or a boolean, and neither did + `effectiveFormat.textFormat.link`, while `effectiveValue.numberValue` served a string's + `effectiveValue` as `{}`, and `sheets.properties.tabColorStyle` and + `properties.defaultFormat.numberFormat` served `properties` and `defaultFormat` as `{}`.""" + rows = [] + for row in block["rowData"]: + values = [ + {k: v for k, v in cell.items() if k not in _CELL_FORMATS or not _gmask_hollow(v)} + for cell in row.get("values", []) + ] + while values and not values[-1]: + values.pop() + rows.append({"values": values} if values else {}) + while rows and not rows[-1]: + rows.pop() + if rows: + block["rowData"] = rows + else: + del block["rowData"] + + @router.get("/slides/v1/presentations/{presentation_id}") async def slides_get(presentation_id: str, request: Request): """The presentation as slides: one ``TEXT_BOX`` slide per blank-line-separated block of text.""" @@ -4885,10 +5037,10 @@ def _typed_query(request: Request, readers: dict) -> dict[str, list]: # The typed booleans each Drive method Backlot serves declares, as the proto field its refusal # names. Each is parsed and the parsed value never read. Measured 2026-09-23 on each of them: the -# Sheets boolean spellings (`_sheets_bool_value`), 30 of them swept on `supportsAllDrives`; and on -# 2026-10-06 `yeſ`, `YEſ`, `falſe` and `FALſE`, each refused, since only ASCII letters are folded -# (`protojson.to_bool`). Spelled `true`, four of them run a check of their own and two lift one -- -# see `_drive_true`. `files.export` and `about.get` declare none, and real ignores +# spellings `_query_bool` reads, first measured on Sheets, 30 of them swept on `supportsAllDrives`; +# and on 2026-10-06 `yeſ`, `YEſ`, `falſe` and `FALſE`, each refused, since only ASCII letters are +# folded (`protojson.to_bool`). Spelled `true`, four of them run a check of their own and two lift +# one -- see `_drive_true`. `files.export` and `about.get` declare none, and real ignores # `supportsAllDrives=NOPE` on both. _DRIVE_BOOLS = { "supportsAllDrives": "supports_all_drives", @@ -4904,8 +5056,7 @@ def _drive_typed(request: Request, *bools: str, page_size: bool = False) -> dict """`_typed_query` over the booleans a Drive method declares, and its `pageSize` if it has one.""" readers = { - name: (lambda raw, field=_DRIVE_BOOLS[name]: _sheets_bool_value(raw, field)) - for name in bools + name: (lambda raw, field=_DRIVE_BOOLS[name]: _query_bool(raw, field)) for name in bools } if page_size: readers["pageSize"] = _drive_int32 @@ -5154,6 +5305,6 @@ def _drive_page_size(sizes: list[int]) -> int: return min(size, get_settings().max_page_size) -# The Sheets routes, registered on `router` by the module that holds them -- which imports this -# one's helpers, so it is imported last. -from backlot.routers import google_sheets # noqa: E402, F401 +# The Sheets and Docs routes, registered on `router` by the modules that hold them -- which import +# this one's helpers, so they are imported last. +from backlot.routers import google_docs, google_sheets # noqa: E402, F401 diff --git a/backlot/routers/google_docs.py b/backlot/routers/google_docs.py new file mode 100644 index 000000000..0493d6a03 --- /dev/null +++ b/backlot/routers/google_docs.py @@ -0,0 +1,218 @@ +"""Google Docs v1 (``/docs/v1``): ``documents.get``. + +The routes are registered on `routers.google.router` as `routers.google_sheets` describes, and a +``fields`` mask is read as the Sheets routes read theirs (`routers.google._gmask_select`). + +The answers below were measured on docs.googleapis.com between 2026-10-09 and 2026-10-28, each +comment dating its own, by a Google Workspace member on a one-tab Doc it owns and by a second +member it was shared with as a reader. +""" + +from __future__ import annotations + +from fastapi import Request + +from backlot import auth, gdocs, protojson, store, synth +from backlot.errors import google as gerr +from backlot.openapi import qp +from backlot.routers.google import ( + _GOOGLE_FIELDS, + _drive_owned_by, + _editor_doc, + _gmask_apply, + _gmask_select, + _query_bool, + _query_enum, + _require, + _typed_query, + router, +) + +# `documents.get`'s two enums, names in number order. Measured 2026-10-09: `suggestionsViewMode` +# reads `0` through `3`, `+1` and `01` (the value numbered 1) and a name in any ASCII case, and +# refuses `4`, `-1`, `1.0`, a padded `" 1"`, empty and `NOPE`. `commentsViewMode` reads the same +# way (2026-10-14: `3`, `+1` and `comments_view_mode_omitted` read, `4` and `-1` refused). +# `includeTabsContent` is the boolean `_query_bool` reads (2026-10-14: `no`, `n`, `y`, `T` +# and `F` read, `on`, `off`, `2` and `tru` refused). A value that cannot be read is a 400 before +# the document is looked up. +_DOCS_VIEW = "type.googleapis.com/google.apps.docs.v1" +_DOCS_SUGGESTIONS_VIEW = protojson.Enum( + f"{_DOCS_VIEW}.SuggestionsViewMode", + ( + "DEFAULT_FOR_CURRENT_ACCESS", + "SUGGESTIONS_INLINE", + "PREVIEW_SUGGESTIONS_ACCEPTED", + "PREVIEW_WITHOUT_SUGGESTIONS", + ), +) +_DOCS_COMMENTS_VIEW = protojson.Enum( + f"{_DOCS_VIEW}.CommentsViewMode", + ( + "COMMENTS_VIEW_MODE_UNSPECIFIED", + "COMMENTS_VIEW_MODE_DEFAULT_FOR_CURRENT_ACCESS", + "COMMENTS_VIEW_MODE_OMITTED", + "COMMENTS_VIEW_MODE_INCLUDED", + ), +) +_DOCS_PREVIEWS = frozenset(_DOCS_SUGGESTIONS_VIEW.names[2:]) + +# The members a tab carries as well as the document, which a mask may not name beside the tabs: +# measured 2026-10-26, each of the twelve was the 400 below under `includeTabsContent=true`. +_DOCS_LEGACY = frozenset(_GOOGLE_FIELDS["docs.Document"]) & frozenset( + _GOOGLE_FIELDS["docs.DocumentTab"] +) + +# A corpus states no tabs, so a Doc is the one tab every Doc starts with: measured 2026-10-09 on a +# one-tab Doc and 2026-10-26 on one `documents.create` made. +_DOCS_TAB = {"tabId": "t.0", "title": "Tab 1", "index": 0} + +_P_DOCS_GET = [ + qp("suggestionsViewMode"), + qp("includeTabsContent", "boolean"), + qp("commentsViewMode"), + qp("fields"), +] + + +def _docs_comment_fields(named: dict) -> bool: + """Whether a mask names a comment-specific field: measured 2026-10-26, `comments`, + `suggestions`, `suggestions.*`, `*.commentId`, `tabs.documentTab.commentAnchors`, + `tabs.*.commentAnchors` and `*.*.commentAnchors` were refused for it, and `*.*`, `*.*.*`, + `tabs` and `tabs.documentTab.*`, which reach them through ``*`` alone, were answered. A child + tab's anchors are one as well, however deep: measured 2026-10-28, + `tabs.childTabs.documentTab.commentAnchors`, `tabs.childTabs.*.commentAnchors`, + `tabs.*.*.commentAnchors`, `tabs.*.*.*.commentAnchors` and + `tabs.childTabs.childTabs.documentTab.commentAnchors` were each refused on a one-tab Doc.""" + + def anchored(tab: dict) -> bool: + return bool(tab) and ( + "commentAnchors" in tab.get("documentTab", {}) or anchored(tab.get("childTabs", {})) + ) + + return "comments" in named or "suggestions" in named or anchored(named.get("tabs", {})) + + +def _docs_refuse_suggestions() -> gerr.GoogleError: + return gerr.GoogleError( + 403, + "You do not have permission to access the document suggestions.", + reason="forbidden", + status="PERMISSION_DENIED", + ) + + +@router.get("/docs/v1/documents/{document_id}", openapi_extra={"parameters": _P_DOCS_GET}) +async def docs_get(document_id: str, request: Request): + """The document as Docs structural elements: its stored body, or one ``paragraph`` per line of + its text when it states none (`backlot.gdocs`), flat or as its one tab. + + The order real answers in, measured 2026-10-26: the credential, the typed values, the mask + (`fields=nope` on an id that does not exist is its 400), the lookup, a reader's suggestions + 403, the `commentsViewMode` 400s, a comment field's 400 and then a legacy field's. Each comes + ahead of the next when both hold, `fields=body,comments&includeTabsContent=true` answering the + comment field's. + + A caller who is not the owner is a reader here (`routers.google._drive_file_capabilities`), the + admin token of every file it did not create among them (`routers.google._drive_owned_by`). A + reader is answered with no `revisionId`, and a `suggestionsViewMode` it does not ask for is + `PREVIEW_WITHOUT_SUGGESTIONS`, where the owner's is `SUGGESTIONS_INLINE`; a reader asking for + `SUGGESTIONS_INLINE` or `PREVIEW_SUGGESTIONS_ACCEPTED` is the 403. A mode asked for is echoed by + its name, its number included. No Doc here holds a suggestion, so a preview serves the same + content.""" + caller = _require(request) + flags = _typed_query( + request, + { + "suggestionsViewMode": lambda raw: _query_enum( + raw, "suggestions_view_mode", _DOCS_SUGGESTIONS_VIEW + ), + "includeTabsContent": lambda raw: _query_bool(raw, "include_tabs_content"), + "commentsViewMode": lambda raw: _query_enum( + raw, "comments_view_mode", _DOCS_COMMENTS_VIEW + ), + }, + ) + tree, named = _gmask_select(gerr.first_repeat(request.query_params, "fields"), "docs.Document") + row = _editor_doc(request, document_id, expect="document") + owned = _drive_owned_by(row["author_email"], caller.email) + + asked = (flags["suggestionsViewMode"] or [None])[-1] + if not owned and asked in ("SUGGESTIONS_INLINE", "PREVIEW_SUGGESTIONS_ACCEPTED"): + raise _docs_refuse_suggestions() + if asked in (None, "DEFAULT_FOR_CURRENT_ACCESS"): + suggestions = "SUGGESTIONS_INLINE" if owned else "PREVIEW_WITHOUT_SUGGESTIONS" + else: + suggestions = asked + + # The tabbed answer is asked for by `includeTabsContent`, or by a mask naming `tabs`: measured + # 2026-10-26, `fields=tabs` and `*.tabProperties` answered it beside `includeTabsContent=false` + # and without it, and `fields=*`, `*.*` and `tabs()` answered the flat document. + tabs = (flags["includeTabsContent"] or [False])[-1] or "tabs" in named + comments = (flags["commentsViewMode"] or ["COMMENTS_VIEW_MODE_UNSPECIFIED"])[-1] + asks_comments = comments in ( + "COMMENTS_VIEW_MODE_DEFAULT_FOR_CURRENT_ACCESS", + "COMMENTS_VIEW_MODE_INCLUDED", + ) + # A mode other than `UNSPECIFIED` needs the tabs, and the preview refusal is the explicit + # preview's: a reader's `DEFAULT_FOR_CURRENT_ACCESS` beside no `suggestionsViewMode`, which it + # is served as a preview, answered 200. + if comments != "COMMENTS_VIEW_MODE_UNSPECIFIED" and not tabs: + raise gerr.invalid_argument( + "Comments view mode may only be specified if tabs content is also requested." + ) + if asks_comments and asked in _DOCS_PREVIEWS: + raise gerr.invalid_argument("Comments may not be requested when previewing suggestions.") + if comments == "COMMENTS_VIEW_MODE_INCLUDED" and asked != "SUGGESTIONS_INLINE": + raise gerr.invalid_argument( + "Comments may only be explicitly included if inline suggestions are also explicitly " + "requested." + ) + # The comment fields follow the mode asked for, not the one answered: a reader's + # `DEFAULT_FOR_CURRENT_ACCESS` is answered `OMITTED` and takes `fields=comments`. + if not asks_comments and _docs_comment_fields(named): + raise gerr.invalid_argument( + "Field mask may not contain comment-specific fields if comments are not requested" + ) + if tabs and _DOCS_LEGACY & named.keys(): + raise gerr.invalid_argument( + "Field mask may not contain legacy text-level Document resource fields while " + "requesting tabs content" + ) + # `INCLUDED` to the owner, who alone here may comment; no Doc here holds a comment, so it + # adds nothing to the answer. + view = ( + "COMMENTS_VIEW_MODE_INCLUDED" if asks_comments and owned else "COMMENTS_VIEW_MODE_OMITTED" + ) + + body = store.gdrive_doc_body_for(auth.conn(request), document_id) + if body is None: + body = gdocs.from_text(row["content"] or "") + content = { + "body": {"content": gdocs.indexed(body)}, + "documentStyle": gdocs.document_style(), + "namedStyles": gdocs.named_styles(), + } + revision = {"revisionId": synth._digest(document_id)[:24]} if owned else {} + # Real's orders, measured 2026-10-16 on twelve Docs and 2026-10-26 on the tabbed answer, the + # same under each mode and whatever order a mask names the members in. Real also serves + # `headers` and `footers` after `body`, `lists` after `namedStyles` and `inlineObjects` before + # `documentId` when a Doc has them; this server serves none of the four. + if tabs: + doc = { + "title": row["title"], + **revision, + "suggestionsViewMode": suggestions, + "documentId": document_id, + "tabs": [{"tabProperties": dict(_DOCS_TAB), "documentTab": content}], + "commentsViewMode": view, + } + else: + doc = { + "title": row["title"], + **content, + **revision, + "suggestionsViewMode": suggestions, + "documentId": document_id, + "commentsViewMode": view, + } + return doc if tree is None else _gmask_apply(tree, doc) diff --git a/backlot/routers/google_sheets.py b/backlot/routers/google_sheets.py index 40f5bf58c..586a018be 100644 --- a/backlot/routers/google_sheets.py +++ b/backlot/routers/google_sheets.py @@ -34,10 +34,12 @@ _DRIVE_ROOT_FOLDER, _drive_owned_by, _editor_doc, + _gmask_apply, + _gmask_select, + _query_bool, + _query_enum, _require, _service_email, - _sheets_bool_value, - _sheets_enum_value, _typed_query, router, ) @@ -97,163 +99,6 @@ def _sheets_grid(content: str | None) -> list[list[str]]: ] -def _sheets_types() -> dict[str, dict[str, str | None]]: - """The messages a Sheets read answers with and every message they reach, each as its fields and - the message each holds (`None` for a scalar, an enum, a map or a list of lists): the `types` - `backlot diff` records in the `google_drive` baseline from the discovery document. A field only - Backlot serves is not one a mask may name.""" - types = json.loads((_BASELINES / "google_drive.json").read_text(encoding="utf-8"))["types"] - - def held(type_: str) -> str | None: - name = type_.strip("[]") - return name if name in types else None - - return { - name: {f: held(m["type"]) for f, m in entry.get("fields", {}).items() if "type" in m} - for name, entry in types.items() - } - - -_SHEETS_FIELDS = _sheets_types() - - -def _gmask_paths(mask: str) -> tuple[list[tuple[str, ...]], str]: - """The paths a ``fields`` mask names, in its order, each as its names, and the bracket it leaves - unpaired (``""`` for none). - - Measured grammar: ``.`` and ``/`` both descend, ``a(b,c)`` groups a sub-selection, ``,`` - separates siblings and a trailing comma is tolerated. A name is matched case-sensitively and is - not trimmed -- `` spreadsheetId`` with a leading space 400s. An empty group names nothing, not - even the name before it: measured 2026-10-22, `a()` answered the whole spreadsheet with no - refusal for `a`, and `spreadsheetId,sheets()` the `spreadsheetId` alone. - - A bracket left unpaired is refused after the paths before it, a ``)`` where it stands and a - ``(`` at the end (`gerr.unbalanced_field_mask`): measured 2026-10-22, `a)` and - `bogus)spreadsheetId` named `a` and `bogus`, `spreadsheetId)bogus` was the `)` refusal, and - `sheets(properties/title,bogus` named `sheets.bogus` where `sheets(properties/title` was the - `(` one.""" - paths: list[tuple[str, ...]] = [] - # the prefix of each open group - stack: list[tuple[str, ...]] = [] - prefix: tuple[str, ...] = () - token = "" - - def names(text: str) -> tuple[str, ...]: - return tuple(text.replace("/", ".").split(".")) - - for ch in mask: - if ch == "(": - stack.append(prefix) - prefix += names(token) if token else () - token = "" - elif ch == ")": - if token: - paths.append(prefix + names(token)) - if not stack: - return paths, ")" - prefix, token = stack.pop(), "" - elif ch == ",": - if token: - paths.append(prefix + names(token)) - token = "" - else: - token += ch - if token: - paths.append(prefix + names(token)) - return paths, "(" if stack else "" - - -def _gmask_resolve(path: tuple[str, ...], message: str | None) -> dict | None: - """What ``path`` selects in ``message`` (`None` for a field that holds no message), or ``None`` - when it names nothing there; ``{}`` is all of it. - - A ``*`` stands for one name. Measured 2026-10-22: `sheets.*.title` is - `sheets.properties.title`, `*.*.title` the same, `*.*.rowCount` refused as naming nothing (the - row count is three names under `sheets`), and `*.data` is `sheets.data`. A ``*`` that ends a - path selects all of what it is under, a field holding no message included, and so does a run - of them: `spreadsheetId.*`, `spreadsheetId.*.*`, a map's `criteria.*` and `*.*.*` answered, - where `spreadsheetId.*.x` names nothing.""" - if not path: - return {} - head, rest = path[0], path[1:] - if message is None: - return {} if all(name == "*" for name in path) else None - fields = _SHEETS_FIELDS[message] - if head == "*": - if not rest: - return {} - out = {} - for name, held in fields.items(): - sub = _gmask_resolve(rest, held) - if sub is not None: - out[name] = sub - return out or None - if head not in fields: - return None - sub = _gmask_resolve(rest, fields[head]) - return None if sub is None else {head: sub} - - -# The messages a mask selects whole once any path names them whole, a narrower path beside it -# notwithstanding. Measured 2026-10-27: `values(userEnteredValue,userEnteredValue.formulaValue)` -# answered a string cell's `stringValue`, and so did the same for `effectiveValue`, an -# `errorValue`'s `message` beside `errorValue.type`, a `backgroundColorStyle`'s `rgbColor` beside -# `backgroundColorStyle.themeColor`, and `themeColors` whole beside `themeColors.colorType`. -_GMASK_WHOLE = frozenset( - {"sheets.ExtendedValue", "sheets.ErrorValue", "sheets.ColorStyle", "sheets.ThemeColorPair"} -) - - -def _gmask_merge(into: dict, selection: dict, message: str | None) -> None: - """Merge one path's selection into the tree, a narrower selection replacing a wider one: - measured 2026-10-22, `spreadsheetId,*` answered `spreadsheetId` alone, and - `sheets,sheets.properties.title` and `sheets.*,sheets.properties.title` the title alone, and - on 2026-10-27 `effectiveFormat,effectiveFormat.numberFormat` and - `properties(defaultFormat,defaultFormat.numberFormat)` the narrower path alone, in either - order. A message in :data:`_GMASK_WHOLE` is the exception, kept whole.""" - fields = _SHEETS_FIELDS.get(message, {}) if message else {} - for name, sub in selection.items(): - held = fields.get(name) - if held in _GMASK_WHOLE and (sub == {} or into.get(name) == {}): - into[name] = {} - else: - _gmask_merge(into.setdefault(name, {}), sub, held) - - -def _gmask_select(mask: str | None, message: str) -> tuple[dict | None, bool]: - """The selection tree a ``fields`` mask makes of a ``message`` response, ``{}`` at a leaf - meaning all of it, and whether the mask names a comment field. ``None`` for no mask, which an - empty one, an empty group and a ``*`` alone each are: measured 2026-10-22, `fields=*` answered - what no mask does, `includeGridData` deciding the grid. - - A path that names nothing is refused, the first such in the mask's order (measured on - `sheets.properties.title,bogus2,sheets.bogus3`: `bogus2`), so a declared field the response - holds no value for answers 200 without it rather than the 400 a field it does not declare gets. - Checked against the whole schema, not against what this module builds: measured 2026-10-22, - `namedRanges`, `sheets(merges,conditionalFormats)` and a cell's `userEnteredFormat` each - answered 200 on a spreadsheet holding none. A bracket left unpaired is refused after the paths - before it (`_gmask_paths`). - - A comment field is one a path reaches by naming something: measured 2026-10-22, `comments`, - `comments.*`, `*.commentId` and `*.headPost` were refused for asking after comments that - `commentsViewMode` did not request, and `*.*`, which reaches `comments` through ``*`` alone, was - answered.""" - if not mask: - return None, False - tree: dict = {} - comments = False - paths, unpaired = _gmask_paths(mask) - for path in paths: - selection = _gmask_resolve(path, message) - if selection is None: - raise gerr.bad_field_mask(mask, ".".join(path)) - comments |= "comments" in selection and any(name != "*" for name in path) - _gmask_merge(tree, selection, message) - if unpaired: - raise gerr.unbalanced_field_mask(mask, unpaired) - return (tree or None), comments - - def _gmask_wants_grid(tree: dict | None, include_grid_data: bool) -> bool: """Whether the cells are served: as `includeGridData` says with no mask, and otherwise when the mask reaches `sheets.data`. @@ -269,60 +114,6 @@ def _gmask_wants_grid(tree: dict | None, include_grid_data: bool) -> bool: return under is not None and (not under or "data" in under) -def _gmask_apply(tree: dict, value): - """Project ``value`` through a selection tree, mapping over a list rather than indexing it — - which is what lets ``sheets.properties.title`` reach into every sheet — and keeping the order - ``value`` has: measured 2026-10-22, `*.title,spreadsheetId` answered `spreadsheetId` first.""" - if not tree: - return value - if isinstance(value, list): - return [_gmask_apply(tree, v) for v in value] - if not isinstance(value, dict): - return value - out = {name: _gmask_apply(tree[name], v) for name, v in value.items() if name in tree} - if "rowData" in out: - _gmask_trim_rows(out) - return out - - -_CELL_FORMATS = frozenset({"userEnteredFormat", "effectiveFormat"}) - - -def _gmask_hollow(value) -> bool: - """Whether a projected message holds nothing but empty messages.""" - return isinstance(value, dict) and all(_gmask_hollow(v) for v in value.values()) - - -def _gmask_trim_rows(block: dict) -> None: - """Cut a projected `GridData` block's rows the way `_sheets_grid_data` cuts them, a cell the - mask left empty counting as an empty cell. Measured 2026-10-22 over `D1:F4` holding `a` in D1 - and `b` in F2: `rowData.values.userEnteredValue` answered - `[{"values": [{…}]}, {"values": [{}, {}, {…}]}]`, and `rowData.values.userEnteredFormat`, which - no cell held, no `rowData` at all. - - A cell's format the mask leaves holding nothing is left out, where every other message is kept - empty. Measured 2026-10-27: `values(userEnteredValue,effectiveFormat.numberFormat)` served no - `effectiveFormat` on a string, a number or a boolean, and neither did - `effectiveFormat.textFormat.link`, while `effectiveValue.numberValue` served a string's - `effectiveValue` as `{}`, and `sheets.properties.tabColorStyle` and - `properties.defaultFormat.numberFormat` served `properties` and `defaultFormat` as `{}`.""" - rows = [] - for row in block["rowData"]: - values = [ - {k: v for k, v in cell.items() if k not in _CELL_FORMATS or not _gmask_hollow(v)} - for cell in row.get("values", []) - ] - while values and not values[-1]: - values.pop() - rows.append({"values": values} if values else {}) - while rows and not rows[-1]: - rows.pop() - if rows: - block["rowData"] = rows - else: - del block["rowData"] - - def _sheets_alt(request: Request) -> None: """Refuse an `alt` that names no format, ahead of everything else a Sheets read checks: measured 2026-10-22, `alt=zzz` answered its 400 with no credential, with a bad one, beside a bad @@ -339,7 +130,7 @@ def _sheets_alt(request: Request) -> None: ) -def _sheets_mask(request: Request, message: str) -> tuple[dict | None, bool]: +def _sheets_mask(request: Request, message: str) -> tuple[dict | None, dict]: """`_gmask_select` over this request's ``fields``. A read checks it once its typed values and body are read and before the spreadsheet is looked up: measured 2026-10-22 on the five reads, a mask naming nothing answered its 400 ahead of a missing spreadsheet's 404, a range that does @@ -398,16 +189,12 @@ def _sheets_respond(request: Request, body: dict, tree: dict | None) -> Response # The typed query values `spreadsheets.get` and `:getByDataFilter` read. _SHEETS_GET_FLAGS = { - "includeGridData": lambda raw: _sheets_bool_value(raw, "include_grid_data"), - "excludeTablesInBandedRanges": lambda raw: _sheets_bool_value( - raw, "exclude_tables_in_banded_ranges" - ), + "includeGridData": lambda raw: _query_bool(raw, "include_grid_data"), + "excludeTablesInBandedRanges": lambda raw: _query_bool(raw, "exclude_tables_in_banded_ranges"), # Read as `documents.get` reads its own (`_DOCS_COMMENTS_VIEW`), measured 2026-10-22: `03`, # `+1` and `comments_view_mode_included` read, `4`, `-1`, `BOGUS` and empty refused, and of # several the last decides. - "commentsViewMode": lambda raw: _sheets_enum_value( - raw, "comments_view_mode", _PJ_COMMENTS_VIEW - ), + "commentsViewMode": lambda raw: _query_enum(raw, "comments_view_mode", _PJ_COMMENTS_VIEW), } @@ -425,11 +212,11 @@ async def sheets_get(spreadsheet_id: str, request: Request): _sheets_alt(request) caller = _require(request) flags = _typed_query(request, _SHEETS_GET_FLAGS) - tree, comments = _sheets_mask(request, "sheets.Spreadsheet") + tree, named = _sheets_mask(request, "sheets.Spreadsheet") row, sheets = _workbook(request, spreadsheet_id) mode = (flags["commentsViewMode"] or [_PJ_COMMENTS_VIEW.names[0]])[-1] owned = _drive_owned_by(row["author_email"], caller.email) - view = _sheets_comments_view(_PJ_COMMENTS_VIEW.names.index(mode), comments, owned) + view = _sheets_comments_view(_PJ_COMMENTS_VIEW.names.index(mode), "comments" in named, owned) # A mask that reaches the cells decides the grid, and `includeGridData` is then ignored rather # than consulted -- the vendor's own wording. Still parsed, so a bad value is still refused. grid = _gmask_wants_grid(tree, (flags["includeGridData"] or [False])[-1]) @@ -1252,18 +1039,16 @@ def _sheets_options(request: Request) -> tuple[str, str, str]: enums = _typed_query( request, { - "majorDimension": lambda raw: _sheets_enum_value(raw, "major_dimension", _PJ_DIMENSION), + "majorDimension": lambda raw: _query_enum(raw, "major_dimension", _PJ_DIMENSION), # Measured over typed cells: FORMATTED_VALUE gives the display string "12", # UNFORMATTED_VALUE the JSON number 12, and FORMULA the same raw value as # UNFORMATTED_VALUE for every cell that is not a formula. A spreadsheet whose cells are # lines of stored text has only strings, so all three agree on one; a spreadsheet that # STATES its grid does not. - "valueRenderOption": lambda raw: _sheets_enum_value( - raw, "value_render_option", _PJ_RENDER - ), + "valueRenderOption": lambda raw: _query_enum(raw, "value_render_option", _PJ_RENDER), # A date, time or date-time cell's serial number or its display string. Only a written # cell can be one: a corpus states no number formats. - "dateTimeRenderOption": lambda raw: _sheets_enum_value( + "dateTimeRenderOption": lambda raw: _query_enum( raw, "date_time_render_option", _PJ_DATETIME ), }, @@ -1769,11 +1554,9 @@ async def sheets_values_batch_get_by_data_filter(spreadsheet_id: str, request: R query, body = await _sheets_query_and_body( request, { - "majorDimension": lambda raw: _sheets_enum_value(raw, "major_dimension", _PJ_DIMENSION), - "valueRenderOption": lambda raw: _sheets_enum_value( - raw, "value_render_option", _PJ_RENDER - ), - "dateTimeRenderOption": lambda raw: _sheets_enum_value( + "majorDimension": lambda raw: _query_enum(raw, "major_dimension", _PJ_DIMENSION), + "valueRenderOption": lambda raw: _query_enum(raw, "value_render_option", _PJ_RENDER), + "dateTimeRenderOption": lambda raw: _query_enum( raw, "date_time_render_option", _PJ_DATETIME ), }, @@ -1856,7 +1639,7 @@ async def sheets_get_by_data_filter(spreadsheet_id: str, request: Request): _sheets_alt(request) caller = _require(request) query, body = await _sheets_query_and_body(request, _SHEETS_GET_FLAGS, _PJ_GET_BY_FILTER) - tree, comments = _sheets_mask(request, "sheets.Spreadsheet") + tree, named = _sheets_mask(request, "sheets.Spreadsheet") row, sheets = _workbook(request, spreadsheet_id) selections = _sheets_selections(body.get("data_filters", []), sheets, values_level=False) owned = _drive_owned_by(row["author_email"], caller.email) @@ -1864,7 +1647,7 @@ async def sheets_get_by_data_filter(spreadsheet_id: str, request: Request): mode = ( body.get("comments_view_mode", 0) if mode is None else _PJ_COMMENTS_VIEW.names.index(mode) ) - view = _sheets_comments_view(mode, comments, owned) + view = _sheets_comments_view(mode, "comments" in named, owned) # Filters that cover the same cells share one `data` block, placed where the first of them was # sent, measured 2026-10-04 on the pairs with cells `sheets_values_batch_get_by_data_filter` # lists and on one empty range sent twice. Two empty ranges in different places stay two @@ -1964,26 +1747,18 @@ def _sheets_write_readers(*, append: bool = False) -> dict: gets it rather than its 403. ``append`` adds the `insertDataOption` only `values.append` declares.""" extra = ( - { - "insertDataOption": lambda raw: _sheets_enum_value( - raw, "insert_data_option", _PJ_INSERT_DATA - ) - } + {"insertDataOption": lambda raw: _query_enum(raw, "insert_data_option", _PJ_INSERT_DATA)} if append else {} ) return { **extra, - "valueInputOption": lambda raw: _sheets_enum_value( - raw, "value_input_option", _PJ_VALUE_INPUT - ), - "includeValuesInResponse": lambda raw: _sheets_bool_value( - raw, "include_values_in_response" - ), - "responseValueRenderOption": lambda raw: _sheets_enum_value( + "valueInputOption": lambda raw: _query_enum(raw, "value_input_option", _PJ_VALUE_INPUT), + "includeValuesInResponse": lambda raw: _query_bool(raw, "include_values_in_response"), + "responseValueRenderOption": lambda raw: _query_enum( raw, "response_value_render_option", _PJ_RENDER ), - "responseDateTimeRenderOption": lambda raw: _sheets_enum_value( + "responseDateTimeRenderOption": lambda raw: _query_enum( raw, "response_date_time_render_option", _PJ_DATETIME ), } diff --git a/tests/test_google.py b/tests/test_google.py index 7518a652e..5bb71dcf5 100644 --- a/tests/test_google.py +++ b/tests/test_google.py @@ -6973,31 +6973,133 @@ def para(start, end, runs, style="NORMAL_TEXT"): assert list(newline["textRun"]["textStyle"]) == ["bold", "italic", "strikethrough"] -@pytest.mark.parametrize( - "params", - [{}, {"suggestionsViewMode": "PREVIEW_WITHOUT_SUGGESTIONS"}, {"includeTabsContent": "true"}], - ids=["plain", "preview-without-suggestions", "tabs-content"], -) -def test_docs_get_serves_members_in_reals_order(base, admin_h, params): - """A document read serves its members in real's order, `commentsViewMode` last with the value - real serves. The tabbed shape `includeTabsContent=true` selects is not served, so that row - checks the last member alone. +_DOCS_FLAT = [ + "title", + "body", + "documentStyle", + "namedStyles", + "revisionId", + "suggestionsViewMode", + "documentId", + "commentsViewMode", +] +_DOCS_TABBED = [ + "title", + "revisionId", + "suggestionsViewMode", + "documentId", + "tabs", + "commentsViewMode", +] +_DOCS_TAB = {"tabId": "t.0", "title": "Tab 1", "index": 0} +_INLINE, _PREVIEW = "SUGGESTIONS_INLINE", "PREVIEW_WITHOUT_SUGGESTIONS" +_OMITTED, _INCLUDED = "COMMENTS_VIEW_MODE_OMITTED", "COMMENTS_VIEW_MODE_INCLUDED" +_TABS = {"includeTabsContent": "true"} +_DEFAULT_COMMENTS = {"commentsViewMode": "COMMENTS_VIEW_MODE_DEFAULT_FOR_CURRENT_ACCESS"} + + +@pytest.fixture(scope="module") +def document_callers(tokens_yaml): + """The SAMPLE document's owner, and a user it is visible to, whom every grant here makes a + reader, as headers.""" + tokens = {u["email"]: u["token"] for u in tokens_yaml["users"]} + return { + who: {"Authorization": f"Bearer {tokens[email]}"} + for who, email in (("owner", "mia@acme.com"), ("reader", "cfo@acme.com")) + } + + +# `(caller, query, members, suggestionsViewMode, commentsViewMode)` per measured read. +_DOCS_MEMBER_ROWS = [ + ("owner", {}, _DOCS_FLAT, _INLINE, _OMITTED), + ( + "owner", + {"suggestionsViewMode": "DEFAULT_FOR_CURRENT_ACCESS"}, + _DOCS_FLAT, + _INLINE, + _OMITTED, + ), + ("owner", {"suggestionsViewMode": _PREVIEW}, _DOCS_FLAT, _PREVIEW, _OMITTED), + ( + "owner", + {"suggestionsViewMode": "2"}, + _DOCS_FLAT, + "PREVIEW_SUGGESTIONS_ACCEPTED", + _OMITTED, + ), + ("owner", {"includeTabsContent": "false"}, _DOCS_FLAT, _INLINE, _OMITTED), + ( + "owner", + [("suggestionsViewMode", "1"), ("suggestionsViewMode", "3")], + _DOCS_FLAT, + _PREVIEW, + _OMITTED, + ), + ( + "owner", + [("includeTabsContent", "true"), ("includeTabsContent", "false")], + _DOCS_FLAT, + _INLINE, + _OMITTED, + ), + ( + "owner", + {"commentsViewMode": "COMMENTS_VIEW_MODE_UNSPECIFIED"}, + _DOCS_FLAT, + _INLINE, + _OMITTED, + ), + ("owner", _TABS, _DOCS_TABBED, _INLINE, _OMITTED), + ("owner", {**_TABS, **_DEFAULT_COMMENTS}, _DOCS_TABBED, _INLINE, _INCLUDED), + ( + "owner", + {**_TABS, "commentsViewMode": "3", "suggestionsViewMode": _INLINE}, + _DOCS_TABBED, + _INLINE, + _INCLUDED, + ), + ("reader", {}, _DOCS_FLAT, _PREVIEW, _OMITTED), + ("reader", {"suggestionsViewMode": "0"}, _DOCS_FLAT, _PREVIEW, _OMITTED), + ("reader", {**_TABS, **_DEFAULT_COMMENTS}, _DOCS_TABBED, _PREVIEW, _OMITTED), + ("admin", _TABS, _DOCS_TABBED, _PREVIEW, _OMITTED), +] + + +def test_docs_get_serves_members_in_reals_order(client, tokens_yaml, document_callers, document_id): + """A document read serves its members in real's order, flat or as its one tab, the tab holding + what the flat read holds. A reader, the admin token on a corpus file among them, is served no + `revisionId` and a preview of the suggestions unless it asks for another mode, and comments to + the owner alone. """ - fid, _ = _drive_by_mime(base, admin_h, "application/vnd.google-apps.document") - r = httpx.get(f"{base}/docs/v1/documents/{fid}", headers=admin_h, params=params) - assert r.status_code == 200 - assert list(r.json().items())[-1] == ("commentsViewMode", "COMMENTS_VIEW_MODE_OMITTED") - if "includeTabsContent" not in params: - assert list(r.json()) == [ - "title", - "body", - "documentStyle", - "namedStyles", - "revisionId", - "suggestionsViewMode", - "documentId", - "commentsViewMode", - ] + callers = { + **document_callers, + "admin": {"Authorization": f"Bearer {tokens_yaml['admin_token']}"}, + } + url = f"/docs/v1/documents/{document_id}" + flat = {who: client.get(url, headers=h).json() for who, h in callers.items()} + + def answer(who, params): + r = client.get(url, headers=callers[who], params=params) + doc = r.json() + views = (doc.get("suggestionsViewMode"), doc.get("commentsViewMode")) + return (r.status_code, list(doc), *views, doc.get("tabs")) + + def tabs(who): + held = {k: flat[who][k] for k in ("body", "documentStyle", "namedStyles")} + return [{"tabProperties": _DOCS_TAB, "documentTab": held}] + + assert { + f"{who} {urlencode(params)}": answer(who, params) for who, params, *_ in _DOCS_MEMBER_ROWS + } == { + f"{who} {urlencode(params)}": ( + 200, + [m for m in members if m != "revisionId" or who == "owner"], + suggestions, + comments, + tabs(who) if "tabs" in members else None, + ) + for who, params, members, suggestions, comments in _DOCS_MEMBER_ROWS + } _DOCS_SUGGESTIONS = "type.googleapis.com/google.apps.docs.v1.SuggestionsViewMode" @@ -7126,12 +7228,13 @@ def document_id(client, tokens_yaml): def test_documents_get_reads_a_query_value_real_reads_and_refuses_one_it_cannot( - client, tokens_yaml, document_id + client, document_callers, document_id ): - """The spellings `docs_get` records. A repeat holding a refused value is that value's 400 - whichever end it is at; two values that are read are served. At `$.xgafv=1` the refusal's - `errors` entry is the one `backlot.errors.google.invalid_field_value` records.""" - headers = {"Authorization": f"Bearer {tokens_yaml['admin_token']}"} + """The spellings `docs_get` records, read by the document's owner, whom every mode is served + to. A repeat holding a refused value is that value's 400 whichever end it is at; two values that + are read are served. At `$.xgafv=1` the refusal's `errors` entry is the one + `backlot.errors.google.invalid_field_value` records.""" + headers = document_callers["owner"] def answer(query): r = client.get(f"/docs/v1/documents/{document_id}?{urlencode(query)}", headers=headers) @@ -7166,14 +7269,21 @@ def answer(query): ("suggestionsViewMode=NOPE", "admin", "missing", 400, "suggestions_view_mode"), ("includeTabsContent=NOPE", "admin", "missing", 400, "include_tabs_content"), ("commentsViewMode=NOPE", "admin", "missing", 400, "comments_view_mode"), + ("fields=nope&suggestionsViewMode=NOPE", "admin", "missing", 400, "suggestions_view_mode"), + ("fields=nope", "admin", "missing", 400, "path 'nope'"), + ("fields=title", "admin", "missing", 404, "Requested entity"), + ("commentsViewMode=COMMENTS_VIEW_MODE_OMITTED", "admin", "missing", 404, "Requested"), + ("fields=comments", "admin", "missing", 404, "Requested entity"), + ("fields=body&includeTabsContent=true", "admin", "missing", 404, "Requested entity"), ], ) def test_documents_get_answers_a_bad_query_after_the_credential_and_before_the_lookup( client, tokens_yaml, document_id, query, who, target, status, needle ): """A bad token or none is the 401, and `$.xgafv=3` is that parameter's 400, ahead of a value - `docs_get` cannot read. On an id that does not exist the same value is the 400, where the - request without it is the 404.""" + `docs_get` cannot read, and that value ahead of a mask naming nothing. On an id that does not + exist either is the 400, where the request without it is the 404; the refusals of a mode or a + mask real can read come after the lookup.""" headers = { "none": {}, "bad": BAD_TOKEN, @@ -7191,6 +7301,227 @@ def test_documents_get_answers_a_bad_query_after_the_credential_and_before_the_l assert client.get(f"/docs/v1/documents/{document}", headers=headers).status_code == 404 +_TABS_ONLY = "Comments view mode may only be specified if tabs content is also requested." +_INLINE_ONLY = ( + "Comments may only be explicitly included if inline suggestions are also explicitly requested." +) +_PREVIEWING = "Comments may not be requested when previewing suggestions." +_COMMENT_FIELD = "Field mask may not contain comment-specific fields if comments are not requested" +_LEGACY_FIELD = ( + "Field mask may not contain legacy text-level Document resource fields while requesting tabs " + "content" +) +_NO_SUGGESTIONS = "You do not have permission to access the document suggestions." + + +# `(caller, query, status, message)` per measured refusal, the last rows two that both hold. +_DOCS_VIEW_REFUSALS = [ + ("reader", "suggestionsViewMode=SUGGESTIONS_INLINE", 403, _NO_SUGGESTIONS), + ("reader", "suggestionsViewMode=PREVIEW_SUGGESTIONS_ACCEPTED", 403, _NO_SUGGESTIONS), + ("owner", "commentsViewMode=COMMENTS_VIEW_MODE_OMITTED", 400, _TABS_ONLY), + ( + "owner", + "commentsViewMode=COMMENTS_VIEW_MODE_OMITTED&includeTabsContent=false", + 400, + _TABS_ONLY, + ), + ("owner", "commentsViewMode=COMMENTS_VIEW_MODE_OMITTED&fields=title", 400, _TABS_ONLY), + ("owner", "commentsViewMode=0&commentsViewMode=2", 400, _TABS_ONLY), + ("owner", "includeTabsContent=true&commentsViewMode=3", 400, _INLINE_ONLY), + ( + "owner", + "includeTabsContent=true&commentsViewMode=3&suggestionsViewMode=0", + 400, + _INLINE_ONLY, + ), + ( + "owner", + "includeTabsContent=true&commentsViewMode=3&suggestionsViewMode=2", + 400, + _PREVIEWING, + ), + ( + "owner", + "includeTabsContent=true&commentsViewMode=1&suggestionsViewMode=3", + 400, + _PREVIEWING, + ), + ( + "reader", + "includeTabsContent=true&commentsViewMode=1&suggestionsViewMode=3", + 400, + _PREVIEWING, + ), + ("owner", "fields=comments", 400, _COMMENT_FIELD), + ("owner", "fields=title,suggestions", 400, _COMMENT_FIELD), + ("owner", "fields=*.commentId", 400, _COMMENT_FIELD), + ("owner", "fields=tabs.*.commentAnchors", 400, _COMMENT_FIELD), + ("owner", "fields=tabs.childTabs.documentTab.commentAnchors", 400, _COMMENT_FIELD), + ("owner", "fields=tabs.*.*.commentAnchors", 400, _COMMENT_FIELD), + ("owner", "fields=tabs.childTabs.childTabs.documentTab.commentAnchors", 400, _COMMENT_FIELD), + ( + "owner", + "fields=comments&includeTabsContent=true&commentsViewMode=2", + 400, + _COMMENT_FIELD, + ), + ("owner", "fields=body&includeTabsContent=true", 400, _LEGACY_FIELD), + ("owner", "fields=footnotes&includeTabsContent=true", 400, _LEGACY_FIELD), + ("owner", "fields=*.content,tabs", 400, _LEGACY_FIELD), + ("owner", "fields=tabs.documentTab.body,body&includeTabsContent=true", 400, _LEGACY_FIELD), + # each ahead of the next when both hold + ( + "reader", + "commentsViewMode=COMMENTS_VIEW_MODE_OMITTED&suggestionsViewMode=SUGGESTIONS_INLINE", + 403, + _NO_SUGGESTIONS, + ), + ( + "reader", + "fields=body&includeTabsContent=true&suggestionsViewMode=SUGGESTIONS_INLINE", + 403, + _NO_SUGGESTIONS, + ), + ("owner", "commentsViewMode=3&suggestionsViewMode=3", 400, _TABS_ONLY), + ("owner", "commentsViewMode=COMMENTS_VIEW_MODE_OMITTED&fields=comments", 400, _TABS_ONLY), + ("owner", "includeTabsContent=true&commentsViewMode=3&fields=comments", 400, _INLINE_ONLY), + ("owner", "includeTabsContent=true&commentsViewMode=3&fields=body", 400, _INLINE_ONLY), + ("owner", "fields=body,comments&includeTabsContent=true", 400, _COMMENT_FIELD), +] + + +def test_documents_get_refuses_the_view_real_refuses(client, document_callers, document_id): + """A reader asking to see suggestions, a comments mode without the tabs, comments beside a + suggestions mode other than inline, a comment field the mode did not ask for, and a field a + tab carries beside the tabs, each refused as real refuses it and in its order. At `$.xgafv=1` + each `errors` entry is under `global`.""" + url = f"/docs/v1/documents/{document_id}" + + def answer(who, query): + r = client.get(f"{url}?{query}", headers=document_callers[who]) + verbose = client.get(f"{url}?{query}&$.xgafv=1", headers=document_callers[who]) + return (r.status_code, r.json(), verbose.json()["error"].get("errors")) + + status = {400: "INVALID_ARGUMENT", 403: "PERMISSION_DENIED"} + reason = {400: "badRequest", 403: "forbidden"} + assert {f"{who} {query}": answer(who, query) for who, query, *_ in _DOCS_VIEW_REFUSALS} == { + f"{who} {query}": ( + code, + {"error": {"code": code, "message": message, "status": status[code]}}, + [{"message": message, "domain": "global", "reason": reason[code]}], + ) + for who, query, code, message in _DOCS_VIEW_REFUSALS + } + + +def _bad_docs_mask(field, description): + return { + "error": { + "code": 400, + "message": "Request contains an invalid argument.", + "status": "INVALID_ARGUMENT", + "details": [ + { + "@type": "type.googleapis.com/google.rpc.BadRequest", + "fieldViolations": [{"field": field, "description": description}], + } + ], + } + } + + +def _no_match(mask, path): + return _bad_docs_mask( + mask, f"Error expanding 'fields' parameter. Cannot find matching fields for path '{path}'." + ) + + +# `(caller, query, answer)` per measured mask: the body, its members in order, or the refusal. +_DOCS_MASKS = [ + ("owner", "fields=title", {"title": "Brand guidelines v3"}), + ("owner", "fields=documentId,title", ["title", "documentId"]), + ("owner", "fields=,title,,documentId,", ["title", "documentId"]), + ("owner", "fields=title&fields=nope", ["title"]), + ("owner", "fields=*", _DOCS_FLAT), + ("owner", "fields=", _DOCS_FLAT), + ("owner", "fields=*.*", _DOCS_FLAT), + ("owner", "fields=tabs()", _DOCS_FLAT), + ("owner", "fields=body.content.endIndex", ["body"]), + ("owner", "fields=documentStyle.pageSize", ["documentStyle"]), + ("owner", "fields=tabs", ["tabs"]), + ("owner", "fields=tabs,title", ["title", "tabs"]), + ("owner", "fields=tabs&includeTabsContent=false", ["tabs"]), + ("owner", "fields=*.tabProperties", {"tabs": [{"tabProperties": _DOCS_TAB}]}), + ("owner", "fields=tabs.childTabs", {"tabs": [{}]}), + ("owner", "fields=tabs.documentTab.*", ["tabs"]), + ("owner", "fields=*&includeTabsContent=true", _DOCS_TABBED), + ("owner", "fields=documentId,title&includeTabsContent=true", ["title", "documentId"]), + ("owner", "fields=title,body()&includeTabsContent=true", ["title"]), + ( + "owner", + "fields=comments&includeTabsContent=true&commentsViewMode=3&suggestionsViewMode=1", + {}, + ), + ("reader", "fields=comments&includeTabsContent=true&commentsViewMode=1", {}), + ( + "owner", + "fields=tabs.childTabs.documentTab.commentAnchors&includeTabsContent=true" + "&commentsViewMode=1", + {"tabs": [{}]}, + ), + ("reader", "fields=title,revisionId", ["title"]), + ("owner", "fields=nope", _no_match("nope", "nope")), + ("owner", "fields=nope&fields=title", _no_match("nope", "nope")), + ("owner", "fields=title,nope", _no_match("title,nope", "nope")), + ("owner", "fields=Title", _no_match("Title", "Title")), + ("owner", "fields= title", _no_match(" title", " title")), + ("owner", "fields=*.tabId", _no_match("*.tabId", "*.tabId")), + ("owner", "fields=nope,tabs", _no_match("nope,tabs", "nope")), + ("owner", "fields=body,nope&includeTabsContent=true", _no_match("body,nope", "nope")), + ( + "owner", + "fields=nope&commentsViewMode=COMMENTS_VIEW_MODE_OMITTED", + _no_match("nope", "nope"), + ), + ( + "owner", + "fields=body(content", + _bad_docs_mask( + "body(content", + "Invalid FieldMask 'body(content'. Cannot find matching ')' for all '('.", + ), + ), + ( + "owner", + "fields=title)", + _bad_docs_mask( + "title)", "Invalid FieldMask 'title)'. Cannot find matching '(' for all ')'." + ), + ), +] + + +def test_documents_get_trims_to_a_fields_mask_as_real_does(client, document_callers, document_id): + """A mask trims the answer to what it names, in the order real serves the members whatever + order the mask names them in, and selects the tabbed answer when it names `tabs`. The first + `fields` is the one read. A path naming nothing, or a bracket left unpaired, is the 400 whose + detail names the whole mask.""" + url = f"/docs/v1/documents/{document_id}" + + def answer(who, query, expected): + r = client.get(f"{url}?{query}", headers=document_callers[who]) + if r.status_code != 200 or isinstance(expected, dict): + return r.json() + return list(r.json()) + + assert {f"{who} {query}": answer(who, query, want) for who, query, want in _DOCS_MASKS} == { + f"{who} {query}": want for who, query, want in _DOCS_MASKS + } + whole = client.get(url, headers=document_callers["owner"]).json()["body"]["content"] + ends = client.get(f"{url}?fields=body.content.endIndex", headers=document_callers["owner"]) + assert ends.json() == {"body": {"content": [{"endIndex": el["endIndex"]} for el in whole]}} + + def test_sheets_get_withholds_grid_data_by_default(base, admin_h): """Measured: a plain `spreadsheets.get` returns `sheets[i].properties` and NO `data` — on a real workbook that is 4 KB against 5.7 MB with `includeGridData=true`. Volunteering the full grid on