diff --git a/.pylintrc b/.pylintrc index 5722c1d10..e9aabe068 100644 --- a/.pylintrc +++ b/.pylintrc @@ -72,6 +72,7 @@ disable=raw-checker-failed, logging-fstring-interpolation, cyclic-import, too-few-public-methods, + fixme, # Enable the message, report, category or checker with the given id(s). You can diff --git a/docs/sdk/issue.md b/docs/sdk/issue.md index f32fa2306..59d28c60d 100644 --- a/docs/sdk/issue.md +++ b/docs/sdk/issue.md @@ -1,7 +1,5 @@ # Issue module -## Queries +::: kili.presentation.client.issue.IssueClientMethods ::: kili.entrypoints.queries.issue.__init__.QueriesIssue - -## Mutations ::: kili.entrypoints.mutations.issue.__init__.MutationsIssue diff --git a/src/kili/client.py b/src/kili/client.py index c0c83fad0..5f3ec6681 100644 --- a/src/kili/client.py +++ b/src/kili/client.py @@ -37,7 +37,9 @@ from kili.entrypoints.queries.user import QueriesUser from kili.entrypoints.subscriptions.label import SubscriptionsLabel from kili.exceptions import AuthenticationFailed, UserNotFoundError -from kili.internal import KiliInternal +from kili.gateways.kili_api_gateway import KiliAPIGateway +from kili.presentation.client.internal import InternalClientMethods +from kili.presentation.client.issue import IssueClientMethods from kili.utils.logcontext import LogContext, log_call warnings.filterwarnings("default", module="kili", category=DeprecationWarning) @@ -77,6 +79,7 @@ class Kili( # pylint: disable=too-many-ancestors,too-many-instance-attributes QueriesProjectVersion, QueriesUser, SubscriptionsLabel, + IssueClientMethods, ): """Kili Client.""" @@ -170,11 +173,13 @@ def __init__( **(graphql_client_params or {}), # type: ignore ) + self.kili_api_gateway = KiliAPIGateway(self.graphql_client, self.http_client) + if not skip_checks: api_key_query = APIKeyQuery(self.graphql_client, self.http_client) self._check_expiry_of_key_is_close(api_key_query, self.api_key) - self.internal = KiliInternal(self) + self.internal = InternalClientMethods(self) @log_call def _is_api_key_valid(self) -> bool: diff --git a/src/kili/core/enums.py b/src/kili/core/enums.py index 8e0d8ff0b..eac48e312 100644 --- a/src/kili/core/enums.py +++ b/src/kili/core/enums.py @@ -39,19 +39,6 @@ "VIDEO_LEGACY", ] - -IssueStatus = Literal[ - "OPEN", - "SOLVED", -] - - -IssueType = Literal[ - "ISSUE", - "QUESTION", -] - - LabelFormat = Literal[ "RAW", "SIMPLE", diff --git a/src/kili/domain/__init__.py b/src/kili/domain/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/kili/domain/issue.py b/src/kili/domain/issue.py new file mode 100644 index 000000000..d1fbb0bf3 --- /dev/null +++ b/src/kili/domain/issue.py @@ -0,0 +1,13 @@ +"""Issue domain.""" +from dataclasses import dataclass +from typing import Literal + +IssueType = Literal["ISSUE", "QUESTION"] +IssueStatus = Literal["OPEN", "SOLVED"] + + +@dataclass +class Issue: + """Issue Entity.""" + + id_: str diff --git a/src/kili/entrypoints/mutations/issue/__init__.py b/src/kili/entrypoints/mutations/issue/__init__.py index 835ff49b7..b2529c2dd 100644 --- a/src/kili/entrypoints/mutations/issue/__init__.py +++ b/src/kili/entrypoints/mutations/issue/__init__.py @@ -1,6 +1,5 @@ """Issue mutations.""" -from itertools import repeat from typing import Dict, List, Literal, Optional from typeguard import typechecked @@ -10,11 +9,11 @@ from kili.core.helpers import deprecate from kili.entrypoints.base import BaseOperationEntrypointMixin from kili.entrypoints.mutations.asset.helpers import get_asset_ids_or_throw_error +from kili.gateways.kili_api_gateway.issue.operations import GQL_CREATE_ISSUES from kili.services.helpers import assert_all_arrays_have_same_size from kili.utils.logcontext import for_all_methods, log_call -from .helpers import get_issue_numbers, get_labels_asset_ids_map -from .queries import GQL_CREATE_ISSUES +from .helpers import get_issue_numbers @for_all_methods(log_call, exclude=["__init__"]) @@ -91,51 +90,6 @@ def append_to_issues( result = self.graphql_client.execute(GQL_CREATE_ISSUES, variables) return self.format_result("data", result)[0] - @typechecked - def create_issues( - self, - project_id: str, - label_id_array: List[str], - object_mid_array: Optional[List[Optional[str]]] = None, - text_array: Optional[List[Optional[str]]] = None, - ) -> List[Dict]: - """Create an issue. - - Args: - project_id: Id of the project. - label_id_array: List of Ids of the labels to add an issue to. - object_mid_array: List of mids of the objects in the labels to associate the issues to. - text_array: List of texts to associate to the issues. - - Returns: - A list of dictionary with the `id` key of the created issues. - """ - assert_all_arrays_have_same_size([label_id_array, object_mid_array, text_array]) - issue_number_array = get_issue_numbers(self, project_id, "ISSUE", len(label_id_array)) - label_asset_ids_map = get_labels_asset_ids_map(self, project_id, label_id_array) - variables = { - "issues": [ - { - "issueNumber": issue_number, - "labelID": label_id, - "objectMid": object_mid, - "type": "ISSUE", - "assetId": label_asset_ids_map[label_id], - "text": text, - } - for (issue_number, label_id, object_mid, text) in zip( - issue_number_array, - label_id_array, - object_mid_array or repeat(None), - text_array or repeat(None), - ) - ], - "where": {"idIn": list(label_asset_ids_map.values())}, - } - - result = self.graphql_client.execute(GQL_CREATE_ISSUES, variables) - return self.format_result("data", result) - @typechecked def create_questions( self, diff --git a/src/kili/entrypoints/mutations/issue/fragments.py b/src/kili/entrypoints/mutations/issue/fragments.py deleted file mode 100644 index be33e997b..000000000 --- a/src/kili/entrypoints/mutations/issue/fragments.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Fragments of issue mutations.""" - -ISSUE_FRAGMENT = """ -id -""" diff --git a/src/kili/entrypoints/mutations/issue/queries.py b/src/kili/entrypoints/mutations/issue/queries.py deleted file mode 100644 index 8381595fd..000000000 --- a/src/kili/entrypoints/mutations/issue/queries.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Queries of issue mutations.""" - -from .fragments import ISSUE_FRAGMENT - -GQL_APPEND_TO_ISSUES = f""" -mutation( - $data: AppendToIssuesData! - $where: AssetWhere! -) {{ - data: appendToIssues( - data: $data - where: $where - ) {{ - {ISSUE_FRAGMENT} - }} -}} -""" - -GQL_CREATE_ISSUES = f""" -mutation createIssues( - $issues: [IssueToCreate!]! - $where: AssetWhere! -) {{ - data: createIssues( - issues: $issues - where: $where - ) {{ - {ISSUE_FRAGMENT} - }} -}} -""" diff --git a/src/kili/gateways/__init__.py b/src/kili/gateways/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/kili/gateways/kili_api_gateway/__init__.py b/src/kili/gateways/kili_api_gateway/__init__.py new file mode 100644 index 000000000..cec59a0a7 --- /dev/null +++ b/src/kili/gateways/kili_api_gateway/__init__.py @@ -0,0 +1,13 @@ +"""Kili API Gateway module for interacting with Kili.""" +import requests + +from kili.core.graphql.graphql_client import GraphQLClient +from kili.gateways.kili_api_gateway.issue import IssueOperationMixin + + +class KiliAPIGateway(IssueOperationMixin): + """GraphQL gateway to communicate with Kili backend.""" + + def __init__(self, graphql_client: GraphQLClient, http_client: requests.Session): + self.graphql_client = graphql_client + self.http_client = http_client diff --git a/src/kili/gateways/kili_api_gateway/issue/__init__.py b/src/kili/gateways/kili_api_gateway/issue/__init__.py new file mode 100644 index 000000000..e6d82a7d8 --- /dev/null +++ b/src/kili/gateways/kili_api_gateway/issue/__init__.py @@ -0,0 +1,68 @@ +"""Mixin extending Kili API Gateway class with Issue related operations.""" + +from typing import List, Optional + +from kili.core.graphql.graphql_client import GraphQLClient +from kili.core.utils.pagination import BatchIteratorBuilder +from kili.domain.issue import Issue, IssueStatus, IssueType +from kili.gateways.kili_api_gateway.issue.operations import ( + GQL_COUNT_ISSUES, + GQL_CREATE_ISSUES, +) +from kili.gateways.kili_api_gateway.issue.types import ( + IssueToCreateKiliAPIGatewayInput, + IssueWhere, +) +from kili.utils import tqdm + + +class IssueOperationMixin: + """GraphQL Mixin extending GraphQL Gateway class with Issue related operations.""" + + graphql_client: GraphQLClient + + def create_issues( + self, type_: IssueType, issues: List[IssueToCreateKiliAPIGatewayInput] + ) -> List[Issue]: + """Send a GraphQL request calling createIssues resolver.""" + created_issue_entities: List[Issue] = [] + with tqdm.tqdm(total=len(issues)) as pbar: + for issues_batch in BatchIteratorBuilder(issues): + batch_targeted_asset_ids = [issue.asset_id for issue in issues_batch] + payload = { + "issues": [ + { + "issueNumber": 0, + "labelID": issue.label_id, + "objectMid": issue.object_mid, + "type": type_, + "assetId": issue.asset_id, + "text": issue.text, + } + for issue in issues_batch + ], + "where": {"idIn": batch_targeted_asset_ids}, + } + result = self.graphql_client.execute(GQL_CREATE_ISSUES, payload) + batch_created_issues = result["data"] + created_issue_entities.extend( + [Issue(id_=issue["id"]) for issue in batch_created_issues] + ) + pbar.update(len(issues_batch)) + return created_issue_entities + + def count_issues( # pylint: disable=too-many-arguments, + self, + project_id: str, + asset_id: Optional[str] = None, + asset_id_in: Optional[List[str]] = None, + issue_type: Optional[IssueType] = None, + status: Optional[IssueStatus] = None, + ) -> int: + """Send a GraphQL request calling countIssues resolver.""" + where = IssueWhere(project_id, asset_id, asset_id_in, issue_type, status) + payload = { + "where": where.get_graphql_where_value(), + } + count_result = self.graphql_client.execute(GQL_COUNT_ISSUES, payload) + return count_result["data"] diff --git a/src/kili/gateways/kili_api_gateway/issue/operations.py b/src/kili/gateways/kili_api_gateway/issue/operations.py new file mode 100644 index 000000000..1f14caad6 --- /dev/null +++ b/src/kili/gateways/kili_api_gateway/issue/operations.py @@ -0,0 +1,21 @@ +"""GraphQL Issues operations.""" + +GQL_CREATE_ISSUES = """ +mutation createIssues( + $issues: [IssueToCreate!]! + $where: AssetWhere! +) { + data: createIssues( + issues: $issues + where: $where + ) { + id + } +} +""" + +GQL_COUNT_ISSUES = """ +query countIssues($where: IssueWhere!) { + data: countIssues(where: $where) +} +""" diff --git a/src/kili/gateways/kili_api_gateway/issue/types.py b/src/kili/gateways/kili_api_gateway/issue/types.py new file mode 100644 index 000000000..fec001fe4 --- /dev/null +++ b/src/kili/gateways/kili_api_gateway/issue/types.py @@ -0,0 +1,36 @@ +"""Types for the Issue-related Kili API gateway functions.""" +from dataclasses import dataclass +from typing import List, Optional + +from kili.domain.issue import IssueStatus, IssueType + + +@dataclass +class IssueToCreateKiliAPIGatewayInput: + """Data about an issue to create needed in graphql createIssue resolver.""" + + label_id: Optional[str] + object_mid: Optional[str] + asset_id: str + text: Optional[str] + + +@dataclass +class IssueWhere: + """Tuple to be passed to the IssueQuery to restrict query.""" + + project_id: str + asset_id: Optional[str] = None + asset_id_in: Optional[List[str]] = None + issue_type: Optional[IssueType] = None + status: Optional[IssueStatus] = None + + def get_graphql_where_value(self): + """Build the GraphQL IssueWhere variable value to be sent in an operation.""" + return { + "project": {"id": self.project_id}, + "asset": {"id": self.asset_id}, + "assetIn": self.asset_id_in, + "status": self.status, + "type": self.issue_type, + } diff --git a/src/kili/presentation/__init__.py b/src/kili/presentation/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/kili/presentation/client/__init__.py b/src/kili/presentation/client/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/kili/internal/__init__.py b/src/kili/presentation/client/internal.py similarity index 89% rename from src/kili/internal/__init__.py rename to src/kili/presentation/client/internal.py index 68600a9c6..6c566df2a 100644 --- a/src/kili/internal/__init__.py +++ b/src/kili/presentation/client/internal.py @@ -1,4 +1,4 @@ -"""Module for methods and classes that are for internal use by Kili Technology only.""" +"""Module for methods that are for internal use by Kili Technology only.""" from typeguard import typechecked @@ -8,8 +8,8 @@ from kili.entrypoints.queries.api_key import QueriesApiKey -class KiliInternal(MutationsOrganization, QueriesApiKey): - """Inherit classes for internal use by Kili Technology only.""" +class InternalClientMethods(MutationsOrganization, QueriesApiKey): + """Kili client methods for internal use by Kili Technology only.""" def __init__(self, kili): """Initializes the class. diff --git a/src/kili/presentation/client/issue.py b/src/kili/presentation/client/issue.py new file mode 100644 index 000000000..d792a9d9b --- /dev/null +++ b/src/kili/presentation/client/issue.py @@ -0,0 +1,53 @@ +"""Client presentation methods for issues.""" + +from itertools import repeat +from typing import Dict, List, Literal, Optional + +import requests +from typeguard import typechecked + +from kili.gateways.kili_api_gateway import KiliAPIGateway +from kili.services.helpers import assert_all_arrays_have_same_size +from kili.use_cases.issue import IssueUseCases +from kili.use_cases.issue.types import IssueToCreateUseCaseInput +from kili.utils.logcontext import for_all_methods, log_call + + +@for_all_methods(log_call, exclude=["__init__"]) +class IssueClientMethods: + """Methods attached to the Kili client, to run actions on issues.""" + + kili_api_gateway: KiliAPIGateway + http_client: requests.Session + + @typechecked + def create_issues( + self, + project_id: str, + label_id_array: List[str], + object_mid_array: Optional[List[Optional[str]]] = None, + text_array: Optional[List[Optional[str]]] = None, + ) -> List[Dict[Literal["id"], str]]: + """Create an issue. + + Args: + project_id: Id of the project. + label_id_array: List of Ids of the labels to add an issue to. + object_mid_array: List of mids of the objects in the labels to associate the issues to. + text_array: List of texts to associate to the issues. + + Returns: + A list of dictionary with the `id` key of the created issues. + """ + assert_all_arrays_have_same_size([label_id_array, object_mid_array, text_array]) + issues = [ + IssueToCreateUseCaseInput(label_id=label_id, object_mid=object_mid, text=text) + for (label_id, object_mid, text) in zip( + label_id_array, + object_mid_array or repeat(None), + text_array or repeat(None), + ) + ] + issue_service = IssueUseCases(self.kili_api_gateway) + issues_entities = issue_service.create_issues(project_id=project_id, issues=issues) + return [{"id": issue.id_} for issue in issues_entities] diff --git a/src/kili/types.py b/src/kili/types.py index 0c351b764..00461a79d 100644 --- a/src/kili/types.py +++ b/src/kili/types.py @@ -6,7 +6,6 @@ from kili.core.enums import ( InputType, - IssueStatus, LabelType, LicenseType, LockType, @@ -17,6 +16,7 @@ Status, ) from kili.core.helpers import deprecate +from kili.domain.issue import IssueStatus ####### diff --git a/src/kili/use_cases/__init__.py b/src/kili/use_cases/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/kili/use_cases/issue/__init__.py b/src/kili/use_cases/issue/__init__.py new file mode 100644 index 000000000..663069034 --- /dev/null +++ b/src/kili/use_cases/issue/__init__.py @@ -0,0 +1,33 @@ +"""Issue use cases.""" + +from typing import List + +from kili.entrypoints.mutations.issue.helpers import get_labels_asset_ids_map +from kili.gateways.kili_api_gateway import KiliAPIGateway +from kili.gateways.kili_api_gateway.issue.types import IssueToCreateKiliAPIGatewayInput +from kili.use_cases.issue.types import IssueToCreateUseCaseInput + + +class IssueUseCases: + """Issue use cases.""" + + def __init__(self, kili_api_gateway: KiliAPIGateway): + self._kili_api_gateway = kili_api_gateway + + def create_issues(self, project_id, issues: List[IssueToCreateUseCaseInput]): + """Create issues with issue type.""" + label_id_array = [issue.label_id for issue in issues] + label_asset_ids_map = get_labels_asset_ids_map( + self._kili_api_gateway, project_id, label_id_array + ) # TODO: should be done in the backend + gateway_issues = [ + IssueToCreateKiliAPIGatewayInput( + label_id=issue.label_id, + object_mid=issue.object_mid, + asset_id=label_asset_ids_map[issue.label_id], + text=issue.text, + ) + for issue in issues + ] + created_issues = self._kili_api_gateway.create_issues(type_="ISSUE", issues=gateway_issues) + return created_issues diff --git a/src/kili/use_cases/issue/types.py b/src/kili/use_cases/issue/types.py new file mode 100644 index 000000000..1f440fa32 --- /dev/null +++ b/src/kili/use_cases/issue/types.py @@ -0,0 +1,12 @@ +"""Types for Issue-related use cases.""" +from dataclasses import dataclass +from typing import Optional + + +@dataclass +class IssueToCreateUseCaseInput: + """Data about one Issue to create.""" + + label_id: str + object_mid: Optional[str] = None + text: Optional[str] = None diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 000000000..12ffdfa7a --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,16 @@ +"""Common fixtures for tests.""" + +import pytest +import requests +from pytest_mock import MockerFixture + +from kili.core.graphql.graphql_client import GraphQLClient +from kili.gateways.kili_api_gateway import KiliAPIGateway + + +@pytest.fixture() +def kili_api_gateway(mocker: MockerFixture): + mock = mocker.MagicMock(spec=KiliAPIGateway) + mock.graphql_client = mocker.MagicMock(spec=GraphQLClient) + mock.http_client = mocker.MagicMock(spec=requests.Session) + return mock diff --git a/tests/integration/use_cases/test_issue.py b/tests/integration/use_cases/test_issue.py new file mode 100644 index 000000000..6789cd95d --- /dev/null +++ b/tests/integration/use_cases/test_issue.py @@ -0,0 +1,24 @@ +"""Tests for issues service.""" + +import pytest + +from kili.domain.issue import Issue +from kili.gateways.kili_api_gateway import KiliAPIGateway +from kili.use_cases.issue import IssueUseCases +from kili.use_cases.issue.types import IssueToCreateUseCaseInput + + +@pytest.mark.skip(reason="Waiting to implement queries") +def test_create_one_issue(kili_api_gateway: KiliAPIGateway): + issue_use_cases = IssueUseCases(kili_api_gateway) + + # given one issue to create + issues = [IssueToCreateUseCaseInput(label_id="label_id", text="text", object_mid="object_mid")] + issue_entities = [Issue(id_="issue_id")] + kili_api_gateway.create_issues.return_value(issue_entities) + + # when creating one issue + issues = issue_use_cases.create_issues(project_id="project_id", issues=issues) + + # then + assert issues == issue_entities