positiva-consultas-python-sdk 1.0.0__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.
@@ -0,0 +1,12 @@
1
+ # -*- coding: utf-8 -*-
2
+ """SDK Python para a API Positiva Consultas (PositivaConnect)."""
3
+ from positiva.consultas.api import PositivaConsultasApi
4
+ from positiva.consultas.apis.juridico360 import Juridico360Api
5
+ from positiva.consultas.dtos.enums import Juridico360ProductCode, MpKind
6
+
7
+ __all__ = [
8
+ 'PositivaConsultasApi',
9
+ 'Juridico360Api',
10
+ 'Juridico360ProductCode',
11
+ 'MpKind',
12
+ ]
@@ -0,0 +1,106 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Cliente base Positiva Consultas — autenticacao OAuth 2.0 Client Credentials."""
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import logging
7
+ import os
8
+
9
+ import jsonpickle
10
+ from fmconsult.http.api import ApiBase
11
+ from fmconsult.utils.url import UrlUtil
12
+ from http import HTTPMethod
13
+
14
+ from positiva.consultas.dtos.auth import (
15
+ ClientMeResponse,
16
+ LogoutResponse,
17
+ TokenResponse,
18
+ )
19
+
20
+
21
+ class PositivaConsultasApi(ApiBase):
22
+ """API base com login automatico via ``POST /client/token`` (Basic Auth).
23
+
24
+ Variaveis de ambiente:
25
+ - ``positiva.consultas.api.client_id``
26
+ - ``positiva.consultas.api.client_secret``
27
+ """
28
+
29
+ API_BASE_URL = 'https://positivaconnect.positivaconsultas.com.br/api'
30
+
31
+ def __init__(self):
32
+ self.api_client_id = os.environ['positiva.consultas.api.client_id']
33
+ self.api_client_secret = os.environ['positiva.consultas.api.client_secret']
34
+ self.base_url = self.API_BASE_URL
35
+ self.access_token: str | None = None
36
+ self.token_expires_in: int | None = None
37
+ self.headers: dict[str, str] = {
38
+ 'Accept': 'application/json',
39
+ }
40
+ self.__login()
41
+
42
+ def __basic_auth_header(self) -> str:
43
+ raw = f'{self.api_client_id}:{self.api_client_secret}'.encode()
44
+ return f'Basic {base64.b64encode(raw).decode()}'
45
+
46
+ def __apply_bearer(self, access_token: str, expires_in: int | None = None) -> None:
47
+ self.access_token = access_token
48
+ self.token_expires_in = expires_in
49
+ self.headers['Authorization'] = f'Bearer {access_token}'
50
+
51
+ def __login(self) -> TokenResponse:
52
+ """POST /client/token — Authorization Basic (client_id:client_secret)."""
53
+ logging.info('authenticating via Positiva Consultas /client/token...')
54
+ self.headers = {
55
+ 'Accept': 'application/json',
56
+ 'Authorization': self.__basic_auth_header(),
57
+ }
58
+ url = UrlUtil().make_url(self.base_url, ['client', 'token'])
59
+ res = self.call_request(http_method=HTTPMethod.POST, request_url=url)
60
+ data = jsonpickle.decode(res)
61
+ token = TokenResponse.from_dict(data)
62
+ if token is None or not token.access_token:
63
+ raise Exception(
64
+ data.get('error_description')
65
+ or data.get('message')
66
+ or 'Falha ao obter access_token'
67
+ )
68
+ self.__apply_bearer(token.access_token, token.expires_in)
69
+ return token
70
+
71
+ def me(self) -> ClientMeResponse:
72
+ """GET /client/me — dados do cliente autenticado."""
73
+ logging.info('fetching Positiva Consultas /client/me...')
74
+ url = UrlUtil().make_url(self.base_url, ['client', 'me'])
75
+ res = self.call_request(http_method=HTTPMethod.GET, request_url=url)
76
+ parsed = ClientMeResponse.from_dict(jsonpickle.decode(res))
77
+ if parsed is None or parsed.client is None:
78
+ raise Exception('Resposta inesperada de /client/me')
79
+ return parsed
80
+
81
+ def refresh(self) -> TokenResponse:
82
+ """POST /client/refresh — renova o Bearer atual."""
83
+ logging.info('refreshing Positiva Consultas token...')
84
+ url = UrlUtil().make_url(self.base_url, ['client', 'refresh'])
85
+ res = self.call_request(http_method=HTTPMethod.POST, request_url=url)
86
+ data = jsonpickle.decode(res)
87
+ token = TokenResponse.from_dict(data)
88
+ if token is None or not token.access_token:
89
+ raise Exception(
90
+ data.get('error_description')
91
+ or data.get('message')
92
+ or 'Falha ao renovar access_token'
93
+ )
94
+ self.__apply_bearer(token.access_token, token.expires_in)
95
+ return token
96
+
97
+ def logout(self) -> LogoutResponse:
98
+ """POST /client/logout — invalida o token atual."""
99
+ logging.info('logging out Positiva Consultas token...')
100
+ url = UrlUtil().make_url(self.base_url, ['client', 'logout'])
101
+ res = self.call_request(http_method=HTTPMethod.POST, request_url=url)
102
+ parsed = LogoutResponse.from_dict(jsonpickle.decode(res))
103
+ self.access_token = None
104
+ self.token_expires_in = None
105
+ self.headers.pop('Authorization', None)
106
+ return parsed or LogoutResponse()
@@ -0,0 +1,4 @@
1
+ # -*- coding: utf-8 -*-
2
+ from positiva.consultas.apis.juridico360 import Juridico360Api
3
+
4
+ __all__ = ['Juridico360Api']
@@ -0,0 +1,169 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Cliente HTTP Positiva Juridico 360."""
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Sequence
7
+
8
+ import jsonpickle
9
+ from fmconsult.utils.url import UrlUtil
10
+ from http import HTTPMethod
11
+
12
+ from positiva.consultas.api import PositivaConsultasApi
13
+ from positiva.consultas.dtos.enums import Juridico360ProductCode
14
+ from positiva.consultas.dtos.juridico360 import (
15
+ Juridico360Options,
16
+ Juridico360PdfResponse,
17
+ Juridico360Request,
18
+ Juridico360Response,
19
+ )
20
+ from positiva.consultas.dtos.serde import digits_only, to_wire
21
+
22
+ _VALID_PRODUCTS = {p.value for p in Juridico360ProductCode}
23
+
24
+
25
+ def _normalize_product_codes(
26
+ product_code: Sequence[str | Juridico360ProductCode],
27
+ ) -> list[str]:
28
+ if not product_code:
29
+ raise ValueError('product_code é obrigatório e não pode ser vazio')
30
+ normalized: list[str] = []
31
+ for code in product_code:
32
+ value = str(to_wire(code))
33
+ if value not in _VALID_PRODUCTS:
34
+ raise ValueError(
35
+ f'product_code inválido: {value!r}. '
36
+ f'Valores aceitos: {sorted(_VALID_PRODUCTS)}'
37
+ )
38
+ if value not in normalized:
39
+ normalized.append(value)
40
+ return normalized
41
+
42
+
43
+ def _require_document(document: str | None) -> str:
44
+ digits = digits_only(document)
45
+ if len(digits) not in (11, 14):
46
+ raise ValueError(
47
+ 'document deve ser CPF (11 dígitos) ou CNPJ (14 dígitos)'
48
+ )
49
+ return digits
50
+
51
+
52
+ def _coerce_request(
53
+ request: Juridico360Request | dict,
54
+ ) -> Juridico360Request:
55
+ if isinstance(request, Juridico360Request):
56
+ return request
57
+ parsed = Juridico360Request.from_dict(request)
58
+ if parsed is None:
59
+ raise ValueError('request inválido')
60
+ return parsed
61
+
62
+
63
+ class Juridico360Api(PositivaConsultasApi):
64
+ """Consulta processual Positiva Juridico 360."""
65
+
66
+ def consultar(
67
+ self, request: Juridico360Request | dict
68
+ ) -> Juridico360Response:
69
+ """POST /api/client/provider/juridico360."""
70
+ logging.info('consulting Positiva Juridico 360...')
71
+ req = _coerce_request(request)
72
+ payload = {
73
+ 'document': _require_document(req.document),
74
+ 'product_code': _normalize_product_codes(req.product_code),
75
+ }
76
+ if req.options is not None:
77
+ options = req.options.to_dict() if isinstance(req.options, Juridico360Options) else dict(req.options)
78
+ if options:
79
+ if options.get('page_size') is not None and int(options['page_size']) > 100:
80
+ raise ValueError('options.page_size máximo é 100')
81
+ payload['options'] = options
82
+
83
+ url = UrlUtil().make_url(
84
+ self.base_url, ['client', 'provider', 'juridico360']
85
+ )
86
+ res = self.call_request(
87
+ http_method=HTTPMethod.POST,
88
+ request_url=url,
89
+ payload=payload,
90
+ )
91
+ parsed = Juridico360Response.from_dict(jsonpickle.decode(res))
92
+ if parsed is None:
93
+ raise Exception('Resposta inesperada de Juridico 360')
94
+ return parsed
95
+
96
+ def consultar_por_documento(
97
+ self,
98
+ document: str,
99
+ product_code: Sequence[str | Juridico360ProductCode],
100
+ *,
101
+ options: Juridico360Options | dict | None = None,
102
+ ) -> Juridico360Response:
103
+ """Atalho tipado para ``consultar``."""
104
+ opts = None
105
+ if isinstance(options, Juridico360Options):
106
+ opts = options
107
+ elif isinstance(options, dict):
108
+ opts = Juridico360Options.from_dict(options)
109
+ return self.consultar(
110
+ Juridico360Request(
111
+ document=document,
112
+ product_code=list(product_code),
113
+ options=opts,
114
+ )
115
+ )
116
+
117
+ def gerar_pdf(
118
+ self,
119
+ request: Juridico360Request | dict | None = None,
120
+ *,
121
+ document: str | None = None,
122
+ product_code: Sequence[str | Juridico360ProductCode] | None = None,
123
+ options: Juridico360Options | dict | None = None,
124
+ ) -> Juridico360PdfResponse:
125
+ """POST /api/client/provider/juridico360/pdf — retorna PDF em Base64."""
126
+ logging.info('generating Positiva Juridico 360 PDF...')
127
+ if request is not None:
128
+ req = _coerce_request(request)
129
+ else:
130
+ if document is None or product_code is None:
131
+ raise ValueError(
132
+ 'informe request ou (document + product_code) para gerar PDF'
133
+ )
134
+ opts = None
135
+ if isinstance(options, Juridico360Options):
136
+ opts = options
137
+ elif isinstance(options, dict):
138
+ opts = Juridico360Options.from_dict(options)
139
+ req = Juridico360Request(
140
+ document=document,
141
+ product_code=list(product_code),
142
+ options=opts,
143
+ )
144
+
145
+ payload = {
146
+ 'document': _require_document(req.document),
147
+ 'product_code': _normalize_product_codes(req.product_code),
148
+ }
149
+ if req.options is not None:
150
+ options_dict = (
151
+ req.options.to_dict()
152
+ if isinstance(req.options, Juridico360Options)
153
+ else dict(req.options)
154
+ )
155
+ if options_dict:
156
+ payload['options'] = options_dict
157
+
158
+ url = UrlUtil().make_url(
159
+ self.base_url, ['client', 'provider', 'juridico360', 'pdf']
160
+ )
161
+ res = self.call_request(
162
+ http_method=HTTPMethod.POST,
163
+ request_url=url,
164
+ payload=payload,
165
+ )
166
+ parsed = Juridico360PdfResponse.from_dict(jsonpickle.decode(res))
167
+ if parsed is None:
168
+ raise Exception('Resposta inesperada de Juridico 360 PDF')
169
+ return parsed
@@ -0,0 +1,40 @@
1
+ # -*- coding: utf-8 -*-
2
+ from positiva.consultas.dtos.auth import (
3
+ AvailableProduct,
4
+ ClientInfo,
5
+ ClientMeResponse,
6
+ LogoutResponse,
7
+ ProductFeature,
8
+ ProviderProduct,
9
+ TokenResponse,
10
+ )
11
+ from positiva.consultas.dtos.enums import DocumentType, Juridico360ProductCode, MpKind
12
+ from positiva.consultas.dtos.juridico360 import (
13
+ Juridico360Options,
14
+ Juridico360PdfResponse,
15
+ Juridico360ProductData,
16
+ Juridico360ProductResult,
17
+ Juridico360Request,
18
+ Juridico360Response,
19
+ PenaPrevista,
20
+ )
21
+
22
+ __all__ = [
23
+ 'AvailableProduct',
24
+ 'ClientInfo',
25
+ 'ClientMeResponse',
26
+ 'DocumentType',
27
+ 'Juridico360Options',
28
+ 'Juridico360PdfResponse',
29
+ 'Juridico360ProductCode',
30
+ 'Juridico360ProductData',
31
+ 'Juridico360ProductResult',
32
+ 'Juridico360Request',
33
+ 'Juridico360Response',
34
+ 'LogoutResponse',
35
+ 'MpKind',
36
+ 'PenaPrevista',
37
+ 'ProductFeature',
38
+ 'ProviderProduct',
39
+ 'TokenResponse',
40
+ ]
@@ -0,0 +1,173 @@
1
+ # -*- coding: utf-8 -*-
2
+ """DTOs de autenticacao (/client/token, /client/me, /client/refresh, /client/logout)."""
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from positiva.consultas.dtos.serde import as_wire_dict, from_dataclass, omit_none, to_wire
8
+
9
+
10
+ @dataclass
11
+ class ProductFeature:
12
+ code: str | None = None
13
+ name: str | None = None
14
+
15
+ def to_dict(self) -> dict:
16
+ return as_wire_dict(self)
17
+
18
+ @classmethod
19
+ def from_dict(cls, data: dict | ProductFeature | None) -> ProductFeature | None:
20
+ return from_dataclass(cls, data)
21
+
22
+
23
+ @dataclass
24
+ class AvailableProduct:
25
+ code: str | None = None
26
+ name: str | None = None
27
+ has_additionals: bool | None = None
28
+ features: list[ProductFeature] | None = None
29
+
30
+ def to_dict(self) -> dict:
31
+ return omit_none({
32
+ 'code': self.code,
33
+ 'name': self.name,
34
+ 'has_additionals': self.has_additionals,
35
+ 'features': to_wire(self.features),
36
+ })
37
+
38
+ @classmethod
39
+ def from_dict(cls, data: dict | AvailableProduct | None) -> AvailableProduct | None:
40
+ if data is None:
41
+ return None
42
+ if isinstance(data, cls):
43
+ return data
44
+ if not isinstance(data, dict):
45
+ raise TypeError(f'AvailableProduct.from_dict espera dict, recebeu {type(data)!r}')
46
+ features = data.get('features')
47
+ return cls(
48
+ code=data.get('code'),
49
+ name=data.get('name'),
50
+ has_additionals=data.get('has_additionals'),
51
+ features=[ProductFeature.from_dict(f) for f in features] if features else None,
52
+ )
53
+
54
+
55
+ @dataclass
56
+ class ProviderProduct:
57
+ """Produto de provider (ex.: Juridico 360) exposto em ``provider_products``."""
58
+
59
+ code: str | None = None
60
+ name: str | None = None
61
+
62
+ def to_dict(self) -> dict:
63
+ return as_wire_dict(self)
64
+
65
+ @classmethod
66
+ def from_dict(cls, data: dict | ProviderProduct | None) -> ProviderProduct | None:
67
+ return from_dataclass(cls, data)
68
+
69
+
70
+ @dataclass
71
+ class ClientInfo:
72
+ name: str | None = None
73
+ email: str | None = None
74
+ cnpj: str | None = None
75
+ phone: str | None = None
76
+ active: bool | None = None
77
+
78
+ def to_dict(self) -> dict:
79
+ return as_wire_dict(self)
80
+
81
+ @classmethod
82
+ def from_dict(cls, data: dict | ClientInfo | None) -> ClientInfo | None:
83
+ return from_dataclass(cls, data)
84
+
85
+
86
+ @dataclass
87
+ class TokenResponse:
88
+ access_token: str | None = None
89
+ token_type: str | None = None
90
+ expires_in: int | None = None
91
+ client: ClientInfo | None = None
92
+ products: list[AvailableProduct] | None = None
93
+ # Ambiente por provider, ex.: {"juridico360": "homolog"|"production"}
94
+ provider_environments: dict[str, str] | None = None
95
+ # Produtos contratados por provider, ex.: {"juridico360": [{"code","name"}, ...]}
96
+ provider_products: dict[str, list[ProviderProduct]] | None = None
97
+
98
+ def to_dict(self) -> dict:
99
+ return omit_none({
100
+ 'access_token': self.access_token,
101
+ 'token_type': self.token_type,
102
+ 'expires_in': self.expires_in,
103
+ 'client': to_wire(self.client),
104
+ 'products': to_wire(self.products),
105
+ 'provider_environments': self.provider_environments,
106
+ 'provider_products': {
107
+ key: to_wire(value)
108
+ for key, value in (self.provider_products or {}).items()
109
+ }
110
+ if self.provider_products is not None
111
+ else None,
112
+ })
113
+
114
+ @classmethod
115
+ def from_dict(cls, data: dict | TokenResponse | None) -> TokenResponse | None:
116
+ if data is None:
117
+ return None
118
+ if isinstance(data, cls):
119
+ return data
120
+ if not isinstance(data, dict):
121
+ raise TypeError(f'TokenResponse.from_dict espera dict, recebeu {type(data)!r}')
122
+ products = data.get('products')
123
+ raw_provider_products = data.get('provider_products')
124
+ provider_products = None
125
+ if isinstance(raw_provider_products, dict):
126
+ provider_products = {
127
+ key: [
128
+ ProviderProduct.from_dict(item)
129
+ for item in (value or [])
130
+ if isinstance(item, dict)
131
+ ]
132
+ for key, value in raw_provider_products.items()
133
+ }
134
+ environments = data.get('provider_environments')
135
+ return cls(
136
+ access_token=data.get('access_token'),
137
+ token_type=data.get('token_type'),
138
+ expires_in=data.get('expires_in'),
139
+ client=ClientInfo.from_dict(data.get('client')),
140
+ products=[AvailableProduct.from_dict(p) for p in products] if products else None,
141
+ provider_environments=dict(environments) if isinstance(environments, dict) else None,
142
+ provider_products=provider_products,
143
+ )
144
+
145
+
146
+ @dataclass
147
+ class ClientMeResponse:
148
+ client: ClientInfo | None = None
149
+
150
+ def to_dict(self) -> dict:
151
+ return omit_none({'client': to_wire(self.client)})
152
+
153
+ @classmethod
154
+ def from_dict(cls, data: dict | ClientMeResponse | None) -> ClientMeResponse | None:
155
+ if data is None:
156
+ return None
157
+ if isinstance(data, cls):
158
+ return data
159
+ if not isinstance(data, dict):
160
+ raise TypeError(f'ClientMeResponse.from_dict espera dict, recebeu {type(data)!r}')
161
+ return cls(client=ClientInfo.from_dict(data.get('client')))
162
+
163
+
164
+ @dataclass
165
+ class LogoutResponse:
166
+ message: str | None = None
167
+
168
+ def to_dict(self) -> dict:
169
+ return as_wire_dict(self)
170
+
171
+ @classmethod
172
+ def from_dict(cls, data: dict | LogoutResponse | None) -> LogoutResponse | None:
173
+ return from_dataclass(cls, data)
@@ -0,0 +1,28 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Enums de domínio da API Positiva Consultas."""
3
+ from __future__ import annotations
4
+
5
+ from enum import StrEnum
6
+
7
+
8
+ class Juridico360ProductCode(StrEnum):
9
+ """Codigos de produto do endpoint Juridico 360."""
10
+
11
+ CRIMINAL = 'CRIMINAL'
12
+ CIVIL = 'CIVIL'
13
+ TRABALHISTA = 'TRABALHISTA'
14
+ MP = 'MP'
15
+ BNMP = 'BNMP'
16
+ EMPREGADOR_IRREGULAR = 'EMPREGADOR_IRREGULAR'
17
+
18
+
19
+ class MpKind(StrEnum):
20
+ """Filtro opcional ``options.kind`` para produtos MP/BNMP."""
21
+
22
+ CRIMINAL = 'CRIMINAL'
23
+ CIVIL = 'CIVIL'
24
+
25
+
26
+ class DocumentType(StrEnum):
27
+ CPF = 'CPF'
28
+ CNPJ = 'CNPJ'