diff --git a/src/citrine/__version__.py b/src/citrine/__version__.py index 975f69142..32a781905 100644 --- a/src/citrine/__version__.py +++ b/src/citrine/__version__.py @@ -1 +1 @@ -__version__ = "3.7.1" +__version__ = "3.8.0" diff --git a/src/citrine/gemd_queries/filter.py b/src/citrine/gemd_queries/filter.py index 4cd7b018c..0b8a555ca 100644 --- a/src/citrine/gemd_queries/filter.py +++ b/src/citrine/gemd_queries/filter.py @@ -27,9 +27,9 @@ class AllRealFilter(Serializable['AllRealFilter'], PropertyFilterType): Parameters ---------- - lower: str + lower: float The lower bound on this filter range. - upper: str + upper: float The upper bound on this filter range. unit: str The units associated with the floating point values for this filter. @@ -48,15 +48,18 @@ class AllIntegerFilter(Serializable['AllIntegerFilter'], PropertyFilterType): Parameters ---------- - lower: str + lower: float The lower bound on this filter range. - upper: str + upper: float The upper bound on this filter range. + inclusive: bool + Whether the lower & upper bounds are included in the range. """ lower = properties.Float('lower') upper = properties.Float('upper') + inclusive = properties.Optional(properties.Boolean, 'inclusive', default=True) typ = properties.String('type', default="all_integer_filter", deserializable=False) diff --git a/src/citrine/gemtables/variables.py b/src/citrine/gemtables/variables.py index fb3aed8e4..646434326 100644 --- a/src/citrine/gemtables/variables.py +++ b/src/citrine/gemtables/variables.py @@ -72,7 +72,7 @@ def get_type(cls, data) -> Type[Serializable]: raise ValueError("Can only get types from dicts with a 'type' key") types: List[Type[Serializable]] = [ TerminalMaterialInfo, AttributeByTemplate, AttributeByTemplateAfterProcessTemplate, - AttributeByTemplateAndObjectTemplate, LocalAttribute, + AttributeByTemplateAndObjectTemplate, LocalAttribute, LocalAttributeAndObject, IngredientIdentifierByProcessTemplateAndName, IngredientLabelByProcessAndName, IngredientLabelsSetByProcessAndName, IngredientQuantityByProcessAndName, TerminalMaterialIdentifier, AttributeInOutput, @@ -339,6 +339,63 @@ def __init__(self, self.type_selector = type_selector +class LocalAttributeAndObject(Serializable['LocalAttributeAndObject'], Variable): + """[ALPHA] Attribute marked by an attribute template for the root of a material history tree. + + Parameters + ---------- + name: str + a short human-readable name to use when referencing the variable + headers: list[str] + sequence of column headers + template: Union[UUID, str, LinkByUID, AttributeTemplate] + attribute template that identifies the attribute to assign to the variable + object_template: Union[UUID, str, LinkByUID, AttributeTemplate] + attribute template that identifies the attribute to assign to the variable + attribute_constraints: List[Tuple[Union[UUID, str, LinkByUID, AttributeTemplate], Bounds]] + Optional + constraints on object attributes in the target object that must be satisfied. Constraints + are expressed as Bounds. Attributes are expressed with links. The attribute that the + variable is being set to may be the target of a constraint as well. + type_selector: DataObjectTypeSelector + strategy for selecting data object types to consider when matching, defaults to PREFER_RUN + + """ + + name = properties.String('name') + headers = properties.List(properties.String, 'headers') + template = properties.Object(LinkByUID, 'template') + object_template = properties.Object(LinkByUID, 'object_template') + attribute_constraints = properties.Optional( + properties.List( + properties.SpecifiedMixedList( + [properties.Object(LinkByUID), properties.Object(BaseBounds)] + ) + ), 'attribute_constraints') + type_selector = properties.Enumeration(DataObjectTypeSelector, "type_selector") + typ = properties.String('type', default="local_attribute_and_object", deserializable=False) + + attribute_type = Union[UUID, str, LinkByUID, AttributeTemplate] + object_type = Union[UUID, str, LinkByUID, BaseTemplate] + constraint_type = Tuple[attribute_type, BaseBounds] + + def __init__(self, + name: str, + *, + headers: List[str], + template: attribute_type, + object_template: object_type, + attribute_constraints: Optional[List[constraint_type]] = None, + type_selector: DataObjectTypeSelector = DataObjectTypeSelector.PREFER_RUN): + self.name = name + self.headers = headers + self.template = _make_link_by_uid(template) + self.object_template = _make_link_by_uid(object_template) + self.attribute_constraints = None if attribute_constraints is None \ + else [(_make_link_by_uid(x[0]), x[1]) for x in attribute_constraints] + self.type_selector = type_selector + + class IngredientIdentifierByProcessTemplateAndName( Serializable['IngredientIdentifierByProcessAndName'], Variable): """Ingredient identifier associated with a process template and a name. diff --git a/src/citrine/resources/table_config.py b/src/citrine/resources/table_config.py index 9128ac01b..3a5726709 100644 --- a/src/citrine/resources/table_config.py +++ b/src/citrine/resources/table_config.py @@ -13,16 +13,18 @@ from citrine._serialization import properties from citrine._session import Session from citrine._utils.functions import format_escaped_url, _pad_positional_args +from citrine.resources.dataset import DatasetCollection from citrine.resources.data_concepts import CITRINE_SCOPE, _make_link_by_uid from citrine.resources.process_template import ProcessTemplate from citrine.gemd_queries.gemd_query import GemdQuery from citrine.gemtables.columns import Column, MeanColumn, IdentityColumn, OriginalUnitsColumn, \ ConcatColumn from citrine.gemtables.rows import Row -from citrine.gemtables.variables import Variable, IngredientIdentifierByProcessTemplateAndName, \ - IngredientQuantityByProcessAndName, IngredientQuantityDimension, \ - IngredientIdentifierInOutput, IngredientQuantityInOutput, \ +from citrine.gemtables.variables import ( + Variable, IngredientIdentifierByProcessTemplateAndName, IngredientQuantityByProcessAndName, + IngredientQuantityDimension, IngredientIdentifierInOutput, IngredientQuantityInOutput, IngredientLabelsSetByProcessAndName, IngredientLabelsSetInOutput +) from typing import TYPE_CHECKING if TYPE_CHECKING: # pragma: no cover @@ -53,6 +55,17 @@ class TableConfigInitiator(BaseEnumeration): UI = "UI" +class TableFromGemdQueryAlgorithm(BaseEnumeration): + """The algorithm to use in automatically building a Table Configuration. + + * UNSPECIFIED corresponds to initial default state; includes bubbling up process attributes + * MULTISTEP_MATERIALS corresponds keeping all attributes local to a material node / row + """ + + UNSPECIFIED = "unspecified" + MULTISTEP_MATERIALS = "multistep_materials" + + class TableConfig(Resource["TableConfig"]): """ The Table Configuration used to build GEM Tables. @@ -73,7 +86,8 @@ class TableConfig(Resource["TableConfig"]): Column definitions, which describe how the variables are shaped into the table gemd_query: Optional[GemdQuery] The query used to define the materials underpinning this table - + generation_algorithm: TableFromGemdQueryAlgorithm + Which algorithm was used to generate the config based on the GemdQuery results """ # FIXME (DML): rename this (this is dependent on the server side) @@ -100,15 +114,27 @@ def _get_dups(lst: List) -> List: rows = properties.List(properties.Object(Row), "rows") columns = properties.List(properties.Object(Column), "columns") gemd_query = properties.Optional(properties.Object(GemdQuery), "gemd_query") - - def __init__(self, name: str, *, description: str, datasets: List[UUID], - variables: List[Variable], rows: List[Row], columns: List[Column]): + generation_algorithm = properties.Optional( + properties.Enumeration(TableFromGemdQueryAlgorithm), "generation_algorithm" + ) + + def __init__(self, name: str, + *, + description: str, + datasets: List[UUID], + variables: List[Variable], + rows: List[Row], + columns: List[Column], + gemd_query: GemdQuery = None, + generation_algorithm: Optional[TableFromGemdQueryAlgorithm] = None): self.name = name self.description = description self.datasets = datasets self.rows = rows self.variables = variables self.columns = columns + self.gemd_query = gemd_query + self.generation_algorithm = generation_algorithm # Note that these validations only apply at construction time. The current intended usage # is for this object to be created holistically; if changed, then these will need @@ -461,8 +487,9 @@ def get_for_table(self, table: "GemTable") -> TableConfig: # noqa: F821 """ # the route to fetch the config is built off the display table route tree - path = (f'projects/{self.project_id}/display-tables/{table.uid}/versions/{table.version}' - '/definition') + path = format_escaped_url( + 'projects/{}/display-tables/{}/versions/{}/definition', + self.project_id, table.uid, table.version) data = self.session.get_resource(path) return self.build(data) @@ -538,6 +565,65 @@ def default_for_material( ambiguous = [(Variable.build(v), Column.build(c)) for v, c in data['ambiguous']] return config, ambiguous + def from_query( + self, + gemd_query: GemdQuery, + *, + name: str = None, + description: str = None, + algorithm: Optional[TableFromGemdQueryAlgorithm] = None, + register_config: bool = False + ) -> Tuple[TableConfig, List[Tuple[Variable, Column]]]: + """ + Build a TableConfig based on the results of a database query. + + Parameters + ---------- + gemd_query: GemdQuery + What content should end up in the table + name: str, optional + The name for the table config. Defaults to autogenerated message. + description: str, optional + The description of the table config. Defaults to autogenerated message. + algorithm: TableBuildAlgorithm, optional + The algorithm to use in generating a Table Configuration from the sample material + history. If unspecified, uses the webservice's default. + register_config: bool, optional + Whether to register the config + + Returns + ------- + List[Tuple[Variable, Column]] + A table config as well as addition variables/columns which would result in + ambiguous matches if included in the config. + + """ + if name is None: + collection = DatasetCollection( + session=self.session, + team_id=self.team_id + ) + name = (f"Automatic Table for Dataset: " + f"{', '.join([collection.get(x).name for x in gemd_query.datasets])}") + + params = {"name": name} + if description is not None: + params['description'] = description + if algorithm is not None: + params['algorithm'] = algorithm + data = self.session.post_resource( + format_escaped_url('teams/{}/table-configs/from-query', self.team_id), + params=params, + json=gemd_query.dump() + ) + config = TableConfig.build(data['config']) + ambiguous = [(Variable.build(v), Column.build(c)) for v, c in data['ambiguous']] + + if register_config: + return self.register(config), ambiguous + else: + return config, ambiguous + def preview(self, *, table_config: TableConfig, preview_materials: List[LinkByUID] = None @@ -552,7 +638,7 @@ def preview(self, *, List of links to the material runs to use as terminal materials in the preview """ - path = path = format_escaped_url( + path = format_escaped_url( "teams/{}/ara-definitions/preview", self.team_id ) diff --git a/tests/gemtable/test_variables.py b/tests/gemtable/test_variables.py index 2db09f0f4..beb28cac6 100644 --- a/tests/gemtable/test_variables.py +++ b/tests/gemtable/test_variables.py @@ -14,6 +14,7 @@ AttributeByTemplateAndObjectTemplate(name="density", headers=["density"], attribute_template=LinkByUID(scope="template", id="density"), object_template=LinkByUID(scope="template", id="object")), AttributeInOutput(name="density", headers=["density"], attribute_template=LinkByUID(scope="template", id="density"), process_templates=[LinkByUID(scope="template", id="object")]), LocalAttribute(name="density", headers=["density"], template=LinkByUID(scope="templates", id="density"), attribute_constraints=[[LinkByUID(scope="templates", id="density"), RealBounds(0, 100, "g/cm**3")]]), + LocalAttributeAndObject(name="density", headers=["density"], template=LinkByUID(scope="templates", id="density"), object_template=LinkByUID(scope="templates", id="object"), attribute_constraints=[[LinkByUID(scope="templates", id="density"), RealBounds(0, 100, "g/cm**3")]]), IngredientIdentifierByProcessTemplateAndName(name="ingredient id", headers=["density"], process_template=LinkByUID(scope="template", id="process"), ingredient_name="ingredient", scope="scope"), IngredientIdentifierInOutput(name="ingredient id", headers=["ingredient id"], ingredient_name="ingredient", process_templates=[LinkByUID(scope="template", id="object")], scope="scope"), LocalIngredientIdentifier(name="ingredient id", headers=["ingredient id"], ingredient_name="ingredient", scope="scope"), diff --git a/tests/resources/test_table_config.py b/tests/resources/test_table_config.py index 195e02d23..7be645f05 100644 --- a/tests/resources/test_table_config.py +++ b/tests/resources/test_table_config.py @@ -2,6 +2,7 @@ import pytest from gemd.entity.link_by_uid import LinkByUID +from citrine.gemd_queries.gemd_query import GemdQuery from citrine.gemtables.columns import MeanColumn, OriginalUnitsColumn, StdDevColumn, IdentityColumn from citrine.gemtables.rows import MaterialRunByTemplate from citrine.gemtables.variables import AttributeByTemplate, TerminalMaterialInfo, \ @@ -9,14 +10,16 @@ IngredientIdentifierByProcessTemplateAndName, TerminalMaterialIdentifier, \ IngredientQuantityInOutput, IngredientIdentifierInOutput, \ IngredientLabelsSetByProcessAndName, IngredientLabelsSetInOutput -from citrine.resources.table_config import TableConfig, TableConfigCollection, TableBuildAlgorithm +from citrine.resources.table_config import TableConfig, TableConfigCollection, TableBuildAlgorithm, \ + TableFromGemdQueryAlgorithm from citrine.resources.data_concepts import CITRINE_SCOPE from citrine.resources.material_run import MaterialRun from citrine.resources.project import Project from citrine.resources.process_template import ProcessTemplate from citrine.resources.team import Team from citrine.seeding.find_or_create import create_or_update -from tests.utils.factories import TableConfigResponseDataFactory, ListTableConfigResponseDataFactory +from tests.utils.factories import TableConfigResponseDataFactory, ListTableConfigResponseDataFactory, \ + GemdQueryDataFactory, TableConfigDataFactory, DatasetDataFactory from tests.utils.session import FakeSession, FakeCall @@ -252,14 +255,7 @@ def test_default_for_material(collection: TableConfigCollection, session): """Test that default for material hits the right route""" # Given dummy_resp = { - 'config': TableConfig( - name='foo', - description='foo', - variables=[], - columns=[], - rows=[], - datasets=[] - ).dump(), + 'config': TableConfigDataFactory(), 'ambiguous': [ [ TerminalMaterialIdentifier(name='foo', headers=['foo'], scope='id').dump(), @@ -319,6 +315,81 @@ def test_default_for_material_failure(collection: TableConfigCollection): ) +def test_from_query(collection: TableConfigCollection, session): + """Test that default for material hits the right route""" + query = GemdQueryDataFactory() + config = TableConfigDataFactory() + + config_resp = { + 'config': config, + 'ambiguous': [ + [ + TerminalMaterialIdentifier(name='foo', headers=['foo'], scope='id').dump(), + IdentityColumn(data_source='foo').dump(), + ] + ], + } + session.responses.append(config_resp) + fake_call = FakeCall( + method='POST', + path=f'teams/{collection.team_id}/table-configs/from-query', + params={ + 'name': config['name'], + 'description': config['description'], + 'algorithm': TableFromGemdQueryAlgorithm.MULTISTEP_MATERIALS, + }, + json=query, + ) + + collection.from_query( + name=config['name'], + description=config['description'], + gemd_query=GemdQuery.build(query), + algorithm=TableFromGemdQueryAlgorithm.MULTISTEP_MATERIALS + ) + assert 1 == session.num_calls + assert session.last_call.method == fake_call.method + assert session.last_call.path == fake_call.path + assert session.last_call.params == fake_call.params + last_query: GemdQuery = GemdQuery.build(session.last_call.json) + fake_query: GemdQuery = GemdQuery.build(fake_call.json) + assert last_query.datasets == fake_query.datasets + assert last_query.object_types == fake_query.object_types + assert last_query.schema_version == fake_query.schema_version + assert last_query.dump() == fake_query.dump() # Ordering issues + + +def test_from_nameless_query_and_register(collection: TableConfigCollection, session): + """Test that default for material hits the right route""" + query = GemdQueryDataFactory() + config = TableConfigDataFactory(generation_algorithm=TableFromGemdQueryAlgorithm.MULTISTEP_MATERIALS) + + dataset_resps = [DatasetDataFactory(id=dataset) for dataset in query['datasets']] + config_resp = { + 'config': config, + 'ambiguous': [ + [ + TerminalMaterialIdentifier(name='foo', headers=['foo'], scope='id').dump(), + IdentityColumn(data_source='foo').dump(), + ] + ], + } + register_resp = TableConfigResponseDataFactory(version__ara_definition=config) + + session.responses.extend(dataset_resps) + session.responses.append(config_resp) + session.responses.append(register_resp) + + generated, _ = collection.from_query( + gemd_query=GemdQuery.build(query), + description='my_description', + register_config=True + ) + assert session.num_calls == len(query['datasets']) + 1 + 1 + assert generated != TableConfig.build(config) # Because it has ids + assert generated.variables == TableConfig.build(config).variables + + def test_add_columns(): """Test the behavior of AraDefinition.add_columns""" empty = empty_defn() diff --git a/tests/utils/factories.py b/tests/utils/factories.py index 70d92dec6..962262451 100644 --- a/tests/utils/factories.py +++ b/tests/utils/factories.py @@ -257,7 +257,6 @@ class PropertiesCriteriaDataFactory(factory.DictFactory): type = PropertiesCriteria.typ property_templates_filter = factory.List([factory.Faker('uuid4')]) value_type_filter = factory.SubFactory(RealFilterDataFactory) - classifications = factory.Faker('enum', enum_cls=MaterialClassification) class Params: integer = factory.Trait(