feat: add markings support for datasets, connections, and videos - #930
feat: add markings support for datasets, connections, and videos#930drake-nominal wants to merge 13 commits into
Conversation
Review found this private method was unreachable (search_markings calls the module-level _search_markings directly) and, unlike every analogous _iter_search_x wrapper in this file, shared its exact name with the module-level function it delegated to -- a latent infinite-recursion trap for a future "add self." edit. Removed the method along with the now-unused markings_pb2 and _iter_search_markings imports.
Address final whole-branch review findings on the markings feature: - Note in create-path docstrings that markings are applied after resource creation, so creation and marking are not atomic (create_dataset, create_video, create_dataset_with_uuid, create_derived_dataset). - Add tests pinning that Video and StreamingConnection (via DataSource) are actually wired to MarkableMixin, since nothing else would catch that wiring being dropped. - Replace the marking update() sentinel with a Marking._NotProvided class, matching the existing Channel._NotProvided convention, so the parameter type is accurate. - Make a vacuous e2e pass report as skipped instead. - Hoist repeated test-level imports and helpers to module scope in tests/core/test_marking.py.
|
Claude finished @drake-nominal's task in 5m 34s —— View job Review: markings supportSolid, well-shaped addition. The design decisions that matter are the right ones: Five inline comments. The one that matters:
The rest: an unverified "case-insensitive" claim on Minor, not worth their own threads:
Unit test coverage is genuinely good — Static review only — I did not run tests, ruff, or mypy; CI covers those. |
Fetching markings by RID is served by get_marking and get_marking_by_id; the batch form had no caller. Removes the module-level _get_markings it delegated to, which is now unreachable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| Note: | ||
| Markings are scoped to an organization rather than a workspace, so this search takes no | ||
| workspace filter. Archived markings are never returned; fetch them by RID with | ||
| `get_marking` instead. |
There was a problem hiding this comment.
"Case-insensitive" — what backs this? The query field is id_exact_substring_search, and marking ids are already constrained to lowercase by _validate_marking_id, so the case story is unobservable from the client. #900 was specifically about mis-stated search semantics; either confirm the backend lowercases before matching or drop the qualifier.
| s3_path: str, | ||
| workspace_rid: str | None, | ||
| tags: Mapping[str, str] | None, | ||
| marking_rids: Sequence[str] | None = None, |
There was a problem hiding this comment.
_construct_new_ingest_options has no callers — only its definition and tests/core/test_marking.py:368 reference it. This adds a parameter no code path can reach, plus a unit test whose own docstring concedes "this builder has no callers yet." Drop both; wire the parameter when a caller exists, and let that caller's test cover it.
| markings: If present, markings (or marking RIDs) to apply to the created dataset. Markings are | ||
| applied in a separate step after the dataset is created, so creation and marking are not | ||
| atomic; if that matters, verify with `list_markings()`. |
There was a problem hiding this comment.
This "not atomic" note is wrong, and it's repeated on create_dataset_with_uuid, NominalClient.create_dataset (client.py L868-870), and NominalClient.create_video (L912-914). Every one of those passes marking_rids straight into the create request (L64 here); nothing calls apply_markings afterwards — grep apply_markings nominal/ only hits the mixin definition. Creation is atomic, so instructing users to verify with list_markings() after every create is a real cost for a hazard that doesn't exist.
| """Listing markings on a data source succeeds; a fresh dataset typically carries none.""" | ||
| markings = ingested_dataset.list_markings() | ||
|
|
||
| assert all(marking.rid for marking in markings) |
There was a problem hiding this comment.
all(...) over a list that is expected to be empty asserts nothing — this passes identically if list_markings returns [], and so does the analogous assert all(...) in test_search_markings_returns_unarchived_markings. The only real signal is "the RPC didn't raise". Either say that (assert ingested_dataset.list_markings() == [] for a fresh dataset, which is a genuine claim) or drop the assertion and keep the call as an explicit smoke check.
Color.create checks the hex code and is the constructor users should reach for, matching Symbol's icon/emoji/image classmethods. _from_proto now reads back exactly what the server sent rather than lowercasing it, since validation no longer sits on the read path. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| origin_metadata=scout_catalog.DatasetOriginMetadata(), | ||
| workspace=client._clients.resolve_default_workspace_rid(), | ||
| marking_rids=[], | ||
| marking_rids=[] if markings is None else [rid_from_instance_or_string(m) for m in markings], |
There was a problem hiding this comment.
Marking | str → RID coercion is now open-coded at five call sites (here, _derived_datasets.py L64, client.py L883/L928/L1207) in two spellings that disagree on the empty case ([] if markings is None else [...] vs None if markings is None else [...]). Export one _marking_rids(markings: Sequence[Marking | str] | None) -> list[str] from nominal/core/marking.py and call it at each site: the create paths then read identically and the None-vs-[] question is answered once, in the module that owns the concept.
_get_marking went through the batch endpoint with a one-element list and hand-rolled the not-found case, which conflated a missing marking with one the caller cannot read: batch get silently omits both. The single get reports them distinctly, so an unreadable marking now raises NominalPermissionDeniedError instead of NominalNotFoundError. Also notes on Color.hex_code that validation lives in Color.create. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Color dataclass wrapped a single field over a single-arm oneof, so it carried no tag and bought no clarity. It also treated color differently from a marking's id, which is a format-constrained string validated at the call boundary; color now works the same way. Symbol keeps its type: it spans three oneof arms, so flattening it would be lossy. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds support for markings — the access-control primitive that restricts a data source to users in the marking's authorized groups. Markings were previously reachable only as raw RID strings on
create_dataset; this makes them a first-class resource.What's new
Marking— a frozen dataclass withrid,id(a slug unique within the organization),description,symbol,color,created_at,updated_at,is_archived, plusupdate(),archive(),unarchive(),refresh(), andauthorized_group_rids().Symbol— display metadata, built through classmethod constructors:Symbol.icon(...),Symbol.emoji(...),Symbol.image(...). A marking's color is a plain lowercase hex string ("#cc0000"), validated at the call boundary the same way itsidis.Markings on resources —
Dataset,Connection,StreamingConnection, andVideogain four methods from a shared mixin:Client methods —
create_marking,get_marking,get_marking_by_id,search_markings.Markings at creation —
markings=oncreate_dataset,create_streaming_connection,create_video, and the experimental dataset helpers. AcceptsMarkinginstances or RID strings; the existing string form is unchanged.Notes for reviewers
search_markingsintentionally takes noworkspaceorarchive_statusargument — markings are organization-scoped, and archived markings are excluded from search regardless. Results are oldest-first; no sort option is exposed.Markingcarries noname— only theidslug.get_markinguses the single-get endpoint rather than a one-element batch, so a marking that is missing and one the caller cannot read raise distinct errors (NominalNotFoundErrorvsNominalPermissionDeniedError) instead of both reporting as not-found.applied_attimestamps.Testing
694 unit tests pass;
just checkand strict mypy are clean. End-to-end coverage is read-only (search_markings,list_markings) because the write paths require organization-admin rights the e2e token isn't guaranteed to have — those paths have unit coverage only.Suggest squash-merging: several commits here are intra-branch cleanups of private helpers that never shipped, and they would otherwise appear in the changelog.
🤖 Generated with Claude Code