diff --git a/molviewspec/app/api/examples.py b/molviewspec/app/api/examples.py index b939e3a..aac698d 100644 --- a/molviewspec/app/api/examples.py +++ b/molviewspec/app/api/examples.py @@ -55,11 +55,11 @@ async def label_example(id: str = "1lap") -> MVSResponse: (whole.representation().color(color="red", selector=ComponentExpression(label_asym_id="A", label_seq_id=120))) # label the residues with custom text & focus it (i.e., position camera) - comp = structure.component(selector=residue).label(text="ALA 120 A: My Label").focus() # leverage vendor-specific properties to request non-covalent interactions in Mol* - comp.additional_properties( - molstar_show_non_covalent_interactions=True, molstar_non_covalent_interactions_radius_ang=5.0 - ) + structure.component( + selector=residue, + custom={"molstar_show_non_covalent_interactions": True, "molstar_non_covalent_interactions_radius_ang": 5.0}, + ).label(text="ALA 120 A: My Label").focus() # structure.label_from_source(schema="residue", category_name="my_custom_cif_category") @@ -260,19 +260,31 @@ async def additional_properties_example() -> MVSResponse: """ builder = create_builder() ( - builder.download(url=_url_for_mmcif("1cbs")) + builder.download(url=_url_for_mmcif("4hhb")) .parse(format="mmcif") - # each node provides this method, which allows storing custom data - .additional_properties(test="You can put whatever is needed here.", will_be_dropped=True) - .additional_properties(chainable="Totally!") - # properties can be removed by setting them to None - .additional_properties(will_be_dropped=None) .model_structure() .component() - .representation() - # you can nest properties as needed - .additional_properties(options={"provide_vendor_specific_props": True, "aim": "Customize representations."}) - .color(color="blue") + .representation(type="cartoon", custom={"a": "hello"}) + .color(selector="protein", color="#0000ff", custom={"b": "ciao"}) + .color(selector="ligand", color="#ff0000") + .color(color="#555555", custom={"c": "salut"}) + ) + return PlainTextResponse(builder.get_state()) + + +@router.get("/refs") +async def refs_example() -> MVSResponse: + """ + MolViewSpec allows assigning string references to nodes that allow referencing them + from various parts of the tree later, for example when building primitive shapes. + """ + builder = create_builder() + ( + builder.download(url=_url_for_mmcif("4hhb")) + .parse(format="mmcif") + .model_structure(ref="structure") + .component() + .representation(type="cartoon") ) return PlainTextResponse(builder.get_state()) @@ -1274,11 +1286,11 @@ async def portfolio_entity(entity_id: str = "1", assembly_id: str = "1") -> MVSR highlight = ENTITY_COLORS_1HDA.get(entity_id, "black") for type, entities in ENTITIES_1HDA.items(): for ent in entities: - (struct - .component(selector=ComponentExpression(label_entity_id=ent)) - .representation(type="ball_and_stick" if type=="ligand" else "cartoon") - .color(color = highlight if ent==entity_id else BASE_COLOR) - .transparency(transparency = 0 if ent==entity_id else BASE_TRANSPARENCY) + ( + struct.component(selector=ComponentExpression(label_entity_id=ent)) + .representation(type="ball_and_stick" if type == "ligand" else "cartoon") + .color(color=highlight if ent == entity_id else BASE_COLOR) + .transparency(transparency=0 if ent == entity_id else BASE_TRANSPARENCY) ) builder.camera(**CAMERA_FOR_1HDA) return PlainTextResponse(builder.get_state()) @@ -1421,8 +1433,12 @@ async def portfolio_modres() -> MVSResponse: builder = create_builder() structure_url = _url_for_mmcif(ID) struct = builder.download(url=structure_url).parse(format="mmcif").assembly_structure(assembly_id=ASSEMBLY) - struct.component(selector="polymer").representation(type="cartoon").color(color=BASE_COLOR).transparency(transparency=BASE_TRANSPARENCY) - struct.component(selector="ligand").representation(type="ball_and_stick").color(color=BASE_COLOR).transparency(transparency=BASE_TRANSPARENCY) + struct.component(selector="polymer").representation(type="cartoon").color(color=BASE_COLOR).transparency( + transparency=BASE_TRANSPARENCY + ) + struct.component(selector="ligand").representation(type="ball_and_stick").color(color=BASE_COLOR).transparency( + transparency=BASE_TRANSPARENCY + ) struct.component(selector=ComponentExpression(label_asym_id="A", label_seq_id=54)).tooltip( text="Modified residue SUI: (3-amino-2,5-dioxo-1-pyrrolidinyl)acetic acid" ).representation(type="ball_and_stick").color(color="#ED645A") diff --git a/molviewspec/molviewspec/builder.py b/molviewspec/molviewspec/builder.py index c6cb47d..b17c33e 100644 --- a/molviewspec/molviewspec/builder.py +++ b/molviewspec/molviewspec/builder.py @@ -7,7 +7,7 @@ import math import os -from typing import Self, Sequence +from typing import Sequence from pydantic import BaseModel, PrivateAttr @@ -23,6 +23,7 @@ ComponentFromUriParams, ComponentInlineParams, ComponentSelectorT, + CustomT, DescriptionFormatT, DownloadParams, FocusInlineParams, @@ -34,6 +35,7 @@ Node, ParseFormatT, ParseParams, + RefT, RepresentationParams, RepresentationTypeT, SchemaFormatT, @@ -80,23 +82,6 @@ def _add_child(self, node: Node) -> None: self._node.children = [] self._node.children.append(node) - def additional_properties(self, **kwargs) -> Self: - """ - Adds provided key-value pairs as additional properties to this node. - key=None to remove a property. - """ - properties = self._node.additional_properties or {} - - for k, v in kwargs.items(): - if v is None: - # remove props with value of None - properties.pop(k, None) - else: - properties[k] = v - - self._node.additional_properties = properties or None - return self - class Root(_Base): """ @@ -190,10 +175,11 @@ def canvas(self, *, background_color: ColorT | None = None) -> Root: self._add_child(node) return self - def download(self, *, url: str) -> Download: + def download(self, *, url: str, ref: RefT = None) -> Download: """ Add a new structure to the builder by downloading structure data from a URL. :param url: source of structure data + :param ref: optional, reference that can be used to access this node :return: a builder that handles operations on the downloaded resource """ params = make_params(DownloadParams, locals()) @@ -216,10 +202,11 @@ class Download(_Base): Builder step with operations needed after downloading structure data. """ - def parse(self, *, format: ParseFormatT) -> Parse: + def parse(self, *, format: ParseFormatT, ref: RefT = None) -> Parse: """ Parse the content by specifying the file format. :param format: specify the format of your structure data + :param ref: optional, reference that can be used to access this node :return: a builder that handles operations on the parsed content """ params = make_params(ParseParams, locals()) @@ -239,6 +226,7 @@ def model_structure( model_index: int | None = None, block_index: int | None = None, block_header: str | None = None, + ref: RefT = None, ) -> Structure: """ Create a structure for the deposited coordinates. @@ -259,6 +247,7 @@ def assembly_structure( model_index: int | None = None, block_index: int | None = None, block_header: str | None = None, + ref: RefT = None, ) -> Structure: """ Create an assembly structure. @@ -281,6 +270,7 @@ def symmetry_structure( model_index: int | None = None, block_index: int | None = None, block_header: str | None = None, + ref: RefT = None, ) -> Structure: """ Create symmetry structure for a given range of Miller indices. @@ -303,6 +293,7 @@ def symmetry_mates_structure( model_index: int | None = None, block_index: int | None = None, block_header: str | None = None, + ref: RefT = None, ) -> Structure: """ Create structure of symmetry mates. @@ -326,10 +317,14 @@ def component( self, *, selector: ComponentSelectorT | ComponentExpression | list[ComponentExpression] = "all", + custom: CustomT = None, + ref: RefT = None, ) -> Component: """ Define a new component/selection for the given structure. :param selector: a predefined component selector or one or more component selection expressions + :param custom: optional, custom data to attach to this node + :param ref: optional, reference that can be used to access this node :return: a builder that handles operations at component level """ params = make_params(ComponentInlineParams, locals()) @@ -348,6 +343,8 @@ def component_from_uri( block_index: int | None = None, schema: SchemaT, field_values: str | list[str] | None = None, + custom: CustomT = None, + ref: RefT = None, ) -> Component: """ Define a new component/selection for the given structure by fetching additional data from a resource. @@ -359,6 +356,8 @@ def component_from_uri( :param block_index: only applies when format is 'cif' or 'bcif' :param schema: granularity/type of the selection :param field_values: create the component from rows that have any of these values in the field specified by `field_name`. If not provided, create the component from all rows. + :param custom: optional, custom data to attach to this node + :param ref: optional, reference that can be used to access this node :return: a builder that handles operations at component level """ if isinstance(field_values, str): @@ -377,6 +376,8 @@ def component_from_source( block_index: int | None = None, schema: SchemaT, field_values: str | list[str] | None = None, + custom: CustomT = None, + ref: RefT = None, ) -> Component: """ Define a new component/selection for the given structure by using categories from the source file. @@ -386,6 +387,8 @@ def component_from_source( :param block_index: only applies when format is 'cif' or 'bcif' :param schema: granularity/type of the selection :param field_values: create the component from rows that have any of these values in the field specified by `field_name`. If not provided, create the component from all rows. + :param custom: optional, custom data to attach to this node + :param ref: optional, reference that can be used to access this node :return: a builder that handles operations at component level """ if isinstance(field_values, str): @@ -500,11 +503,15 @@ def transform( *, rotation: Sequence[float] | None = None, translation: Sequence[float] | None = None, + custom: CustomT = None, + ref: RefT = None, ) -> Structure: """ Transform a structure by applying a rotation matrix and/or translation vector. :param rotation: 9d vector describing the rotation, in column major (j * 3 + i indexing) format, this is equivalent to Fortran-order in numpy, to be multiplied from the left :param translation: 3d vector describing the translation + :param custom: optional, custom data to attach to this node + :param ref: optional, reference that can be used to access this node :return: this builder """ if rotation is not None: @@ -538,10 +545,14 @@ class Component(_Base): Builder step with operations relevant for a particular component. """ - def representation(self, *, type: RepresentationTypeT = "cartoon") -> Representation: + def representation( + self, *, type: RepresentationTypeT = "cartoon", custom: CustomT = None, ref: RefT = None + ) -> Representation: """ Add a representation for this component. :param type: the type of representation, defaults to 'cartoon' + :param custom: optional, custom data to attach to this node + :param ref: optional, reference that can be used to access this node :return: a builder that handles operations at representation level """ params = make_params(RepresentationParams, locals()) @@ -649,11 +660,13 @@ def color( *, color: ColorT, selector: ComponentSelectorT | ComponentExpression | list[ComponentExpression] = "all", + custom: CustomT = None, ) -> Representation: """ Customize the color of this representation. :param color: color using SVG color names or RGB hex code :param selector: optional selector, defaults to applying the color to the whole representation + :param custom: optional, custom data to attach to this node :return: this builder """ params = make_params(ColorInlineParams, locals()) diff --git a/molviewspec/molviewspec/nodes.py b/molviewspec/molviewspec/nodes.py index 0bc5f62..f50ed39 100644 --- a/molviewspec/molviewspec/nodes.py +++ b/molviewspec/molviewspec/nodes.py @@ -39,6 +39,10 @@ ] +CustomT = Optional[Mapping[str, Any]] +RefT = Optional[str] + + class Node(BaseModel): """ Base impl of all state tree nodes. @@ -46,10 +50,19 @@ class Node(BaseModel): kind: KindT = Field(description="The type of this node.") params: Optional[Mapping[str, Any]] = Field(description="Optional params that are needed for this node.") - additional_properties: Optional[Mapping[str, Any]] = Field( - description="Optional free-style dict with custom, non-schema props." - ) children: Optional[list[Node]] = Field(description="Optional collection of nested child nodes.") + custom: CustomT = Field(description="Custom data to store attached to this node.") + ref: RefT = Field(description="Optional reference that can be used to access this node.") + + def __init__(self, **data): + # extract `custom` value from `params` + params = data.get("params", {}) + if "custom" in params: + data["custom"] = params.pop("custom") + if "ref" in params: + data["ref"] = params.pop("ref") + + super().__init__(**data) class FormatMetadata(BaseModel): diff --git a/molviewspec/molviewspec/utils.py b/molviewspec/molviewspec/utils.py index afa5caa..f36bd6d 100644 --- a/molviewspec/molviewspec/utils.py +++ b/molviewspec/molviewspec/utils.py @@ -12,13 +12,19 @@ def make_params(params_type: Type[TParams], values=None, /, **more_values: objec values = {} result = {} - for field in params_type.__fields__.values(): - # special `additional_properties` param that might be part of locals() too but should never be part of `params` - if field.alias == "additional_properties": - continue + # propagate custom properties + if values: + custom_values = values.get("custom") + if custom_values is not None: + result["custom"] = custom_values + ref = values.get("ref") + if ref is not None: + result["ref"] = ref + for field in params_type.__fields__.values(): # must use alias here to properly resolve goodies like `schema_` key = field.alias + if more_values.get(key) is not None: result[key] = more_values[key] elif values.get(key) is not None: