Skip to content

Commit ad0c7a0

Browse files
drernieclaude
andcommitted
feat: full EntryLink type map + classify_links; fix entity set (#389)
Generalize the discovery layer from entities-only to the full EntryLink enum (18 types from test/openapi.yaml), per #389. - classify_links(entry): surfaces ALL note links, each labeled with a LinkCategory (entity/inventory/reference/metadata/not_packageable/uncertain/ external/unknown). Consumers filter, e.g. `r.is_packageable`. Unknown/future types surface as UNKNOWN rather than being silently dropped. - LINK_TYPE_CATEGORY: type -> category for all 18 tokens; PACKAGEABLE_CATEGORIES. - Fix entity set: ENTITY_LINK_TYPES now {custom_entity, dna_sequence, aa_sequence, batch}. Adds `batch` (a real registry entity, was missed); drops dna_oligo/rna_oligo (NOT EntryLink types -- can't appear as note links). - spec/entry-link-types.json: human-facing reference map (category, packageable, id prefix, GET endpoint, webhook events) for all 18 types, plus the not-inline-linkable resources. A test asserts its categories match the module so it can't drift. 26 unit tests; black/isort/pyright clean. Refs #143 #389 #68 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 1ce7f24 commit ad0c7a0

3 files changed

Lines changed: 301 additions & 23 deletions

File tree

docker/src/entry_references.py

Lines changed: 127 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,82 @@
11
"""Extract typed references out of a Benchling entry's structured data.
22
3-
A Benchling entry can point at other Benchling objects in three places:
3+
A Benchling entry points at other Benchling objects in three places:
44
5-
1. Note links -- ``days[].notes[].links[]``, each ``{id, type, webURL}``. Entity
6-
mentions appear as ``custom_entity`` / ``dna_sequence`` / ``aa_sequence`` /
7-
``dna_oligo`` / ``rna_oligo``; the same list also carries non-entity types
8-
(e.g. ``sql_dashboard``), so callers must filter by ``type``.
5+
1. Note links -- ``days[].notes[].links[]``, each ``{id, type, webURL}``. ``type``
6+
is a closed enum (``EntryLink.type`` in the Benchling OpenAPI spec) of 18
7+
tokens spanning entities, inventory, references, dashboards, and plain
8+
external hyperlinks -- see :data:`LINK_TYPE_CATEGORY`.
99
2. Entity-link fields -- ``fields[name]`` whose ``type`` mentions ``entity``,
1010
carrying one or more entity IDs directly in ``value``.
1111
3. Results tables -- ``results_table`` notes carrying an ``assayResultSchemaId``
1212
(the discovery site for assay results, issues #68/#69).
1313
1414
This module is pure: it operates on the entry dict already fetched by
1515
``EntryPackager`` and makes no Benchling API calls. Resolving each reference to a
16-
full record (``get_by_id`` / ``bulk_get``) is the caller's job.
16+
full record (``get_by_id`` / ``bulk_get``) is the caller's job; this layer only
17+
discovers and classifies what an entry points at.
1718
18-
Shared discovery layer for entity packaging (#143) and assay results (#68/#69).
19+
Shared discovery layer for entity packaging (#143), the full entry-linked
20+
resource map (#389), and assay results (#68/#69).
1921
"""
2022

2123
from dataclasses import dataclass
24+
from enum import Enum
2225
from typing import Any, Dict, Iterator, List, Optional, Tuple
2326

24-
# Note-link / field ``type`` values that denote a registry entity. Used to keep
25-
# entity references out of the non-entity links (dashboards, etc.) that share
26-
# the same ``links[]`` array.
27-
ENTITY_LINK_TYPES = frozenset(
28-
{
29-
"custom_entity",
30-
"dna_sequence",
31-
"aa_sequence",
32-
"dna_oligo",
33-
"rna_oligo",
34-
}
35-
)
27+
28+
class LinkCategory(str, Enum):
29+
"""How a note-link type relates to packaging (per #389 conclusions)."""
30+
31+
ENTITY = "entity" # registry entity; GET-by-id + v2.entity.registered event
32+
INVENTORY = "inventory" # packageable via GET-by-id; no webhook events
33+
REFERENCE = "reference" # packageable; has its own create/update events
34+
METADATA = "metadata" # GET works but low value to package as a record
35+
NOT_PACKAGEABLE = "not_packageable" # no read API (dashboards, protocol)
36+
UNCERTAIN = "uncertain" # endpoint depends on tenant API version; verify first
37+
EXTERNAL = "external" # plain http(s) hyperlink ("link"); no Benchling ID
38+
UNKNOWN = "unknown" # type not in the known enum -- surfaced, not dropped
39+
40+
41+
# EntryLink.type -> category. Covers all 18 enum tokens from test/openapi.yaml.
42+
# Unknown/future tokens fall through to LinkCategory.UNKNOWN via classify_link_type.
43+
LINK_TYPE_CATEGORY: Dict[str, LinkCategory] = {
44+
# entities (eventable via v2.entity.registered)
45+
"custom_entity": LinkCategory.ENTITY,
46+
"dna_sequence": LinkCategory.ENTITY,
47+
"aa_sequence": LinkCategory.ENTITY,
48+
"batch": LinkCategory.ENTITY,
49+
# inventory (packageable on reference, no events)
50+
"container": LinkCategory.INVENTORY,
51+
"box": LinkCategory.INVENTORY,
52+
"plate": LinkCategory.INVENTORY,
53+
"location": LinkCategory.INVENTORY,
54+
# references (packageable, own events)
55+
"entry": LinkCategory.REFERENCE,
56+
"request": LinkCategory.REFERENCE,
57+
"workflow": LinkCategory.REFERENCE,
58+
# metadata-only pointers
59+
"user": LinkCategory.METADATA,
60+
"folder": LinkCategory.METADATA,
61+
# no read API exists -- keep the webURL as a reference only
62+
"sql_dashboard": LinkCategory.NOT_PACKAGEABLE,
63+
"insights_dashboard": LinkCategory.NOT_PACKAGEABLE,
64+
"protocol": LinkCategory.NOT_PACKAGEABLE,
65+
# endpoint depends on tenant API version (v2-alpha) -- verify before relying
66+
"stage_entry": LinkCategory.UNCERTAIN,
67+
# plain external hyperlink (no Benchling id)
68+
"link": LinkCategory.EXTERNAL,
69+
}
70+
71+
# Note: dna_oligo / rna_oligo / mixture / assay_run / assay_result / workflow_task
72+
# are NOT EntryLink types -- they cannot appear as note links. They reach an entry
73+
# via structured note parts / inventory tables, not links[].
74+
75+
# Categories whose resources can be fetched as a record via GET-by-id.
76+
PACKAGEABLE_CATEGORIES = frozenset({LinkCategory.ENTITY, LinkCategory.INVENTORY, LinkCategory.REFERENCE})
77+
78+
# Linkable entity types (subset of LINK_TYPE_CATEGORY that are LinkCategory.ENTITY).
79+
ENTITY_LINK_TYPES = frozenset(t for t, cat in LINK_TYPE_CATEGORY.items() if cat is LinkCategory.ENTITY)
3680

3781
# Note ``type`` values that carry tabular assay results.
3882
RESULTS_TABLE_NOTE_TYPES = frozenset(
@@ -44,6 +88,21 @@
4488
)
4589

4690

91+
@dataclass(frozen=True)
92+
class LinkRef:
93+
"""A classified note link. ``id``/``web_url`` are absent for some types
94+
(``link`` has no id; ``location`` has no webURL)."""
95+
96+
type: str
97+
category: LinkCategory
98+
id: Optional[str] = None
99+
web_url: Optional[str] = None
100+
101+
@property
102+
def is_packageable(self) -> bool:
103+
return self.category in PACKAGEABLE_CATEGORIES
104+
105+
47106
@dataclass(frozen=True)
48107
class EntityReference:
49108
"""A reference to a Benchling entity discovered inside an entry.
@@ -105,10 +164,25 @@ def _field_value_ids(fval: Dict[str, Any]) -> List[str]:
105164
return []
106165

107166

167+
def _link_web_url(link: Dict[str, Any]) -> Optional[str]:
168+
return link.get("webURL") or link.get("web_url")
169+
170+
171+
def classify_link_type(link_type: Optional[str]) -> LinkCategory:
172+
"""Map an EntryLink ``type`` token to its :class:`LinkCategory`.
173+
174+
Unknown/future tokens map to ``UNKNOWN`` rather than being silently dropped.
175+
"""
176+
if not link_type:
177+
return LinkCategory.UNKNOWN
178+
return LINK_TYPE_CATEGORY.get(link_type, LinkCategory.UNKNOWN)
179+
180+
108181
def extract_note_links(entry_data: Dict[str, Any]) -> List[Dict[str, Any]]:
109-
"""Return every link object across all note bodies, unfiltered.
182+
"""Return every link object across all note bodies, unfiltered and untyped.
110183
111-
Lower-level primitive; most callers want :func:`extract_entity_references`.
184+
Lowest-level primitive; most callers want :func:`classify_links` or
185+
:func:`extract_entity_references`.
112186
"""
113187
links: List[Dict[str, Any]] = []
114188
for note in _iter_notes(entry_data):
@@ -118,14 +192,44 @@ def extract_note_links(entry_data: Dict[str, Any]) -> List[Dict[str, Any]]:
118192
return links
119193

120194

195+
def classify_links(entry_data: Dict[str, Any]) -> List[LinkRef]:
196+
"""Return every note link, classified by category and deduped.
197+
198+
Surfaces the *full* set of objects an entry points at -- entities, inventory,
199+
references, metadata pointers, dashboards, and external URLs -- so callers can
200+
decide what to fetch (e.g. ``[r for r in classify_links(e) if r.is_packageable]``).
201+
Deduped by Benchling ID when present, else by URL; first-seen order preserved.
202+
"""
203+
seen: set[str] = set()
204+
refs: List[LinkRef] = []
205+
for link in extract_note_links(entry_data):
206+
link_type = link.get("type")
207+
link_id = link.get("id")
208+
web_url = _link_web_url(link)
209+
dedup_key = link_id or web_url
210+
if dedup_key is not None:
211+
if dedup_key in seen:
212+
continue
213+
seen.add(dedup_key)
214+
refs.append(
215+
LinkRef(
216+
type=str(link_type) if link_type is not None else "",
217+
category=classify_link_type(link_type),
218+
id=link_id,
219+
web_url=web_url,
220+
)
221+
)
222+
return refs
223+
224+
121225
def extract_entity_references(
122226
entry_data: Dict[str, Any],
123227
*,
124228
types: "frozenset[str] | set[str]" = ENTITY_LINK_TYPES,
125229
) -> List[EntityReference]:
126230
"""Return deduped entity references from note links and entity-link fields.
127231
128-
Note links are filtered to ``types`` (default: all known entity types).
232+
Note links are filtered to ``types`` (default: all linkable entity types).
129233
Entity-link fields are detected by an ``entity`` substring in the field
130234
``type`` and are included regardless of ``types``. References are deduped by
131235
ID, preserving first-seen order (note links before fields).
@@ -143,7 +247,7 @@ def extract_entity_references(
143247
EntityReference(
144248
id=str(link_id),
145249
type=str(link_type),
146-
web_url=link.get("webURL") or link.get("web_url"),
250+
web_url=_link_web_url(link),
147251
source="note_link",
148252
)
149253
)

docker/tests/test_entry_references.py

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,18 @@
11
"""Tests for entry_references extractor (shared discovery for #143 + #68/#69)."""
22

3+
import json
4+
from pathlib import Path
5+
36
from src.entry_references import (
47
ENTITY_LINK_TYPES,
8+
LINK_TYPE_CATEGORY,
9+
PACKAGEABLE_CATEGORIES,
510
EntityReference,
11+
LinkCategory,
12+
LinkRef,
613
ResultsTableReference,
14+
classify_link_type,
15+
classify_links,
716
extract_entity_references,
817
extract_note_links,
918
extract_results_tables,
@@ -126,3 +135,130 @@ def test_dedupes_by_api_and_schema(self):
126135
note = {"type": "results_table", "apiId": "tbl_1", "assayResultSchemaId": "assaysch_1"}
127136
entry = _entry(dict(note), dict(note))
128137
assert len(extract_results_tables(entry)) == 1
138+
139+
140+
class TestEntityLinkTypes:
141+
"""Lock the linkable-entity set to the EntryLink enum (#389)."""
142+
143+
def test_set_matches_entrylink_entities(self):
144+
# custom_entity, dna_sequence, aa_sequence, batch -- and nothing else.
145+
assert ENTITY_LINK_TYPES == {"custom_entity", "dna_sequence", "aa_sequence", "batch"}
146+
147+
def test_batch_is_an_entity_reference(self):
148+
entry = _entry(_link_note({"id": "bat_1", "type": "batch", "webURL": "u"}))
149+
assert [r.id for r in extract_entity_references(entry)] == ["bat_1"]
150+
151+
def test_oligos_are_not_link_types(self):
152+
# dna_oligo / rna_oligo cannot appear as note links -- not in the enum.
153+
for t in ("dna_oligo", "rna_oligo"):
154+
assert t not in ENTITY_LINK_TYPES
155+
assert t not in LINK_TYPE_CATEGORY
156+
157+
158+
class TestClassifyLinkType:
159+
def test_full_enum_is_mapped(self):
160+
# All 18 EntryLink.type tokens from test/openapi.yaml.
161+
enum = {
162+
"link", "user", "request", "entry", "stage_entry", "protocol", "workflow",
163+
"custom_entity", "aa_sequence", "dna_sequence", "batch", "box", "container",
164+
"location", "plate", "insights_dashboard", "folder", "sql_dashboard",
165+
} # fmt: skip
166+
assert set(LINK_TYPE_CATEGORY) == enum
167+
# none fall through to UNKNOWN
168+
assert all(classify_link_type(t) is not LinkCategory.UNKNOWN for t in enum)
169+
170+
def test_category_assignments(self):
171+
assert classify_link_type("custom_entity") is LinkCategory.ENTITY
172+
assert classify_link_type("batch") is LinkCategory.ENTITY
173+
assert classify_link_type("container") is LinkCategory.INVENTORY
174+
assert classify_link_type("entry") is LinkCategory.REFERENCE
175+
assert classify_link_type("user") is LinkCategory.METADATA
176+
assert classify_link_type("sql_dashboard") is LinkCategory.NOT_PACKAGEABLE
177+
assert classify_link_type("stage_entry") is LinkCategory.UNCERTAIN
178+
assert classify_link_type("link") is LinkCategory.EXTERNAL
179+
180+
def test_unknown_and_empty_fall_through(self):
181+
assert classify_link_type("future_type") is LinkCategory.UNKNOWN
182+
assert classify_link_type(None) is LinkCategory.UNKNOWN
183+
assert classify_link_type("") is LinkCategory.UNKNOWN
184+
185+
186+
class TestClassifyLinks:
187+
def test_surfaces_all_types_classified(self):
188+
entry = _entry(
189+
_link_note(
190+
{"id": "bfi_1", "type": "custom_entity", "webURL": "u1"},
191+
{"id": "con_1", "type": "container", "webURL": "u2"},
192+
{"id": "axdash_1", "type": "sql_dashboard", "webURL": "u3"},
193+
{"type": "link", "webURL": "https://example.com"},
194+
)
195+
)
196+
refs = classify_links(entry)
197+
assert refs == [
198+
LinkRef(type="custom_entity", category=LinkCategory.ENTITY, id="bfi_1", web_url="u1"),
199+
LinkRef(type="container", category=LinkCategory.INVENTORY, id="con_1", web_url="u2"),
200+
LinkRef(
201+
type="sql_dashboard",
202+
category=LinkCategory.NOT_PACKAGEABLE,
203+
id="axdash_1",
204+
web_url="u3",
205+
),
206+
LinkRef(type="link", category=LinkCategory.EXTERNAL, id=None, web_url="https://example.com"),
207+
]
208+
209+
def test_is_packageable_filter(self):
210+
entry = _entry(
211+
_link_note(
212+
{"id": "bfi_1", "type": "custom_entity", "webURL": "u1"},
213+
{"id": "axdash_1", "type": "sql_dashboard", "webURL": "u3"},
214+
{"type": "link", "webURL": "https://example.com"},
215+
)
216+
)
217+
packageable = [r.id for r in classify_links(entry) if r.is_packageable]
218+
assert packageable == ["bfi_1"]
219+
220+
def test_dedupes_by_id_then_url(self):
221+
entry = _entry(
222+
_link_note(
223+
{"id": "bfi_1", "type": "custom_entity", "webURL": "u1"},
224+
{"id": "bfi_1", "type": "custom_entity", "webURL": "u1"},
225+
{"type": "link", "webURL": "https://dup.com"},
226+
{"type": "link", "webURL": "https://dup.com"},
227+
)
228+
)
229+
refs = classify_links(entry)
230+
assert [(r.type, r.id or r.web_url) for r in refs] == [
231+
("custom_entity", "bfi_1"),
232+
("link", "https://dup.com"),
233+
]
234+
235+
def test_packageable_categories_membership(self):
236+
assert PACKAGEABLE_CATEGORIES == {
237+
LinkCategory.ENTITY,
238+
LinkCategory.INVENTORY,
239+
LinkCategory.REFERENCE,
240+
}
241+
242+
243+
class TestEntryLinkTypesJson:
244+
"""Guard spec/entry-link-types.json against drift from the module."""
245+
246+
def _load(self):
247+
path = Path(__file__).resolve().parents[2] / "spec" / "entry-link-types.json"
248+
return json.loads(path.read_text())
249+
250+
def test_json_covers_same_types(self):
251+
ref = self._load()
252+
json_types = {row["type"] for row in ref["link_types"]}
253+
assert json_types == set(LINK_TYPE_CATEGORY)
254+
255+
def test_json_categories_match_module(self):
256+
ref = self._load()
257+
for row in ref["link_types"]:
258+
module_cat = LINK_TYPE_CATEGORY[row["type"]]
259+
assert row["category"] == module_cat.value, row["type"]
260+
assert row["packageable"] == (module_cat in PACKAGEABLE_CATEGORIES), row["type"]
261+
262+
def test_json_packageable_categories_match_module(self):
263+
ref = self._load()
264+
assert set(ref["packageable_categories"]) == {c.value for c in PACKAGEABLE_CATEGORIES}

spec/entry-link-types.json

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
{
2+
"$comment": "Reference map of Benchling EntryLink.type tokens -> how the packager treats them. Source of truth for `type` enum: test/openapi.yaml (EntryLink schema, 18 tokens). Categories mirror docker/src/entry_references.py (LINK_TYPE_CATEGORY); test_entry_references.py asserts they agree. Endpoints / id prefixes / events per issue #389. See issues #143, #389, #68.",
3+
"categories": {
4+
"entity": "Registry entity; GET-by-id and v2.entity.registered event. Packageable.",
5+
"inventory": "Packageable via GET-by-id; no webhook events.",
6+
"reference": "Packageable; has its own create/update webhook events.",
7+
"metadata": "GET works but low value to package as a record.",
8+
"not_packageable": "No read API exists; keep the webURL as a reference only.",
9+
"uncertain": "Endpoint depends on tenant API version (e.g. v2-alpha); verify before relying.",
10+
"external": "Plain http(s) hyperlink ('link' type); no Benchling ID.",
11+
"unknown": "Type not in the known enum; surfaced rather than dropped."
12+
},
13+
"packageable_categories": ["entity", "inventory", "reference"],
14+
"link_types": [
15+
{"type": "custom_entity", "category": "entity", "packageable": true, "id_prefix": "bfi_", "get_endpoint": "GET /custom-entities/{id}", "webhook_events": ["v2.entity.registered"]},
16+
{"type": "dna_sequence", "category": "entity", "packageable": true, "id_prefix": "seq_", "get_endpoint": "GET /dna-sequences/{id}", "webhook_events": ["v2.entity.registered"]},
17+
{"type": "aa_sequence", "category": "entity", "packageable": true, "id_prefix": "prtn_", "get_endpoint": "GET /aa-sequences/{id}", "webhook_events": ["v2.entity.registered"]},
18+
{"type": "batch", "category": "entity", "packageable": true, "id_prefix": "bat_", "get_endpoint": "GET /batches/{id}", "webhook_events": ["v2.entity.registered"]},
19+
{"type": "container", "category": "inventory", "packageable": true, "id_prefix": "con_", "get_endpoint": "GET /containers/{id}", "webhook_events": []},
20+
{"type": "box", "category": "inventory", "packageable": true, "id_prefix": "box_", "get_endpoint": "GET /boxes/{id}", "webhook_events": []},
21+
{"type": "plate", "category": "inventory", "packageable": true, "id_prefix": "plt_", "get_endpoint": "GET /plates/{id}", "webhook_events": []},
22+
{"type": "location", "category": "inventory", "packageable": true, "id_prefix": "loc_", "get_endpoint": "GET /locations/{id}", "webhook_events": [], "notes": "Locations do not have a webURL."},
23+
{"type": "entry", "category": "reference", "packageable": true, "id_prefix": "etr_", "get_endpoint": "GET /entries/{id}", "webhook_events": ["v2.entry.created", "v2.entry.updated.fields", "v2.entry.updated.reviewRecord"]},
24+
{"type": "request", "category": "reference", "packageable": true, "id_prefix": "req_", "get_endpoint": "GET /requests/{id}", "webhook_events": ["v2.request.created", "v2.request.updated.fields", "v2.request.updated.status"]},
25+
{"type": "workflow", "category": "reference", "packageable": true, "id_prefix": "wfgrp_", "get_endpoint": "GET /workflow-task-groups/{id}", "webhook_events": ["v2.workflowTaskGroup.created", "v2.workflowTaskGroup.mappingCompleted", "v2.workflowTaskGroup.updated.watchers"]},
26+
{"type": "stage_entry", "category": "uncertain", "packageable": false, "id_prefix": "etr_", "get_endpoint": "v2-alpha only", "webhook_events": ["v2-alpha.stageEntry.*"], "notes": "Verify against the deployed tenant's API version before relying on it."},
27+
{"type": "protocol", "category": "not_packageable", "packageable": false, "id_prefix": null, "get_endpoint": null, "webhook_events": [], "notes": "Linkable but no GET-by-id endpoint in the stable spec."},
28+
{"type": "user", "category": "metadata", "packageable": false, "id_prefix": "ent_", "get_endpoint": "GET /users/{id}", "webhook_events": [], "notes": "Metadata only."},
29+
{"type": "folder", "category": "metadata", "packageable": false, "id_prefix": "lib_", "get_endpoint": "GET /folders/{id}", "webhook_events": [], "notes": "Legacy; metadata only."},
30+
{"type": "sql_dashboard", "category": "not_packageable", "packageable": false, "id_prefix": "axdash_", "get_endpoint": null, "webhook_events": [], "notes": "No read API; Insights/analytics artifact reachable only via the Warehouse. Keep webURL deep link."},
31+
{"type": "insights_dashboard", "category": "not_packageable", "packageable": false, "id_prefix": "axdash_", "get_endpoint": null, "webhook_events": [], "notes": "Legacy alias of sql_dashboard; no read API."},
32+
{"type": "link", "category": "external", "packageable": false, "id_prefix": null, "get_endpoint": null, "webhook_events": [], "notes": "Plain external hyperlink; id omitted, webURL is the literal URL."}
33+
],
34+
"not_inline_linkable": {
35+
"$comment": "Resources that are NOT EntryLink types -- they cannot appear in links[]. They reach an entry via structured note parts / inventory tables instead.",
36+
"types": ["dna_oligo", "rna_oligo", "mixture", "assay_run", "assay_result", "workflow_task", "workflow_output"]
37+
}
38+
}

0 commit comments

Comments
 (0)