konecty_sdk_python 2.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. KonectySdkPython/__init__.py +41 -0
  2. KonectySdkPython/cli/__init__.py +38 -0
  3. KonectySdkPython/cli/apply.py +555 -0
  4. KonectySdkPython/cli/backup.py +161 -0
  5. KonectySdkPython/cli/pull.py +309 -0
  6. KonectySdkPython/lib/__init__.py +43 -0
  7. KonectySdkPython/lib/client.py +950 -0
  8. KonectySdkPython/lib/exceptions.py +26 -0
  9. KonectySdkPython/lib/feature_types/__init__.py +46 -0
  10. KonectySdkPython/lib/feature_types/cross_module_query.py +78 -0
  11. KonectySdkPython/lib/feature_types/kpi.py +21 -0
  12. KonectySdkPython/lib/feature_types/query_json.py +242 -0
  13. KonectySdkPython/lib/file_manager.py +248 -0
  14. KonectySdkPython/lib/filters.py +155 -0
  15. KonectySdkPython/lib/http.py +95 -0
  16. KonectySdkPython/lib/model.py +88 -0
  17. KonectySdkPython/lib/serialization.py +13 -0
  18. KonectySdkPython/lib/services/__init__.py +5 -0
  19. KonectySdkPython/lib/services/aggregation.py +142 -0
  20. KonectySdkPython/lib/services/auth.py +105 -0
  21. KonectySdkPython/lib/services/base.py +63 -0
  22. KonectySdkPython/lib/services/change_user.py +64 -0
  23. KonectySdkPython/lib/services/comments.py +89 -0
  24. KonectySdkPython/lib/services/export.py +54 -0
  25. KonectySdkPython/lib/services/files.py +59 -0
  26. KonectySdkPython/lib/services/notifications.py +41 -0
  27. KonectySdkPython/lib/services/query.py +192 -0
  28. KonectySdkPython/lib/services/stream.py +133 -0
  29. KonectySdkPython/lib/services/subscriptions.py +26 -0
  30. KonectySdkPython/lib/settings.py +152 -0
  31. KonectySdkPython/lib/types.py +418 -0
  32. konecty_sdk_python-2.1.1.dist-info/METADATA +155 -0
  33. konecty_sdk_python-2.1.1.dist-info/RECORD +35 -0
  34. konecty_sdk_python-2.1.1.dist-info/WHEEL +4 -0
  35. konecty_sdk_python-2.1.1.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,155 @@
1
+ from datetime import datetime
2
+ from enum import Enum
3
+ from typing import Any, Dict, List, Optional, Union
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+
8
+ class FilterMatch(str, Enum):
9
+ """Tipo de correspondência do filtro."""
10
+
11
+ AND = "and"
12
+ OR = "or"
13
+
14
+
15
+ class FilterOperator(str, Enum):
16
+ """Operadores disponíveis para filtros."""
17
+
18
+ EQUALS = "equals"
19
+ NOT_EQUALS = "not_equals"
20
+ STARTS_WITH = "starts_with"
21
+ END_WITH = "end_with"
22
+ CONTAINS = "contains"
23
+ NOT_CONTAINS = "not_contains"
24
+ LESS_THAN = "less_than"
25
+ GREATER_THAN = "greater_than"
26
+ LESS_OR_EQUALS = "less_or_equals"
27
+ GREATER_OR_EQUALS = "greater_or_equals"
28
+ BETWEEN = "between"
29
+ IN = "in"
30
+ NOT_IN = "not_in"
31
+ EXISTS = "exists"
32
+
33
+
34
+ class DateValue(BaseModel):
35
+ """Valor de data para filtros."""
36
+
37
+ date: datetime
38
+
39
+
40
+ class BetweenValue(BaseModel):
41
+ """Valor para filtros do tipo between."""
42
+
43
+ greater_or_equals: Union[int, float, DateValue, datetime, None]
44
+ less_or_equals: Union[int, float, DateValue, datetime, None]
45
+
46
+
47
+ class FilterCondition(BaseModel):
48
+ """Condição de filtro."""
49
+
50
+ term: str = Field(..., description="Campo a ser filtrado")
51
+ operator: FilterOperator = Field(..., description="Operador de comparação")
52
+ value: Any = Field(..., description="Valor para comparação")
53
+ disabled: bool = Field(False, description="Se a condição está desativada")
54
+
55
+
56
+ class KonectyFilter(BaseModel):
57
+ """Filtro Konecty."""
58
+
59
+ match: FilterMatch = Field(FilterMatch.AND, description="Tipo de correspondência")
60
+ conditions: List[FilterCondition] = Field(default_factory=list, description="Lista de condições")
61
+ filters: List["KonectyFilter"] = Field(default_factory=list, description="Lista de filtros aninhados")
62
+
63
+ def to_json(self) -> Dict[str, Any]:
64
+ """Converte o filtro para formato JSON."""
65
+ return self.model_dump(mode="json")
66
+
67
+ def is_empty(self) -> bool:
68
+ """Verifica se o filtro está vazio."""
69
+ return len(self.conditions) == 0 and len(self.filters) == 0
70
+
71
+ @classmethod
72
+ def from_dict(cls, data: Dict[str, Any]) -> "KonectyFilter":
73
+ """Converte dicionário para filtro."""
74
+ return cls(**data)
75
+
76
+ @classmethod
77
+ def create(cls, match: Union[FilterMatch, str] = FilterMatch.AND) -> "KonectyFilter":
78
+ """Cria uma nova instância de filtro.
79
+
80
+ Args:
81
+ match: Tipo de correspondência ("and" ou "or")
82
+
83
+ Returns:
84
+ Nova instância de KonectyFilter
85
+ """
86
+ if isinstance(match, str):
87
+ match = FilterMatch(match.lower())
88
+ return cls(match=match)
89
+
90
+ def add_condition(
91
+ self, term: str, operator: Union[FilterOperator, str], value: Any, disabled: bool = False
92
+ ) -> "KonectyFilter":
93
+ """Adiciona uma condição ao filtro.
94
+
95
+ Args:
96
+ term: Campo a ser filtrado
97
+ operator: Operador de comparação
98
+ value: Valor para comparação
99
+ disabled: Se a condição está desativada
100
+
101
+ Returns:
102
+ Self para encadeamento
103
+ """
104
+ if isinstance(operator, str):
105
+ operator = FilterOperator(operator.lower())
106
+
107
+ self.conditions.append(
108
+ FilterCondition(
109
+ term=term,
110
+ operator=operator,
111
+ value=value,
112
+ disabled=disabled,
113
+ )
114
+ )
115
+ return self
116
+
117
+ def add_filter(self, match: Union[FilterMatch, str] = FilterMatch.AND) -> "KonectyFilter":
118
+ """Adiciona um filtro aninhado.
119
+
120
+ Args:
121
+ match: Tipo de correspondência do filtro aninhado
122
+
123
+ Returns:
124
+ Novo filtro aninhado
125
+ """
126
+ if isinstance(match, str):
127
+ match = FilterMatch(match.lower())
128
+
129
+ nested_filter = KonectyFilter(match=match)
130
+ self.filters.append(nested_filter)
131
+ return nested_filter
132
+
133
+
134
+ class SortDirection(str, Enum):
135
+ """Direção da ordenação."""
136
+
137
+ ASC = "ASC"
138
+ DESC = "DESC"
139
+
140
+
141
+ class SortOrder(BaseModel):
142
+ """Ordenação de resultados."""
143
+
144
+ property: str = Field(..., description="Campo para ordenação")
145
+ direction: SortDirection = Field(..., description="Direção da ordenação")
146
+
147
+
148
+ class KonectyFindParams(BaseModel):
149
+ """Parâmetros para busca no Konecty."""
150
+
151
+ filter: KonectyFilter
152
+ start: Optional[int] = None
153
+ limit: Optional[int] = None
154
+ sort: Optional[List[SortOrder]] = None
155
+ fields: Optional[List[Union[str, int]]] = None
@@ -0,0 +1,95 @@
1
+ """HTTP helpers for Konecty API requests. Used by KonectyClient and services."""
2
+
3
+ import logging
4
+ from typing import Any, Dict, Optional, Union
5
+
6
+ import aiohttp
7
+
8
+ from .exceptions import KonectyAPIError
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class StreamResponse:
14
+ """
15
+ Wrapper for streaming HTTP response. Caller can read headers and iterate body by line.
16
+ Must be used as async context manager so the session and response are closed on exit.
17
+ """
18
+
19
+ __slots__ = ("_session", "_response")
20
+
21
+ def __init__(self, session: aiohttp.ClientSession, response: aiohttp.ClientResponse) -> None:
22
+ self._session = session
23
+ self._response = response
24
+
25
+ @property
26
+ def headers(self) -> Dict[str, str]:
27
+ return dict(self._response.headers)
28
+
29
+ async def readline(self) -> bytes:
30
+ """Read one line (including newline). Returns empty bytes when done."""
31
+ return await self._response.content.readline()
32
+
33
+ async def __aenter__(self) -> "StreamResponse":
34
+ return self
35
+
36
+ async def __aexit__(self, *args: Any) -> None:
37
+ await self._response.release()
38
+ await self._session.close()
39
+
40
+
41
+ async def request(
42
+ client: Any,
43
+ method: str,
44
+ path: str,
45
+ *,
46
+ params: Optional[Dict[str, Any]] = None,
47
+ json: Optional[Dict[str, Any]] = None,
48
+ stream: bool = False,
49
+ return_bytes: bool = False,
50
+ ) -> Union[Dict[str, Any], StreamResponse, bytes]:
51
+ """
52
+ Perform an HTTP request using the client's base_url and headers.
53
+
54
+ Args:
55
+ client: KonectyClient (must have base_url and headers).
56
+ method: HTTP method (GET, POST, PUT, DELETE, etc.).
57
+ path: Path (e.g. /rest/data/Contact/find). Must not include base_url.
58
+ params: Optional query parameters.
59
+ json: Optional JSON body.
60
+ stream: If True, return StreamResponse for reading body by line; do not parse JSON.
61
+ return_bytes: If True (and not stream), return response body as bytes; do not parse JSON.
62
+
63
+ Returns:
64
+ If stream=True: StreamResponse (use as async with). Has .headers and .readline().
65
+ If return_bytes=True: bytes.
66
+ Else: parsed JSON dict. Raises KonectyAPIError if success is false.
67
+ """
68
+ url = f"{client.base_url.rstrip('/')}{path}"
69
+ kwargs: Dict[str, Any] = {"headers": client.headers}
70
+ if params is not None:
71
+ kwargs["params"] = params
72
+ if json is not None:
73
+ kwargs["json"] = json
74
+
75
+ if stream:
76
+ session = aiohttp.ClientSession()
77
+ response = await session.request(method, url, **kwargs)
78
+ response.raise_for_status()
79
+ return StreamResponse(session, response)
80
+
81
+ async with aiohttp.ClientSession() as session:
82
+ async with session.request(method, url, **kwargs) as response:
83
+ if return_bytes:
84
+ response.raise_for_status()
85
+ return await response.read()
86
+ body = await response.json()
87
+ if response.status >= 400:
88
+ errors = body.get("errors", []) if isinstance(body, dict) else []
89
+ logger.error("API error %s: %s", response.status, errors)
90
+ raise KonectyAPIError(errors)
91
+ if isinstance(body, dict) and body.get("success") is False:
92
+ errors = body.get("errors", [])
93
+ logger.error(errors)
94
+ raise KonectyAPIError(errors)
95
+ return body
@@ -0,0 +1,88 @@
1
+ from typing import Any, Dict, List, Optional, Type, TypeVar, cast
2
+
3
+ from pydantic import BaseModel, Field, create_model
4
+ from pydantic.fields import FieldInfo
5
+
6
+ from .types import (
7
+ Address,
8
+ KonectyDateTime,
9
+ KonectyEmail,
10
+ KonectyLookup,
11
+ KonectyPersonName,
12
+ KonectyPhone,
13
+ )
14
+
15
+ T = TypeVar("T")
16
+ ModelType = TypeVar("ModelType", bound=BaseModel)
17
+
18
+
19
+ class KonectyModelGenerator:
20
+ def __init__(self, schema: Dict[str, Any]):
21
+ self.schema = schema
22
+ self.type_mapping: Dict[str, Type[Any]] = {
23
+ "json": dict,
24
+ "richText": str,
25
+ "lookup": KonectyLookup,
26
+ "picklist": str,
27
+ "url": str,
28
+ "boolean": bool,
29
+ "text": str,
30
+ "dateTime": KonectyDateTime,
31
+ "address": Address,
32
+ "email": KonectyEmail,
33
+ "phone": KonectyPhone,
34
+ "personName": KonectyPersonName,
35
+ }
36
+
37
+ def generate_model(self, language: str = "en") -> Type[BaseModel]:
38
+ fields: Dict[str, tuple[Type[Any], FieldInfo]] = {}
39
+ for field in self.schema["fields"]:
40
+ field_name = field["name"]
41
+ field_raw_type = field["type"]
42
+ description = (
43
+ field["help"].get(language, field["help"].get("en", ""))
44
+ if "help" in field
45
+ else field["label"].get(language, field["label"].get("en", ""))
46
+ )
47
+
48
+ base_type = self._get_field_type(field_raw_type)
49
+ current_type = base_type
50
+
51
+ if field.get("isList"):
52
+ current_type = List[base_type] # type: ignore
53
+
54
+ if field.get("minSelected", 1) > 1 or field.get("maxSelected", 1) > 1:
55
+ current_type = List[base_type] # type: ignore
56
+
57
+ is_required = (
58
+ field.get("isRequired", False) or field.get("minSelected", 0) > 0
59
+ )
60
+
61
+ if not is_required and "defaultValue" not in field:
62
+ current_type = Optional[base_type] # type: ignore
63
+
64
+ field_params = {
65
+ "alias": field_name,
66
+ "description": description,
67
+ }
68
+
69
+ if "defaultValue" in field:
70
+ field_params["default"] = field["defaultValue"]
71
+
72
+ if not is_required and "defaultValue" not in field:
73
+ field_params["default"] = None
74
+
75
+ fields[self._clean_name_for_pydantic(field_name)] = (
76
+ current_type,
77
+ Field(**field_params),
78
+ )
79
+
80
+ model_name = self.schema["name"]
81
+ return create_model(model_name, **fields)
82
+
83
+ def _clean_name_for_pydantic(self, name: str) -> str:
84
+ return name.replace("_", "")
85
+
86
+ def _get_field_type(self, field_type: str) -> Type[Any]:
87
+ result = self.type_mapping.get(field_type, Any)
88
+ return cast(Type[Any], result)
@@ -0,0 +1,13 @@
1
+ """JSON serialization helpers for Konecty API (e.g. $date format)."""
2
+
3
+ from datetime import datetime
4
+ from typing import Any
5
+
6
+ from .exceptions import KonectySerializationError
7
+
8
+
9
+ def json_serial(obj: Any) -> Any:
10
+ """Serialize objects to JSON-friendly form (e.g. datetime -> $date)."""
11
+ if isinstance(obj, datetime):
12
+ return {"$date": obj.isoformat()}
13
+ raise KonectySerializationError()
@@ -0,0 +1,5 @@
1
+ """Services for Konecty API domains. Used by KonectyClient."""
2
+
3
+ from .base import BaseService
4
+
5
+ __all__ = ["BaseService"]
@@ -0,0 +1,142 @@
1
+ """Aggregation API: KPI, Graph, Pivot."""
2
+
3
+ import json
4
+ from typing import Any, Dict, Optional, Union
5
+
6
+ from ..serialization import json_serial
7
+ from ..filters import KonectyFilter
8
+ from ..feature_types.kpi import KpiConfig
9
+ from .base import BaseService
10
+
11
+
12
+ class AggregationService(BaseService):
13
+ """Service for KPI, Graph, and Pivot aggregations."""
14
+
15
+ async def get_kpi(
16
+ self,
17
+ module: str,
18
+ kpi_config: KpiConfig,
19
+ *,
20
+ filter_params: Optional[KonectyFilter] = None,
21
+ display_name: Optional[str] = None,
22
+ display_type: Optional[str] = None,
23
+ sort: Optional[Any] = None,
24
+ limit: Optional[int] = None,
25
+ start: Optional[int] = None,
26
+ with_detail_fields: Optional[bool] = None,
27
+ ) -> Dict[str, Any]:
28
+ """
29
+ Run KPI aggregation. GET /rest/data/{module}/kpi. Returns { value, count }.
30
+ """
31
+ params: Dict[str, str] = {
32
+ "kpiConfig": json.dumps(kpi_config.model_dump(exclude_none=True)),
33
+ }
34
+ if filter_params is not None:
35
+ params["filter"] = json.dumps(filter_params.to_json(), default=json_serial)
36
+ if display_name is not None:
37
+ params["displayName"] = display_name
38
+ if display_type is not None:
39
+ params["displayType"] = display_type
40
+ if sort is not None:
41
+ params["sort"] = json.dumps(sort, default=json_serial) if not isinstance(sort, str) else sort
42
+ if limit is not None:
43
+ params["limit"] = str(limit)
44
+ if start is not None:
45
+ params["start"] = str(start)
46
+ if with_detail_fields is not None:
47
+ params["withDetailFields"] = "1" if with_detail_fields else "0"
48
+ path = f"/rest/data/{module}/kpi"
49
+ result = await self._get(path, params=params)
50
+ if isinstance(result, dict) and result.get("success"):
51
+ return {"value": result.get("value", 0), "count": result.get("count", 0)}
52
+ return {"value": 0, "count": 0}
53
+
54
+ async def get_graph(
55
+ self,
56
+ module: str,
57
+ graph_config: Union[Dict[str, Any], Any],
58
+ *,
59
+ filter_params: Optional[KonectyFilter] = None,
60
+ display_name: Optional[str] = None,
61
+ display_type: Optional[str] = None,
62
+ sort: Optional[Any] = None,
63
+ limit: Optional[int] = None,
64
+ start: Optional[int] = None,
65
+ with_detail_fields: Optional[bool] = None,
66
+ lang: Optional[str] = None,
67
+ ) -> str:
68
+ """
69
+ Get graph as SVG. GET /rest/data/{module}/graph. graphConfig: type, xAxis, yAxis, series, etc.
70
+ """
71
+ payload = (
72
+ graph_config.model_dump(by_alias=True, exclude_none=True)
73
+ if hasattr(graph_config, "model_dump")
74
+ else graph_config
75
+ )
76
+ params: Dict[str, str] = {"graphConfig": json.dumps(payload)}
77
+ if filter_params is not None:
78
+ params["filter"] = json.dumps(filter_params.to_json(), default=json_serial)
79
+ if display_name is not None:
80
+ params["displayName"] = display_name
81
+ if display_type is not None:
82
+ params["displayType"] = display_type
83
+ if sort is not None:
84
+ params["sort"] = json.dumps(sort, default=json_serial) if not isinstance(sort, str) else sort
85
+ if limit is not None:
86
+ params["limit"] = str(limit)
87
+ if start is not None:
88
+ params["start"] = str(start)
89
+ if with_detail_fields is not None:
90
+ params["withDetailFields"] = "1" if with_detail_fields else "0"
91
+ if lang is not None:
92
+ params["lang"] = lang
93
+ path = f"/rest/data/{module}/graph"
94
+ raw = await self._get(path, params=params, return_bytes=True)
95
+ if isinstance(raw, bytes):
96
+ return raw.decode("utf-8")
97
+ return str(raw)
98
+
99
+ async def get_pivot(
100
+ self,
101
+ module: str,
102
+ pivot_config: Union[Dict[str, Any], Any],
103
+ *,
104
+ filter_params: Optional[KonectyFilter] = None,
105
+ display_name: Optional[str] = None,
106
+ display_type: Optional[str] = None,
107
+ sort: Optional[Any] = None,
108
+ limit: Optional[int] = None,
109
+ start: Optional[int] = None,
110
+ with_detail_fields: Optional[bool] = None,
111
+ lang: Optional[str] = None,
112
+ ) -> Dict[str, Any]:
113
+ """
114
+ Get pivot table result. GET /rest/data/{module}/pivot. pivotConfig: rows, columns, values.
115
+ """
116
+ payload = (
117
+ pivot_config.model_dump(by_alias=True, exclude_none=True)
118
+ if hasattr(pivot_config, "model_dump")
119
+ else pivot_config
120
+ )
121
+ params: Dict[str, str] = {"pivotConfig": json.dumps(payload)}
122
+ if filter_params is not None:
123
+ params["filter"] = json.dumps(filter_params.to_json(), default=json_serial)
124
+ if display_name is not None:
125
+ params["displayName"] = display_name
126
+ if display_type is not None:
127
+ params["displayType"] = display_type
128
+ if sort is not None:
129
+ params["sort"] = json.dumps(sort, default=json_serial) if not isinstance(sort, str) else sort
130
+ if limit is not None:
131
+ params["limit"] = str(limit)
132
+ if start is not None:
133
+ params["start"] = str(start)
134
+ if with_detail_fields is not None:
135
+ params["withDetailFields"] = "1" if with_detail_fields else "0"
136
+ if lang is not None:
137
+ params["lang"] = lang
138
+ path = f"/rest/data/{module}/pivot"
139
+ result = await self._get(path, params=params)
140
+ if isinstance(result, dict):
141
+ return result
142
+ return {}
@@ -0,0 +1,105 @@
1
+ """Auth API: Google hosted authorization code flow and login options."""
2
+
3
+ from typing import Any, Dict, Optional, Union
4
+ from urllib.parse import quote, urlencode
5
+
6
+ from ..exceptions import KonectyAPIError
7
+ from .base import BaseService
8
+
9
+ GOOGLE_START_PATH = "/api/auth/google/start"
10
+ # Conjunto que `encodeURIComponent` deixa passar além de [A-Za-z0-9_.-~], que o
11
+ # `quote` já preserva por padrão. Usado para que a URL montada aqui seja
12
+ # byte a byte igual à do SDK TypeScript — ver `_encode_query` abaixo.
13
+ _ENCODE_URI_COMPONENT_SAFE = "!*'()"
14
+ GOOGLE_SESSION_PATH = "/api/auth/google/session"
15
+ LOGIN_OPTIONS_PATH = "/api/auth/login-options"
16
+
17
+
18
+ def _first_error_message(error: KonectyAPIError) -> str:
19
+ """Extract errors[0].message from a KonectyAPIError raised by the HTTP layer."""
20
+ errors = error.args[0] if error.args else None
21
+ if isinstance(errors, list) and errors:
22
+ first = errors[0]
23
+ if isinstance(first, dict):
24
+ message = first.get("message")
25
+ if isinstance(message, str) and message:
26
+ return message
27
+ return str(error)
28
+
29
+
30
+ def _encode_query(params: list) -> str:
31
+ """
32
+ Codifica o query string como `encodeURIComponent` do SDK TypeScript.
33
+
34
+ O default do `urlencode` é `quote_plus`, que transforma espaço em `+`;
35
+ `encodeURIComponent` produz `%20`. Os dois decodificam para o mesmo valor no
36
+ servidor, mas a spec pede URLs idênticas entre os SDKs.
37
+ """
38
+ return urlencode(params, quote_via=quote, safe=_ENCODE_URI_COMPONENT_SAFE)
39
+
40
+
41
+ class AuthService(BaseService):
42
+ """Service for the Google login flow (authorization code hosted by Konecty)."""
43
+
44
+ def google_login_url(
45
+ self,
46
+ client_id: str,
47
+ redirect_uri: Optional[str] = None,
48
+ state: Optional[str] = None,
49
+ ) -> str:
50
+ """
51
+ Build the absolute URL of GET /api/auth/google/start. Does not perform any request.
52
+
53
+ The browser follows this URL; Konecty redirects to accounts.google.com and,
54
+ after consent, back to redirect_uri with a single-use code (and the original state).
55
+ """
56
+ params = [("client_id", client_id)]
57
+ if redirect_uri is not None:
58
+ params.append(("redirect_uri", redirect_uri))
59
+ if state is not None:
60
+ params.append(("state", state))
61
+ base_url = self._client.base_url.rstrip("/")
62
+ return f"{base_url}{GOOGLE_START_PATH}?{_encode_query(params)}"
63
+
64
+ async def exchange_google_code(
65
+ self,
66
+ code: str,
67
+ *,
68
+ geolocation: Optional[Union[Dict[str, Any], str]] = None,
69
+ resolution: Optional[Union[Dict[str, Any], str]] = None,
70
+ source: Optional[str] = None,
71
+ fingerprint: Optional[str] = None,
72
+ ) -> Dict[str, Any]:
73
+ """
74
+ POST /api/auth/google/session. Exchanges the single-use code (TTL 60s) for a session.
75
+
76
+ On success the returned authId is adopted by the client, so following requests
77
+ are authenticated. On error raises KonectyAPIError with errors[0].message and
78
+ leaves the client authentication state untouched.
79
+ """
80
+ payload: Dict[str, Any] = {"code": code}
81
+ if geolocation is not None:
82
+ payload["geolocation"] = geolocation
83
+ if resolution is not None:
84
+ payload["resolution"] = resolution
85
+ if source is not None:
86
+ payload["source"] = source
87
+ if fingerprint is not None:
88
+ payload["fingerprint"] = fingerprint
89
+
90
+ try:
91
+ response = await self._post(GOOGLE_SESSION_PATH, json=payload)
92
+ except KonectyAPIError as error:
93
+ raise KonectyAPIError(_first_error_message(error)) from error
94
+
95
+ auth_id = response.get("authId") if isinstance(response, dict) else None
96
+ if not isinstance(auth_id, str) or not auth_id:
97
+ raise KonectyAPIError("Google session response has no authId")
98
+
99
+ self._client.set_auth_id(auth_id)
100
+ return response
101
+
102
+ async def get_login_options(self) -> Dict[str, Any]:
103
+ """GET /api/auth/login-options. Flags: passwordEnabled, emailOtpEnabled,
104
+ whatsAppOtpEnabled, webauthnEnabled, webauthnRequired, googleEnabled."""
105
+ return await self._get(LOGIN_OPTIONS_PATH)
@@ -0,0 +1,63 @@
1
+ """Base service class. All domain services inherit from it and delegate HTTP to the client."""
2
+
3
+ from typing import Any, Dict, Optional, TypeVar
4
+
5
+ Client = TypeVar("Client", bound=Any)
6
+
7
+
8
+ class BaseService:
9
+ """Base for all Konecty API services. Exposes _get, _post, _put, _delete via client._request."""
10
+
11
+ def __init__(self, client: Client) -> None:
12
+ self._client = client
13
+
14
+ async def _get(
15
+ self,
16
+ path: str,
17
+ *,
18
+ params: Optional[Dict[str, Any]] = None,
19
+ stream: bool = False,
20
+ return_bytes: bool = False,
21
+ ) -> Any:
22
+ return await self._client._request(
23
+ "GET", path, params=params, stream=stream, return_bytes=return_bytes
24
+ )
25
+
26
+ async def _post(
27
+ self,
28
+ path: str,
29
+ *,
30
+ params: Optional[Dict[str, Any]] = None,
31
+ json: Optional[Dict[str, Any]] = None,
32
+ stream: bool = False,
33
+ ) -> Any:
34
+ return await self._client._request(
35
+ "POST", path, params=params, json=json, stream=stream
36
+ )
37
+
38
+ async def _put(
39
+ self,
40
+ path: str,
41
+ *,
42
+ params: Optional[Dict[str, Any]] = None,
43
+ json: Optional[Dict[str, Any]] = None,
44
+ ) -> Any:
45
+ return await self._client._request("PUT", path, params=params, json=json)
46
+
47
+ async def _delete(
48
+ self,
49
+ path: str,
50
+ *,
51
+ params: Optional[Dict[str, Any]] = None,
52
+ json: Optional[Dict[str, Any]] = None,
53
+ ) -> Any:
54
+ return await self._client._request("DELETE", path, params=params, json=json)
55
+
56
+ async def _patch(
57
+ self,
58
+ path: str,
59
+ *,
60
+ params: Optional[Dict[str, Any]] = None,
61
+ json: Optional[Dict[str, Any]] = None,
62
+ ) -> Any:
63
+ return await self._client._request("PATCH", path, params=params, json=json)