Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: refacto repo archi #1392

Merged
Show file tree
Hide file tree
Changes from 15 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
4 changes: 1 addition & 3 deletions docs/sdk/issue.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
# Issue module

## Queries
::: kili.entrypoints.client.issue.IssueEntrypoints
::: kili.entrypoints.queries.issue.__init__.QueriesIssue

## Mutations
::: kili.entrypoints.mutations.issue.__init__.MutationsIssue
236 changes: 6 additions & 230 deletions src/kili/client.py
Original file line number Diff line number Diff line change
@@ -1,233 +1,9 @@
"""This script permits to initialize the Kili Python SDK client."""
import getpass
import logging
import os
import sys
import warnings
from datetime import datetime, timedelta
from typing import Callable, Dict, Optional, Union
"""Module with the Kili client.

import requests
The class definition is entrypoint/client/__init__.py
This is a shortcut module for the final user import
"""

from kili import __version__
from kili.core.graphql import QueryOptions
from kili.core.graphql.graphql_client import GraphQLClient, GraphQLClientName
from kili.core.graphql.operations.api_key.queries import APIKeyQuery, APIKeyWhere
from kili.core.graphql.operations.user.queries import GQL_ME
from kili.entrypoints.mutations.asset import MutationsAsset
from kili.entrypoints.mutations.data_connection import MutationsDataConnection
from kili.entrypoints.mutations.issue import MutationsIssue
from kili.entrypoints.mutations.label import MutationsLabel
from kili.entrypoints.mutations.notification import MutationsNotification
from kili.entrypoints.mutations.plugins import MutationsPlugins
from kili.entrypoints.mutations.project import MutationsProject
from kili.entrypoints.mutations.project_version import MutationsProjectVersion
from kili.entrypoints.mutations.user import MutationsUser
from kili.entrypoints.queries.asset import QueriesAsset
from kili.entrypoints.queries.data_connection import QueriesDataConnection
from kili.entrypoints.queries.data_integration import QueriesDataIntegration
from kili.entrypoints.queries.issue import QueriesIssue
from kili.entrypoints.queries.label import QueriesLabel
from kili.entrypoints.queries.notification import QueriesNotification
from kili.entrypoints.queries.organization import QueriesOrganization
from kili.entrypoints.queries.plugins import QueriesPlugins
from kili.entrypoints.queries.project import QueriesProject
from kili.entrypoints.queries.project_user import QueriesProjectUser
from kili.entrypoints.queries.project_version import QueriesProjectVersion
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.entrypoints.client import Kili

warnings.filterwarnings("default", module="kili", category=DeprecationWarning)


class FilterPoolFullWarning(logging.Filter):
"""Filter out the specific urllib3 warning related to the connection pool."""

def filter(self, record) -> bool:
"""urllib3.connectionpool:Connection pool is full, discarding connection: ..."""
return "Connection pool is full, discarding connection" not in record.getMessage()


logging.getLogger("urllib3.connectionpool").addFilter(FilterPoolFullWarning())


class Kili( # pylint: disable=too-many-ancestors,too-many-instance-attributes
MutationsAsset,
MutationsDataConnection,
MutationsIssue,
MutationsLabel,
MutationsNotification,
MutationsPlugins,
MutationsProject,
MutationsProjectVersion,
MutationsUser,
QueriesAsset,
QueriesDataConnection,
QueriesDataIntegration,
QueriesIssue,
QueriesLabel,
QueriesNotification,
QueriesOrganization,
QueriesPlugins,
QueriesProject,
QueriesProjectUser,
QueriesProjectVersion,
QueriesUser,
SubscriptionsLabel,
):
"""Kili Client."""

# pylint: disable=too-many-arguments
def __init__(
self,
api_key: Optional[str] = None,
api_endpoint: Optional[str] = None,
verify: Union[bool, str] = True,
client_name: GraphQLClientName = GraphQLClientName.SDK,
graphql_client_params: Optional[Dict[str, object]] = None,
) -> None:
"""Initialize Kili client.

Args:
api_key: User API key generated
from https://cloud.kili-technology.com/label/my-account/api-key.
Default to `KILI_API_KEY` environment variable.
If not passed, requires the `KILI_API_KEY` environment variable to be set.
api_endpoint: Recipient of the HTTP operation.
Default to `KILI_API_ENDPOINT` environment variable.
If not passed, default to Kili SaaS:
'https://cloud.kili-technology.com/api/label/v2/graphql'
verify: similar to `requests`' verify.
Either a boolean, in which case it controls whether we verify
the server's TLS certificate, or a string, in which case it must be a path
to a CA bundle to use. Defaults to ``True``. When set to
``False``, requests will accept any TLS certificate presented by
the server, and will ignore hostname mismatches and/or expired
certificates, which will make your application vulnerable to
man-in-the-middle (MitM) attacks. Setting verify to ``False``
may be useful during local development or testing.
client_name: For internal use only.
Define the name of the graphQL client whith which graphQL calls will be sent.
graphql_client_params: Parameters to pass to the graphQL client.

Returns:
Instance of the Kili client.

Examples:
```python
from kili.client import Kili

kili = Kili()

kili.assets() # list your assets
kili.labels() # list your labels
kili.projects() # list your projects
```
"""
api_key = api_key or os.getenv("KILI_API_KEY")

if not api_key and sys.stdin.isatty():
api_key = getpass.getpass(
"No `KILI_API_KEY` environment variable found.\nPlease enter your API key: "
)

if api_endpoint is None:
api_endpoint = os.getenv(
"KILI_API_ENDPOINT",
"https://cloud.kili-technology.com/api/label/v2/graphql",
)

if not api_key:
raise AuthenticationFailed(api_key, api_endpoint)

self.api_key = api_key
self.api_endpoint = api_endpoint
self.verify = verify
self.client_name = client_name
self.graphql_client_params = graphql_client_params

skip_checks = os.getenv("KILI_SDK_SKIP_CHECKS") is not None

self.http_client = requests.Session()
self.http_client.verify = verify

if not skip_checks and not self._check_api_key_valid():
raise AuthenticationFailed(
api_key=self.api_key,
api_endpoint=self.api_endpoint,
error_msg="Api key does not seem to be valid.",
)

self.graphql_client = GraphQLClient(
endpoint=api_endpoint,
api_key=api_key,
client_name=client_name,
verify=self.verify,
http_client=self.http_client,
**(graphql_client_params or {}), # type: ignore
)

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)

def _check_api_key_valid(self) -> bool:
"""Check that the api_key provided is valid."""
response = self.http_client.post(
url=self.api_endpoint,
data='{"query":"{ me { id email } }"}',
timeout=30,
headers={
"Authorization": f"X-API-Key: {self.api_key}",
"Accept": "application/json",
"Content-Type": "application/json",
"apollographql-client-name": self.client_name.value,
"apollographql-client-version": __version__,
},
)
return response.status_code == 200 and "email" in response.text and "id" in response.text

def _get_kili_app_version(self) -> Optional[str]:
"""Get the version of the Kili app server.

Returns None if the version cannot be retrieved.
"""
url = self.api_endpoint.replace("/graphql", "/version")
response = self.http_client.get(url, timeout=30)
if response.status_code == 200 and '"version":' in response.text:
response_json = response.json()
version = response_json["version"]
return version
return None

@staticmethod
def _check_expiry_of_key_is_close(api_key_query: Callable, api_key: str) -> None:
"""Check that the expiration date of the api_key is not too close."""
warn_days = 30

api_keys = api_key_query(
fields=["expiryDate"],
where=APIKeyWhere(api_key=api_key),
options=QueryOptions(disable_tqdm=True),
)

key_expiry = datetime.strptime(next(api_keys)["expiryDate"], r"%Y-%m-%dT%H:%M:%S.%fZ")
key_remaining_time = key_expiry - datetime.now()
key_soon_deprecated = key_remaining_time < timedelta(days=warn_days)
if key_soon_deprecated:
message = f"""
Your api key will be deprecated on {key_expiry:%Y-%m-%d}.
You should generate a new one on My account > API KEY."""
warnings.warn(message, UserWarning, stacklevel=2)

def get_user(self) -> Dict:
"""Get the current user from the api_key provided."""
result = self.graphql_client.execute(GQL_ME)
user = self.format_result("data", result)
if user is None or user["id"] is None or user["email"] is None:
raise UserNotFoundError("No user attached to the API key was found")
return user
__all__ = ["Kili"]
10 changes: 10 additions & 0 deletions src/kili/core/graphql/gateway/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
"""GraphQL gateway module."""
from kili.core.graphql.gateway.issue import IssueOperationMixin


class GraphQLGateway(IssueOperationMixin):
"""GraphQL gateway to communicate with Kili backend."""

def __init__(self, graphql_client, http_client):
self.graphql_client = graphql_client
self.http_client = http_client
85 changes: 85 additions & 0 deletions src/kili/core/graphql/gateway/issue/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
"""GraphQL Mixin extending GraphQL Gateway class with Issue related operations."""

from dataclasses import dataclass
from typing import List, Optional

from kili.core.enums import IssueStatus
from kili.core.graphql.gateway.issue.operations import (
GQL_COUNT_ISSUES,
GQL_CREATE_ISSUES,
)
from kili.core.graphql.gateway.issue.types import IssueToCreateGQLGatewayInput
from kili.core.graphql.graphql_client import GraphQLClient
from kili.core.utils.pagination import BatchIteratorBuilder
from kili.domain.issues import Issue, IssueType


@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_input(self):
"""Build the GraphQL IssueWhere payload 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,
}


class IssueOperationMixin:
"""GraphQL Mixin extending GraphQL Gateway class with Issue related operations."""

graphql_client: GraphQLClient

def create_issues(
self, type_: IssueType, issues: List[IssueToCreateGQLGatewayInput]
) -> List[Issue]:
"""Send a GraphQL request calling createIssues resolver."""
created_issue_entities: List[Issue] = []
for issues_batch in BatchIteratorBuilder(issues):
batch_targeted_asset_ids = [issue.asset_id for issue in issues_batch]
payload = {
"issues": [
{
"issueNumber": issue.issue_number,
"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]
)
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,
):
"""Send a GraphQL request calling countIssues resolver."""
where = IssueWhere(project_id, asset_id, asset_id_in, issue_type, status)
payload = {
"where": where.get_graphql_input(),
}
count_result = self.graphql_client.execute(GQL_COUNT_ISSUES, payload)
return count_result["data"]
33 changes: 33 additions & 0 deletions src/kili/core/graphql/gateway/issue/operations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"""GraphQL Issues operations."""

GQL_CREATE_ISSUES = """
mutation createIssues(
$issues: [IssueToCreate!]!
$where: AssetWhere!
) {
data: createIssues(
issues: $issues
where: $where
) {
id
}
}
"""


def get_gql_issues_query(fragment):
"""Return the GraphQL issues query."""
return f"""
query issues($where: IssueWhere!, $first: PageSize!, $skip: Int!) {{
data: issues(where: $where, first: $first, skip: $skip) {{
{fragment}
}}
}}
"""


GQL_COUNT_ISSUES = """
query countIssues($where: IssueWhere!) {
data: countIssues(where: $where)
}
"""
14 changes: 14 additions & 0 deletions src/kili/core/graphql/gateway/issue/types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
"""Types for the Issue-related graphql gateway functions."""
from dataclasses import dataclass
from typing import Optional


@dataclass
class IssueToCreateGQLGatewayInput:
"""Data about an issue to create needed in graphql createIssue resolver."""

issue_number: int
label_id: Optional[str]
object_mid: Optional[str]
asset_id: str
text: Optional[str]
Empty file added src/kili/domain/__init__.py
Empty file.
Loading