From dc3f887967532158f4c1b159e9a7f3e5ef2b3cfc Mon Sep 17 00:00:00 2001 From: Nathan Perkins Date: Mon, 18 Dec 2023 13:19:52 +0000 Subject: [PATCH] [PY-576][PY-581] Create + Update of properties (#756) * create update scaffold * create + property changes * linting * linting * typing * create + update: including e2e tests * removing unnecessary import * removing bad comments * test cleanup * linting * unit tests --- .vscode/settings.json | 2 +- darwin/future/core/properties/__init__.py | 2 + darwin/future/core/properties/create.py | 36 +++ darwin/future/core/properties/get.py | 2 +- darwin/future/core/properties/update.py | 74 ++++++ darwin/future/core/types/common.py | 1 + darwin/future/data_objects/properties.py | 63 ++++- darwin/future/tests/core/fixtures.py | 14 +- .../tests/core/properties/test_create.py | 51 ++++ .../tests/core/properties/test_update.py | 57 +++++ e2e_tests/cli/convert/test_convert.py | 6 +- e2e_tests/conftest.py | 25 +- e2e_tests/exceptions.py | 8 + e2e_tests/objects.py | 1 - e2e_tests/sdk/__init__.py | 0 e2e_tests/sdk/future/core/__init__.py | 0 e2e_tests/sdk/future/core/test_properties.py | 212 +++++++++++++++++ e2e_tests/setup_tests.py | 221 ++++++++++++++++-- 18 files changed, 731 insertions(+), 44 deletions(-) create mode 100644 darwin/future/core/properties/create.py create mode 100644 darwin/future/core/properties/update.py create mode 100644 darwin/future/tests/core/properties/test_create.py create mode 100644 darwin/future/tests/core/properties/test_update.py create mode 100644 e2e_tests/sdk/__init__.py create mode 100644 e2e_tests/sdk/future/core/__init__.py create mode 100644 e2e_tests/sdk/future/core/test_properties.py diff --git a/.vscode/settings.json b/.vscode/settings.json index 28f99b8d9..40489cb53 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,7 +4,7 @@ "editor.formatOnSave": true, "editor.tabSize": 4, "editor.codeActionsOnSave": { - "source.organizeImports": true, + "source.organizeImports": "explicit" }, "editor.defaultFormatter": "ms-python.black-formatter" }, diff --git a/darwin/future/core/properties/__init__.py b/darwin/future/core/properties/__init__.py index ef53ea047..d65bc8357 100644 --- a/darwin/future/core/properties/__init__.py +++ b/darwin/future/core/properties/__init__.py @@ -1,5 +1,7 @@ +from darwin.future.core.properties.create import create_property from darwin.future.core.properties.get import ( get_property_by_id, get_team_full_properties, get_team_properties, ) +from darwin.future.core.properties.update import update_property, update_property_value diff --git a/darwin/future/core/properties/create.py b/darwin/future/core/properties/create.py new file mode 100644 index 000000000..e290cf6fa --- /dev/null +++ b/darwin/future/core/properties/create.py @@ -0,0 +1,36 @@ +from typing import Optional, Union + +from pydantic import parse_obj_as + +from darwin.future.core.client import ClientCore +from darwin.future.core.types.common import JSONDict +from darwin.future.data_objects.properties import FullProperty + + +def create_property( + client: ClientCore, + params: Union[FullProperty, JSONDict], + team_slug: Optional[str] = None, +) -> FullProperty: + """ + Creates a property for the specified team slug. + + Parameters: + client (ClientCore): The client to use for the request. + team_slug (Optional[str]): The slug of the team to get. If not specified, the + default team from the client's config will be used. + params (Optional[JSONType]): The JSON data to use for the request. + + Returns: + FullProperty: FullProperty object for the created property. + + Raises: + HTTPError: If the response status code is not in the 200-299 range. + """ + if not team_slug: + team_slug = client.config.default_team + if isinstance(params, FullProperty): + params = params.to_create_endpoint() + response = client.post(f"/v2/teams/{team_slug}/properties", data=params) + assert isinstance(response, dict) + return parse_obj_as(FullProperty, response) diff --git a/darwin/future/core/properties/get.py b/darwin/future/core/properties/get.py index 23a80a380..5470f19f9 100644 --- a/darwin/future/core/properties/get.py +++ b/darwin/future/core/properties/get.py @@ -67,6 +67,6 @@ def get_property_by_id( """ if not team_slug: team_slug = client.config.default_team - response = client.get(f"/v2/teams/{team_slug}/properties/{property_id}") + response = client.get(f"/v2/teams/{team_slug}/properties/{str(property_id)}") assert isinstance(response, dict) return parse_obj_as(FullProperty, response) diff --git a/darwin/future/core/properties/update.py b/darwin/future/core/properties/update.py new file mode 100644 index 000000000..529fc15ee --- /dev/null +++ b/darwin/future/core/properties/update.py @@ -0,0 +1,74 @@ +from typing import Optional, Union + +from pydantic import parse_obj_as + +from darwin.future.core.client import ClientCore +from darwin.future.core.types.common import JSONDict +from darwin.future.data_objects.properties import FullProperty, PropertyValue + + +def update_property( + client: ClientCore, + params: Union[FullProperty, JSONDict], + team_slug: Optional[str] = None, +) -> FullProperty: + """ + Updates a property for the specified team slug. + + Parameters: + client (ClientCore): The client to use for the request. + team_slug (Optional[str]): The slug of the team to get. If not specified, the + default team from the client's config will be used. + params (Optional[JSONType]): The JSON data to use for the request. + + Returns: + FullProperty: FullProperty object for the created property. + + Raises: + HTTPError: If the response status code is not in the 200-299 range. + """ + if not team_slug: + team_slug = client.config.default_team + if isinstance(params, FullProperty): + id, params = params.to_update_endpoint() + else: + id = params.get("id") + del params["id"] + response = client.put(f"/v2/teams/{team_slug}/properties/{id}", data=params) + assert isinstance(response, dict) + return parse_obj_as(FullProperty, response) + + +def update_property_value( + client: ClientCore, + params: Union[PropertyValue, JSONDict], + item_id: str, + team_slug: Optional[str] = None, +) -> PropertyValue: + """ + Updates a property value for the specified property id. + + Parameters: + client (ClientCore): The client to use for the request. + team_slug (Optional[str]): The slug of the team to get. If not specified, the + default team from the client's config will be used. + params (Optional[JSONType]): The JSON data to use for the request. + + Returns: + FullProperty: FullProperty object for the created property. + + Raises: + HTTPError: If the response status code is not in the 200-299 range. + """ + if not team_slug: + team_slug = client.config.default_team + if isinstance(params, PropertyValue): + id, params = params.to_update_endpoint() + else: + id = params.get("id") + del params["id"] + response = client.put( + f"/v2/teams/{team_slug}/properties/{item_id}/property_values/{id}", data=params + ) + assert isinstance(response, dict) + return parse_obj_as(PropertyValue, response) diff --git a/darwin/future/core/types/common.py b/darwin/future/core/types/common.py index a282e51f5..368ce122a 100644 --- a/darwin/future/core/types/common.py +++ b/darwin/future/core/types/common.py @@ -5,6 +5,7 @@ from darwin.future.data_objects import validators as darwin_validators JSONType = Union[Dict[str, Any], List[Dict[str, Any]]] # type: ignore +JSONDict = Dict[str, Any] # type: ignore class Implements_str(Protocol): diff --git a/darwin/future/data_objects/properties.py b/darwin/future/data_objects/properties.py index 3e9443f5f..2297def6b 100644 --- a/darwin/future/data_objects/properties.py +++ b/darwin/future/data_objects/properties.py @@ -3,14 +3,23 @@ import json import os from pathlib import Path -from typing import Dict, List, Optional, Union +from typing import Dict, List, Literal, Optional, Tuple, Union from pydantic import validator from darwin.future.data_objects.pydantic_base import DefaultDarwin +PropertyType = Literal[ + "multi_select", + "single_select", + "text", + "attributes", + "instance_id", + "directional_vector", +] -class PropertyOption(DefaultDarwin): + +class PropertyValue(DefaultDarwin): """ Describes a single option for a property @@ -25,16 +34,29 @@ class PropertyOption(DefaultDarwin): id: Optional[str] position: Optional[int] - type: str + type: Literal["string"] = "string" value: Union[Dict[str, str], str] - color: str + color: str = "auto" @validator("color") def validate_rgba(cls, v: str) -> str: - if not v.startswith("rgba"): - raise ValueError("Color must be in rgba format") + if not v.startswith("rgba") and v != "auto": + raise ValueError("Color must be in rgba format or 'auto'") + return v + + @validator("value") + def validate_value(cls, v: Union[Dict[str, str], str]) -> Dict[str, str]: + """TODO: Replace once the value.value bug is fixed in the API""" + if isinstance(v, str): + return {"value": v} return v + def to_update_endpoint(self) -> Tuple[str, dict]: + if self.id is None: + raise ValueError("id must be set") + updated_base = self.dict(exclude={"id", "type"}) + return self.id, updated_base + class FullProperty(DefaultDarwin): """ @@ -49,14 +71,37 @@ class FullProperty(DefaultDarwin): id: Optional[str] name: str - type: str + type: PropertyType description: Optional[str] required: bool slug: Optional[str] team_id: Optional[int] annotation_class_id: Optional[int] - property_values: Optional[List[PropertyOption]] - options: Optional[List[PropertyOption]] + property_values: Optional[List[PropertyValue]] + options: Optional[List[PropertyValue]] + + def to_create_endpoint( + self, + ) -> dict: + if self.annotation_class_id is None: + raise ValueError("annotation_class_id must be set") + return self.dict( + include={ + "name": True, + "type": True, + "required": True, + "annotation_class_id": True, + "property_values": {"__all__": {"type", "value", "color"}}, + "description": True, + } + ) + + def to_update_endpoint(self) -> Tuple[str, dict]: + if self.id is None: + raise ValueError("id must be set") + updated_base = self.to_create_endpoint() + del updated_base["annotation_class_id"] # can't update this field + return self.id, updated_base class MetaDataClass(DefaultDarwin): diff --git a/darwin/future/tests/core/fixtures.py b/darwin/future/tests/core/fixtures.py index 8c7434416..b7c4ad0bc 100644 --- a/darwin/future/tests/core/fixtures.py +++ b/darwin/future/tests/core/fixtures.py @@ -8,25 +8,25 @@ from darwin.future.core.client import ClientCore, DarwinConfig from darwin.future.data_objects.dataset import DatasetCore from darwin.future.data_objects.item import ItemCore, ItemLayout, ItemSlot -from darwin.future.data_objects.properties import FullProperty, PropertyOption +from darwin.future.data_objects.properties import FullProperty, PropertyValue from darwin.future.data_objects.team import TeamCore, TeamMemberCore from darwin.future.data_objects.team_member_role import TeamMemberRole from darwin.future.data_objects.workflow import WorkflowCore @pytest.fixture -def base_property_option() -> PropertyOption: - return PropertyOption( +def base_property_value() -> PropertyValue: + return PropertyValue( id="0", position=0, - type="text", + type="string", value="test-value", color="rgba(0,0,0,0)", ) @pytest.fixture -def base_property_object(base_property_option: PropertyOption) -> FullProperty: +def base_property_object(base_property_value: PropertyValue) -> FullProperty: return FullProperty( id="0", name="test-property", @@ -36,8 +36,8 @@ def base_property_object(base_property_option: PropertyOption) -> FullProperty: slug="test-property", team_id=0, annotation_class_id=0, - property_values=[base_property_option], - options=[base_property_option], + property_values=[base_property_value], + options=[base_property_value], ) diff --git a/darwin/future/tests/core/properties/test_create.py b/darwin/future/tests/core/properties/test_create.py new file mode 100644 index 000000000..f0fe52007 --- /dev/null +++ b/darwin/future/tests/core/properties/test_create.py @@ -0,0 +1,51 @@ +import responses + +from darwin.future.core.client import ClientCore +from darwin.future.core.properties import create_property +from darwin.future.data_objects.properties import FullProperty +from darwin.future.tests.core.fixtures import * + + +@responses.activate +def test_create_property( + base_client: ClientCore, base_property_object: FullProperty +) -> None: + # Mocking the response using responses library + responses.add( + responses.POST, + f"{base_client.config.base_url}api/v2/teams/{base_client.config.default_team}/properties", + json=base_property_object.dict(), + status=200, + ) + # Call the function being tested + property = create_property( + base_client, + params=base_property_object, + team_slug=base_client.config.default_team, + ) + + # Assertions + assert isinstance(property, FullProperty) + assert property == base_property_object + + +@responses.activate +def test_create_property_from_json( + base_client: ClientCore, base_property_object: FullProperty +) -> None: + json = base_property_object.to_create_endpoint() + # Mocking the response using responses library + responses.add( + responses.POST, + f"{base_client.config.base_url}api/v2/teams/{base_client.config.default_team}/properties", + json=base_property_object.dict(), + status=200, + ) + # Call the function being tested + property = create_property( + base_client, params=json, team_slug=base_client.config.default_team + ) + + # Assertions + assert isinstance(property, FullProperty) + assert property == base_property_object diff --git a/darwin/future/tests/core/properties/test_update.py b/darwin/future/tests/core/properties/test_update.py new file mode 100644 index 000000000..16df899fc --- /dev/null +++ b/darwin/future/tests/core/properties/test_update.py @@ -0,0 +1,57 @@ +import responses + +from darwin.future.core.client import ClientCore +from darwin.future.core.properties import update_property, update_property_value +from darwin.future.data_objects.properties import FullProperty, PropertyValue +from darwin.future.tests.core.fixtures import * + + +@responses.activate +def test_update_property( + base_client: ClientCore, base_property_object: FullProperty +) -> None: + # Mocking the response using responses library + responses.add( + responses.PUT, + f"{base_client.config.base_url}api/v2/teams/{base_client.config.default_team}/properties/{base_property_object.id}", + json=base_property_object.dict(), + status=200, + ) + # Call the function being tested + property = update_property( + base_client, + params=base_property_object, + team_slug=base_client.config.default_team, + ) + + # Assertions + assert isinstance(property, FullProperty) + assert property == base_property_object + + +@responses.activate +def test_update_property_value( + base_client: ClientCore, base_property_object: FullProperty +) -> None: + # Mocking the response using responses library + item_id = base_property_object.id + assert item_id + assert base_property_object.property_values + pv = base_property_object.property_values[0] + responses.add( + responses.PUT, + f"{base_client.config.base_url}api/v2/teams/{base_client.config.default_team}/properties/{item_id}/property_values/{pv.id}", + json=pv.dict(), + status=200, + ) + # Call the function being tested + property_value = update_property_value( + base_client, + params=pv, + item_id=item_id, + team_slug=base_client.config.default_team, + ) + + # Assertions + assert isinstance(property_value, PropertyValue) + assert property_value == pv diff --git a/e2e_tests/cli/convert/test_convert.py b/e2e_tests/cli/convert/test_convert.py index eddab8dbc..c3c13cec2 100644 --- a/e2e_tests/cli/convert/test_convert.py +++ b/e2e_tests/cli/convert/test_convert.py @@ -87,7 +87,6 @@ def test_darwin_convert( assert_cli(result, 0) self.compare_directories(expectation_path, tmp_path) - def patch_coco(self, path: Path) -> None: """ Patch coco file to match the expected output, includes changes to year and date_created, @@ -100,10 +99,13 @@ def patch_coco(self, path: Path) -> None: temp["info"]["year"] = 2023 temp["info"]["date_created"] = "2023/12/05" with open(path, "w") as f: - op = json.dumps(temp, option=json.OPT_INDENT_2 | json.OPT_SERIALIZE_NUMPY).decode("utf-8") + op = json.dumps( + temp, option=json.OPT_INDENT_2 | json.OPT_SERIALIZE_NUMPY + ).decode("utf-8") f.write(op) except Exception: print(f"Error patching {path}") + if __name__ == "__main__": pytest.main(["-vv", "-s", __file__]) diff --git a/e2e_tests/conftest.py b/e2e_tests/conftest.py index d26b4fbac..d4ebf9755 100644 --- a/e2e_tests/conftest.py +++ b/e2e_tests/conftest.py @@ -10,7 +10,12 @@ from darwin.future.data_objects.typing import UnknownType from e2e_tests.exceptions import E2EEnvironmentVariableNotSet from e2e_tests.objects import ConfigValues -from e2e_tests.setup_tests import setup_tests, teardown_tests +from e2e_tests.setup_tests import ( + setup_annotation_classes, + setup_datasets, + teardown_annotation_classes, + teardown_tests, +) def pytest_configure(config: pytest.Config) -> None: @@ -45,11 +50,16 @@ def pytest_sessionstart(session: pytest.Session) -> None: session.config.cache.set("api_key", api_key) session.config.cache.set("team_slug", team_slug) - datasets = setup_tests( - ConfigValues(server=server, api_key=api_key, team_slug=team_slug) - ) + config = ConfigValues(server=server, api_key=api_key, team_slug=team_slug) + datasets = setup_datasets(config) + teardown_annotation_classes( + config, [] + ) # Ensure that there are no annotation classes before running tests + annotation_classes = setup_annotation_classes(config) # pytest.datasets = datasets setattr(pytest, "datasets", datasets) + setattr(pytest, "annotation_classes", annotation_classes) + setattr(pytest, "config_values", config) # Set the environment variables for running CLI arguments environ["DARWIN_BASE_URL"] = server environ["DARWIN_TEAM"] = team_slug @@ -66,6 +76,11 @@ def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None: datasets = pytest.datasets if datasets is None: raise ValueError("Datasets were not created, so could not tear them down") + annotation_classes = pytest.annotation_classes + if annotation_classes is None: + raise ValueError( + "Annotation classes were not created, so could not tear them down" + ) server = session.config.cache.get("server", None) api_key = session.config.cache.get("api_key", None) @@ -81,6 +96,8 @@ def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None: config = ConfigValues(server=server, api_key=api_key, team_slug=team) assert isinstance(datasets, List) teardown_tests(config, datasets) + assert isinstance(annotation_classes, List) + teardown_annotation_classes(config, annotation_classes) @pytest.fixture( diff --git a/e2e_tests/exceptions.py b/e2e_tests/exceptions.py index 4ba178738..eb2ab1222 100644 --- a/e2e_tests/exceptions.py +++ b/e2e_tests/exceptions.py @@ -17,3 +17,11 @@ class E2EEnvironmentVariableNotSet(E2EException): def __init__(self, name: str, *args: List, **kwargs: Dict) -> None: super().__init__(*args, **kwargs) self.name = name + + +class DataAlreadyExists(E2EException): + """Raised when the teardown process fails and has left legacy data""" + + def __init__(self, name: str, *args: List, **kwargs: Dict) -> None: + super().__init__(*args, **kwargs) + self.name = name diff --git a/e2e_tests/objects.py b/e2e_tests/objects.py index 6beaec09b..24c48f29c 100644 --- a/e2e_tests/objects.py +++ b/e2e_tests/objects.py @@ -16,7 +16,6 @@ class E2EAnnotation: @dataclass class E2EAnnotationClass: name: str - slug: str type: Literal["bbox", "polygon"] id: int diff --git a/e2e_tests/sdk/__init__.py b/e2e_tests/sdk/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/e2e_tests/sdk/future/core/__init__.py b/e2e_tests/sdk/future/core/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/e2e_tests/sdk/future/core/test_properties.py b/e2e_tests/sdk/future/core/test_properties.py new file mode 100644 index 000000000..889bcb411 --- /dev/null +++ b/e2e_tests/sdk/future/core/test_properties.py @@ -0,0 +1,212 @@ +from pathlib import Path +from typing import Tuple + +import pytest + +from darwin.future.core.client import ClientCore, DarwinConfig +from darwin.future.core.properties.create import create_property +from darwin.future.core.properties.get import ( + get_property_by_id, + get_team_full_properties, + get_team_properties, +) +from darwin.future.core.properties.update import update_property, update_property_value +from darwin.future.data_objects.properties import FullProperty, PropertyValue +from e2e_tests.objects import ConfigValues, E2EAnnotationClass +from e2e_tests.setup_tests import create_annotation_class + + +@pytest.fixture +def base_config(tmpdir: str) -> DarwinConfig: + config = pytest.config_values + assert config is not None + assert isinstance(config, ConfigValues) + server = config.server + api_key = config.api_key + team_slug = config.team_slug + return DarwinConfig( + api_key=api_key, + base_url=server, + api_endpoint=server + "/api/", + datasets_dir=Path(tmpdir), + default_team=team_slug, + teams={}, + ) + + +@pytest.fixture +def base_client(base_config: DarwinConfig) -> ClientCore: + return ClientCore(base_config) + + +@pytest.fixture +def base_property_to_create() -> FullProperty: + return FullProperty( + id=None, + name="test_property", + type="single_select", + description="", + required=False, + slug="test_property", + team_id=None, + annotation_class_id=None, + options=None, + property_values=[ + PropertyValue( + id=None, + position=None, + type="string", + value="test_value", + color="rgba(100,100,100,1)", + ) + ], + ) + + +def helper_create_annotation(name: str) -> Tuple[ConfigValues, E2EAnnotationClass]: + config = pytest.config_values + assert config is not None + assert isinstance(config, ConfigValues) + return config, create_annotation_class(name, "polygon", config) + + +def test_create_property( + base_client: ClientCore, base_property_to_create: FullProperty +) -> None: + config, new_annotation_class = helper_create_annotation("test_for_create_property") + + # Actual test + base_property_to_create.annotation_class_id = new_annotation_class.id + output = create_property( + base_client, base_property_to_create, team_slug=config.team_slug + ) + assert isinstance(output, FullProperty) + + +def test_get_team_properties( + base_client: ClientCore, base_property_to_create: FullProperty +) -> None: + config, new_annotation_class = helper_create_annotation("test_for_get_properties") + + # Create a base property to use for the test + # TODO: replace this with a fixture to isolate the test + base_property_to_create.annotation_class_id = new_annotation_class.id + output = create_property( + base_client, base_property_to_create, team_slug=config.team_slug + ) + output.property_values = ( + None # the base get_team_properties doesn't include property_values + ) + assert isinstance(output, FullProperty) + + properties = get_team_properties(base_client, team_slug=config.team_slug) + assert isinstance(properties, list) + assert all(isinstance(property, FullProperty) for property in properties) + assert len(properties) > 0 + + for property in properties: + if property.annotation_class_id == new_annotation_class.id: + assert property == output + break + + +def test_get_team_full_properties( + base_client: ClientCore, base_property_to_create: FullProperty +) -> None: + config, new_annotation_class = helper_create_annotation( + "test_for_get_full_properties" + ) + + # Create a base property to use for the test + # TODO: replace this with a fixture to isolate the test + base_property_to_create.annotation_class_id = new_annotation_class.id + output = create_property( + base_client, base_property_to_create, team_slug=config.team_slug + ) + assert isinstance(output, FullProperty) + + properties = get_team_full_properties(base_client, team_slug=config.team_slug) + assert isinstance(properties, list) + assert all(isinstance(property, FullProperty) for property in properties) + assert len(properties) > 0 + + for property in properties: + if property.annotation_class_id == new_annotation_class.id: + assert property == output + break + + +def test_get_property_by_id( + base_client: ClientCore, base_property_to_create: FullProperty +) -> None: + config, new_annotation_class = helper_create_annotation( + "test_for_get_property_by_id" + ) + + # Create a base property to use for the test + # TODO: replace this with a fixture to isolate the test + base_property_to_create.annotation_class_id = new_annotation_class.id + output = create_property( + base_client, base_property_to_create, team_slug=config.team_slug + ) + assert isinstance(output, FullProperty) + assert output.id is not None + + prop = get_property_by_id(base_client, output.id, team_slug=config.team_slug) + assert isinstance(prop, FullProperty) + assert output == prop + + +def test_update_property( + base_client: ClientCore, base_property_to_create: FullProperty +) -> None: + config, new_annotation_class = helper_create_annotation("test_for_update_property") + + # Create a base property to use for the test + # TODO: replace this with a fixture to isolate the test + base_property_to_create.annotation_class_id = new_annotation_class.id + output = create_property( + base_client, base_property_to_create, team_slug=config.team_slug + ) + assert isinstance(output, FullProperty) + assert output.id is not None + + assert output.property_values is not None + output.property_values[0].value = "new_value" + # default behaviour for update endpoint is to append the new value to the existing values + new_output = update_property(base_client, output, team_slug=config.team_slug) + assert isinstance(new_output, FullProperty) + assert new_output.property_values is not None + assert len(new_output.property_values) == 2 + assert new_output.property_values[1].value == output.property_values[0].value + + +def test_update_property_value( + base_client: ClientCore, base_property_to_create: FullProperty +) -> None: + config, new_annotation_class = helper_create_annotation( + "test_for_update_property_value" + ) + + # Create a base property to use for the test + # TODO: replace this with a fixture to isolate the test + base_property_to_create.annotation_class_id = new_annotation_class.id + output = create_property( + base_client, base_property_to_create, team_slug=config.team_slug + ) + assert isinstance(output, FullProperty) + assert output.id is not None + + assert output.property_values is not None + id = output.id + pv = output.property_values[0] + pv.value = "new_value" + new_output = update_property_value( + base_client, pv, item_id=id, team_slug=config.team_slug + ) + assert isinstance(new_output, PropertyValue) + assert pv == new_output + + +if __name__ == "__main__": + pytest.main(["-vv", "-s", __file__]) diff --git a/e2e_tests/setup_tests.py b/e2e_tests/setup_tests.py index 7fb1424a5..b454c5149 100644 --- a/e2e_tests/setup_tests.py +++ b/e2e_tests/setup_tests.py @@ -10,8 +10,8 @@ import requests from PIL import Image -from e2e_tests.exceptions import E2EException -from e2e_tests.objects import ConfigValues, E2EDataset, E2EItem +from e2e_tests.exceptions import DataAlreadyExists, E2EException +from e2e_tests.objects import ConfigValues, E2EAnnotationClass, E2EDataset, E2EItem def api_call( @@ -114,6 +114,89 @@ def create_dataset(prefix: str, config: ConfigValues) -> E2EDataset: pytest.exit("Test run failed in test setup stage") +def create_annotation_class( + name: str, + annotation_type: str, + config: ConfigValues, + fixed_name: bool = False, +) -> E2EAnnotationClass: + """ + Create a randomised new annotation class, and return its minimal info for reference + + Parameters + ---------- + name : str + The name of the annotation class + annotation_type : str + The type of the annotation class + + Returns + ------- + E2EAnnotationClass + The minimal info about the created annotation class + """ + team_slug = config.team_slug + + if not fixed_name: + name = f"{name}_{generate_random_string(4)}_annotation_class" + host, api_key = config.server, config.api_key + url = f"{host}/api/teams/{team_slug}/annotation_classes" + response = api_call( + "post", + url, + { + "name": name, + "annotation_types": [annotation_type], + "metadata": {"_color": "auto"}, + }, + api_key, + ) + + if response.ok: + annotation_class_info = response.json() + return E2EAnnotationClass( + id=annotation_class_info["id"], + name=annotation_class_info["name"], + type=annotation_class_info["annotation_types"][0], + ) + if response.status_code == 422 and "already exists" in response.text: + raise DataAlreadyExists( + f"Failed to create annotation class {name} - {response.status_code} - {response.text}" + ) + raise E2EException( + f"Failed to create annotation class {name} - {response.status_code} - {response.text}" + ) + + +def delete_annotation_class(id: str, config: ConfigValues) -> None: + """ + Delete an annotation class on the server + + Parameters + ---------- + id : str + The id of the annotation class to delete + config : ConfigValues + The config values to use + """ + host, api_key = config.server, config.api_key + url = f"{host}/api/annotation_classes/{id}" + try: + response = api_call( + "delete", + url, + None, + api_key, + ) + if not response.ok: + raise E2EException( + f"Failed to delete annotation class {id} - {response.status_code} - {response.text}" + ) + except Exception as e: + print(f"Failed to delete annotation class {id} - {e}") + pytest.exit("Test run failed in test setup stage") + + def create_item( dataset_slug: str, prefix: str, image: Path, config: ConfigValues ) -> E2EItem: @@ -227,7 +310,7 @@ def create_random_image( return directory / image_name -def setup_tests(config: ConfigValues) -> List[E2EDataset]: +def setup_datasets(config: ConfigValues) -> List[E2EDataset]: """ Setup data for End to end test runs @@ -275,6 +358,48 @@ def setup_tests(config: ConfigValues) -> List[E2EDataset]: return datasets +def setup_annotation_classes(config: ConfigValues) -> List[E2EAnnotationClass]: + """ + Setup data for End to end test runs + + Parameters + ---------- + config : ConfigValues + The config values to use + + Returns + ------- + List[E2EAnnotationClass] + The minimal info about the created annotation classes + """ + + annotation_classes: List[E2EAnnotationClass] = [] + + print("Setting up annotation classes") + set_types = [("bb", "bounding_box"), ("poly", "polygon"), ("ellipse", "ellipse")] + try: + for annotation_type, annotation_type_name in set_types: + try: + annotation_class = create_annotation_class( + f"test_{annotation_type}", + annotation_type_name, + config, + fixed_name=True, + ) + annotation_classes.append(annotation_class) + except DataAlreadyExists: + pass + except E2EException as e: + print(e) + pytest.exit("Test run failed in test setup stage") + + except Exception as e: + print(e) + pytest.exit("Setup failed - unknown error") + + return annotation_classes + + def teardown_tests(config: ConfigValues, datasets: List[E2EDataset]) -> None: """ Teardown data for End to end test runs @@ -286,21 +411,41 @@ def teardown_tests(config: ConfigValues, datasets: List[E2EDataset]) -> None: datasets : List[E2EDataset] The minimal info about the created datasets """ - host, api_key, team_slug = config.server, config.api_key, config.team_slug - + failures = [] print("\nTearing down datasets") + failures.extend(delete_known_datasets(config, datasets)) - failures = [] - for dataset in datasets: - url = f"{host}/api/datasets/{dataset.id}/archive" - response = api_call("put", url, {}, api_key) + print("Tearing down workflows") + failures.extend(delete_workflows(config)) - if not response.ok: - failures.append( - f"Failed to delete dataset {dataset.name} - {response.status_code} - {response.text}" - ) + print("Tearing down general datasets") + failures.extend(delete_general_datasets(config)) + + if failures: + for item in failures: + print(item) + pytest.exit("Test run failed in test teardown stage") + + if failures: + for item in failures: + print(item) + pytest.exit("Test run failed in test teardown stage") + + print("Tearing down data complete") + + +def delete_workflows(config: ConfigValues) -> List: + """ + Delete all workflows for the team + + Parameters + ---------- + config : ConfigValues + The config values to use + """ + host, api_key, team_slug = config.server, config.api_key, config.team_slug - # Teardown workflows as they need to be disconnected before datasets can be deleted + failures = [] url = f"{host}/api/v2/teams/{team_slug}/workflows" response = api_call("get", url, {}, api_key) if response.ok: @@ -327,9 +472,37 @@ def teardown_tests(config: ConfigValues, datasets: List[E2EDataset]) -> None: failures.append( f"Failed to delete workflow {item['name']} - {response.status_code} - {response.text}" ) + return failures + + +def delete_known_datasets(config: ConfigValues, datasets: List[E2EDataset]) -> List: + """ + Delete all known datasets for the team + + Parameters + ---------- + config : ConfigValues + The config values to use + """ + host, api_key, _ = config.server, config.api_key, config.team_slug + + failures = [] + for dataset in datasets: + url = f"{host}/api/datasets/{dataset.id}/archive" + response = api_call("put", url, {}, api_key) + + if not response.ok: + failures.append( + f"Failed to delete dataset {dataset.name} - {response.status_code} - {response.text}" + ) + return failures + +def delete_general_datasets(config: ConfigValues) -> List: + host, api_key, _ = config.server, config.api_key, config.team_slug # teardown any other datasets of specific format url = f"{host}/api/datasets" + failures = [] response = api_call("get", url, {}, api_key) if response.ok: items = response.json() @@ -342,10 +515,20 @@ def teardown_tests(config: ConfigValues, datasets: List[E2EDataset]) -> None: failures.append( f"Failed to delete dataset {item['name']} - {response.status_code} - {response.text}" ) + return failures - if failures: - for item in failures: - print(item) - pytest.exit("Test run failed in test teardown stage") - print("Tearing down data complete") +def teardown_annotation_classes( + config: ConfigValues, annotation_classes: List[E2EAnnotationClass] +) -> None: + for annotation_class in annotation_classes: + delete_annotation_class(str(annotation_class.id), config) + team_slug = config.team_slug + host = config.server + response = api_call( + "get", f"{host}/api/teams/{team_slug}/annotation_classes", None, config.api_key + ) + all_annotations = response.json()["annotation_classes"] + for annotation_class in all_annotations: + if annotation_class["name"].startswith("test_"): + delete_annotation_class(annotation_class["id"], config)