Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions docs/examples/substance_v4.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# Substances V4

The `substances_v4` collection wraps the Albert v4 substance API, which exposes hazard data, custom tenant metadata, and structural identifiers at the CAS level.

## Update metadata

Use `update_metadata` to change specific fields on a tenant substance. Only the keyword arguments you pass are updated — everything else on the substance is left as-is.

!!! example "Update scalar fields"
```python
from albert import Albert

client = Albert.from_client_credentials()

client.substances_v4.update_metadata(
id="SUB123",
notes="Revised safety notes",
description="Aqueous solvent",
cas_smiles="O",
)
```

!!! example "Update a custom string metadata field"
```python
from albert import Albert

client = Albert.from_client_credentials()

client.substances_v4.update_metadata(
id="SUB123",
metadata={"solubility": "5 mg/mL"},
)
```

!!! example "Update a single-select custom metadata field"
Single-select fields take an `EntityLink` whose `id` is the list item ID. Use `client.lists.get_all()` or `client.lists.get_matching_item()` to look up IDs.

```python
from albert import Albert
from albert.resources.base import EntityLink

client = Albert.from_client_credentials()

client.substances_v4.update_metadata(
id="SUB123",
metadata={"cmr_eu": EntityLink(id="LST1253")},
)
```

!!! example "Update a multi-select custom metadata field"
Multi-select fields take a list of `EntityLink` objects representing the desired selection.

```python
from albert import Albert
from albert.resources.base import EntityLink

client = Albert.from_client_credentials()

client.substances_v4.update_metadata(
id="SUB123",
metadata={
"amide_category": [
EntityLink(id="LST1256"),
EntityLink(id="LST1257"),
]
},
)
```

!!! example "Delete a custom metadata field"
Pass `None` as the value to remove a custom field (works for string, single-select, and multi-select fields).

```python
from albert import Albert

client = Albert.from_client_credentials()

client.substances_v4.update_metadata(
id="SUB123",
metadata={"deprecated_field": None},
)
```

!!! example "Mix scalar and custom field updates in one call"
```python
from albert import Albert
from albert.resources.base import EntityLink

client = Albert.from_client_credentials()

client.substances_v4.update_metadata(
id="SUB123",
notes="Updated notes",
metadata={
"solubility": "10 mg/mL",
"cmr_eu": EntityLink(id="LST1253"),
"old_field": None, # deletes this custom field
},
)
```
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,7 @@ nav:
- Parameter Groups: examples/parameter_groups.md
- Property Data: examples/property_data.md
- Projects: examples/projects.md
- Substances V4: examples/substance_v4.md
- Tasks: examples/tasks.md
- Teams: examples/teams.md

Expand Down
17 changes: 2 additions & 15 deletions src/albert/collections/chat_sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,14 @@

from collections.abc import AsyncIterator

from pydantic import GetCoreSchemaHandler, validate_call
from pydantic_core import core_schema
from pydantic import validate_call

from albert.core.async_session import AsyncAlbertSession
from albert.core.pagination import AsyncAlbertPaginator
from albert.core.shared.types import _UNSET, _UnsetType
from albert.resources.chats import ChatSession


class _UnsetType:
"""Sentinel type for distinguishing unset parameters from explicit None."""

@classmethod
def __get_pydantic_core_schema__(
cls, _source_type: object, _handler: GetCoreSchemaHandler
) -> core_schema.CoreSchema:
return core_schema.is_instance_schema(cls)


_UNSET = _UnsetType()


class ChatSessionCollection:
"""
Async collection for managing chat sessions (🧪Beta).
Expand Down
128 changes: 104 additions & 24 deletions src/albert/collections/substance_v4.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
from albert.core.pagination import AlbertPaginator
from albert.core.session import AlbertSession
from albert.core.shared.enums import PaginationMode
from albert.core.shared.types import _UNSET, MetadataItem, _UnsetType
from albert.exceptions import AlbertHTTPError
from albert.resources.substance_v4 import (
SubstanceV4Create,
SubstanceV4CreateResult,
Expand Down Expand Up @@ -91,8 +93,8 @@ class SubstanceV4Collection(BaseCollection):
Searches substances by keyword or advanced filters.
create(substance) -> SubstanceV4CreateResult
Creates a new substance record.
update_metadata(id, current_metadata, updated_metadata) -> None
Updates metadata fields on a substance by diffing current vs updated state.
update_metadata(id, ...) -> None
Updates metadata fields on a substance.
"""

_api_version = "v4"
Expand Down Expand Up @@ -316,32 +318,100 @@ def update_metadata(
self,
*,
id: str,
current_metadata: SubstanceV4Metadata,
updated_metadata: SubstanceV4Metadata,
notes: str | _UnsetType = _UNSET,
description: str | _UnsetType = _UNSET,
cas_smiles: str | _UnsetType = _UNSET,
inchi_key: str | _UnsetType = _UNSET,
iupac_name: str | _UnsetType = _UNSET,
cactus_status: str | _UnsetType = _UNSET,
metadata: dict[str, MetadataItem | None] | _UnsetType = _UNSET,
) -> None:
"""Update metadata fields on a substance.

Diffs ``current_metadata`` against ``updated_metadata`` and sends only the
changed fields. Scalar fields support ``add`` and ``update``; custom tenant
metadata fields also support ``delete`` (set the key to ``None`` in
``updated_metadata`` with a value in ``current_metadata``).
Only the keyword arguments you pass are updated — all others are left unchanged.
The current state is fetched automatically.

Parameters
----------
id : str
The substance ID to update.
current_metadata : SubstanceV4Metadata
The current metadata state of the substance.
updated_metadata : SubstanceV4Metadata
The desired metadata state of the substance.
notes : str, optional
Free-text notes.
description : str, optional
Substance description.
cas_smiles : str, optional
SMILES notation for the structure.
inchi_key : str, optional
InChIKey identifier.
iupac_name : str, optional
IUPAC name.
cactus_status : str, optional
CACTUS resolver status.
metadata : dict[str, MetadataItem | None], optional
Custom tenant metadata fields to update. Only the keys listed in this dict
are touched; all other custom fields on the substance are left unchanged.

Value types by field kind:

- **String / number fields** — pass the value directly (``"5 mg/mL"``, ``42``).
- **Single-select fields** — pass an ``EntityLink``; use
``client.lists.get_matching_item()`` to look up the ID.
- **Multi-select fields** — pass a list of ``EntityLink`` objects; only the
changed items are sent.
- **Delete a field** — pass ``None`` as the value (works for all field types).

Notes
-----
The following fields can be updated: ``notes``, ``description``, ``cas_smiles``,
``inchi_key``, ``iupac_name``, ``cactus_status``, and any custom metadata fields
configured for the tenant.

Examples
--------
Update a scalar field and a custom string field:

client.substances_v4.update_metadata(
id="SUB123",
notes="new notes",
metadata={"solubility": "5 mg/mL"},
)

Set a single-select custom field:

client.substances_v4.update_metadata(
id="SUB123",
metadata={"cmr_eu": EntityLink(id="LST1253")},
)

Update a multi-select custom field (becomes exactly this set):

client.substances_v4.update_metadata(
id="SUB123",
metadata={"amide_category": [EntityLink(id="LST1256"), EntityLink(id="LST1257")]},
)

Delete a custom field:

client.substances_v4.update_metadata(id="SUB123", metadata={"old_key": None})
"""
scalar_kwargs = {
"notes": notes,
"description": description,
"cas_smiles": cas_smiles,
"inchi_key": inchi_key,
"iupac_name": iupac_name,
"cactus_status": cactus_status,
}
if all(v is _UNSET for v in scalar_kwargs.values()) and metadata is _UNSET:
return

sub_id = id if id.startswith("SUB") else f"SUB{id}"
try:
substance = self.get_by_id(sub_id=sub_id, catch_errors=True)
except AlbertHTTPError:
# Substance exists but can't be fetched (e.g. no hazard data yet).
# Treat the current state as empty so all operations become adds.
substance = None
operations = []

for attr, wire_name in [
Expand All @@ -352,13 +422,15 @@ def update_metadata(
("iupac_name", "iUpacName"),
("cactus_status", "cactusStatus"),
]:
old = getattr(current_metadata, attr)
new = getattr(updated_metadata, attr)
new = scalar_kwargs[attr]
if new is _UNSET:
continue
old = getattr(substance, attr, None) if substance is not None else None
if old == new:
continue
if old is None and new is not None:
if old is None:
operations.append({"operation": "add", "attribute": wire_name, "newValue": new})
elif old is not None and new is not None:
else:
operations.append(
{
"operation": "update",
Expand All @@ -367,15 +439,23 @@ def update_metadata(
"newValue": new,
}
)
# spec does not support delete for scalar fields — skip old→None case

metadata_patches = self._generate_metadata_diff(
existing_metadata=current_metadata.metadata or {},
updated_metadata=updated_metadata.metadata or {},
)
operations.extend(
p.model_dump(by_alias=True, mode="json", exclude_none=True) for p in metadata_patches
)
if metadata is not _UNSET and metadata:
# Coerce raw JSON dicts to EntityLink objects so _generate_metadata_diff
# can call .id on single/multi-select values.
raw_meta = substance.metadata if substance is not None else {}
coerced = SubstanceV4Metadata.model_validate({"metadata": raw_meta or {}})
current_meta = coerced.metadata or {}
relevant_existing = {k: v for k, v in current_meta.items() if k in metadata}
non_null_updates = {k: v for k, v in metadata.items() if v is not None}
metadata_patches = self._generate_metadata_diff(
existing_metadata=relevant_existing,
updated_metadata=non_null_updates,
)
operations.extend(
p.model_dump(by_alias=True, mode="json", exclude_none=True)
for p in metadata_patches
)

if not operations:
return
Expand Down
16 changes: 15 additions & 1 deletion src/albert/core/shared/types.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,27 @@
from typing import Annotated, TypeVar

from pydantic import PlainSerializer
from pydantic import GetCoreSchemaHandler, PlainSerializer
from pydantic_core import core_schema

from albert.core.shared.models.base import BaseResource, EntityLink, EntityLinkWithName

EntityType = TypeVar("EntityType", bound=BaseResource)
MetadataItem = float | int | str | EntityLink | list[EntityLink]


class _UnsetType:
"""Sentinel type for distinguishing unset parameters from explicit ``None``."""

@classmethod
def __get_pydantic_core_schema__(
cls, _source_type: object, _handler: GetCoreSchemaHandler
) -> core_schema.CoreSchema:
return core_schema.is_instance_schema(cls)


_UNSET = _UnsetType()


def convert_to_entity_link(value: BaseResource | EntityLink) -> EntityLink:
if isinstance(value, BaseResource):
return value.to_entity_link()
Expand Down
11 changes: 3 additions & 8 deletions tests/collections/test_substance_v4.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
SubstanceV4Create,
SubstanceV4Identifier,
SubstanceV4Info,
SubstanceV4Metadata,
)

CAS_IDS = [
Expand Down Expand Up @@ -83,15 +82,11 @@ def test_update_metadata(client: Albert, static_custom_fields: list[CustomField]
sub_id = result.created_items[0].substance_id
assert sub_id

# All fields start as None → all operations are "add"
client.substances_v4.update_metadata(
id=sub_id,
current_metadata=SubstanceV4Metadata(),
updated_metadata=SubstanceV4Metadata(
notes="sdk test note",
cas_smiles="CCO",
metadata={substance_string_field.name: "sdk test value"},
),
notes="sdk test note",
cas_smiles="CCO",
metadata={substance_string_field.name: "sdk test value"},
)


Expand Down