From f35243d4b81f365087de945199d224d0a677a95e Mon Sep 17 00:00:00 2001 From: Danilo Josino Date: Thu, 21 May 2026 13:24:10 -0300 Subject: [PATCH 1/8] =?UTF-8?q?fix(types):=20ampliar=20escopo=20mypy=20e?= =?UTF-8?q?=20corrigir=20anota=C3=A7=C3=B5es=20em=20m=C3=B3dulos=20core?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Adiciona bound_resource, transport, resource, client_scope e outros ao mypy strict - Usa `builtins.list` onde `list` shadowed pelo nome do método Resource.list - Corrige proxy_map em UrllibHTTPClient para não incluir key com proxy=None - Usa variável `raw: Any` para acessar atributos de exc httpx sem mypy error - Alinha assinaturas delete/retrieve com a base Resource (options=None) - Remove type: ignore redundantes após correções acima Co-Authored-By: Claude Sonnet 4.6 --- pyproject.toml | 9 +++++++++ src/clicksign/_async/bound_resource.py | 11 ++++++----- src/clicksign/_http/transport.py | 18 +++++++++++------- src/clicksign/bound_resource.py | 9 +++++---- src/clicksign/client_scope.py | 4 ++-- .../json_api/bulk_operations_client.py | 2 +- src/clicksign/resource.py | 17 +++++++++-------- .../resources/acceptance_term/whatsapp.py | 4 +++- src/clicksign/resources/auto_signature/term.py | 4 ++-- .../resources/envelope_bulk_creation.py | 4 ++-- 10 files changed, 50 insertions(+), 32 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 2c93639..787162a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,15 @@ python_version = "3.10" strict = true files = [ "src/clicksign/types", + "src/clicksign/resource.py", + "src/clicksign/bound_resource.py", + "src/clicksign/client_scope.py", + "src/clicksign/_http/transport.py", + "src/clicksign/_async/bound_resource.py", + "src/clicksign/json_api/bulk_operations_client.py", + "src/clicksign/resources/envelope_bulk_creation.py", + "src/clicksign/resources/auto_signature/term.py", + "src/clicksign/resources/acceptance_term/whatsapp.py", "src/clicksign/resources/notarial/envelope.py", "src/clicksign/resources/notarial/document.py", "src/clicksign/resources/notarial/signer.py", diff --git a/src/clicksign/_async/bound_resource.py b/src/clicksign/_async/bound_resource.py index 1478fde..8e6a519 100644 --- a/src/clicksign/_async/bound_resource.py +++ b/src/clicksign/_async/bound_resource.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import builtins from collections.abc import AsyncIterator from typing import TYPE_CHECKING, Any @@ -16,7 +17,7 @@ class AsyncBoundQueryProxy: - def __init__(self, owner: AsyncClicksignClient, proxy: AsyncQueryProxy) -> None: + def __init__(self, owner: AsyncClicksignClient, proxy: AsyncQueryProxy[Any]) -> None: self._owner = owner self._proxy = proxy @@ -40,7 +41,7 @@ def with_includes(self, *types: str) -> AsyncBoundQueryProxy: self._proxy.with_includes(*types) return self - def fields(self, **types: list[str]) -> AsyncBoundQueryProxy: + def fields(self, **types: builtins.list[str]) -> AsyncBoundQueryProxy: self._proxy.fields(**types) return self @@ -49,11 +50,11 @@ def on_page(self, callback: Any) -> AsyncBoundQueryProxy: return self @property - def last_response(self): + def last_response(self) -> Any: return self._proxy.last_response @property - def page_responses(self): + def page_responses(self) -> builtins.list[Any]: return self._proxy.page_responses async def to_list( @@ -143,7 +144,7 @@ def with_includes(self, *types: str) -> AsyncBoundQueryProxy: AsyncQueryProxy(self._cls, self._owner.http).with_includes(*types), ) - def fields(self, **types: list[str]) -> AsyncBoundQueryProxy: + def fields(self, **types: builtins.list[str]) -> AsyncBoundQueryProxy: return AsyncBoundQueryProxy( self._owner, AsyncQueryProxy(self._cls, self._owner.http).fields(**types), diff --git a/src/clicksign/_http/transport.py b/src/clicksign/_http/transport.py index ecca870..8de40e3 100644 --- a/src/clicksign/_http/transport.py +++ b/src/clicksign/_http/transport.py @@ -95,7 +95,7 @@ def _request_via_proxy( body: bytes | None, timeout: float, ) -> HTTPResponse: - proxy_map = {"http": self._proxy, "https": self._proxy} + proxy_map: dict[str, str] = {k: self._proxy for k in ("http", "https") if self._proxy} handlers: list[Any] = [urllib.request.ProxyHandler(proxy_map)] if not self._verify_ssl_certs: handlers.append(urllib.request.HTTPSHandler(context=ssl._create_unverified_context())) @@ -212,6 +212,7 @@ def request( read_timeout: float, write_timeout: float, ) -> HTTPResponse: + timeout: Any if self._owns_client: import httpx @@ -235,10 +236,11 @@ def request( except Exception as exc: exc_name = type(exc).__name__ if exc_name == "HTTPStatusError": + raw: Any = exc raise HTTPStatusError( - exc.response.status_code, - exc.response.text, - dict(exc.response.headers), + raw.response.status_code, + raw.response.text, + dict(raw.response.headers), ) from exc if exc_name == "RequestError": raise HTTPConnectionError(str(exc)) from exc @@ -325,6 +327,7 @@ async def request( read_timeout: float, write_timeout: float, ) -> HTTPResponse: + timeout: Any if self._owns_client: import httpx @@ -348,10 +351,11 @@ async def request( except Exception as exc: exc_name = type(exc).__name__ if exc_name == "HTTPStatusError": + raw: Any = exc raise HTTPStatusError( - exc.response.status_code, - exc.response.text, - dict(exc.response.headers), + raw.response.status_code, + raw.response.text, + dict(raw.response.headers), ) from exc if exc_name == "RequestError": raise HTTPConnectionError(str(exc)) from exc diff --git a/src/clicksign/bound_resource.py b/src/clicksign/bound_resource.py index ab5f4c3..3ce628d 100644 --- a/src/clicksign/bound_resource.py +++ b/src/clicksign/bound_resource.py @@ -1,5 +1,6 @@ from __future__ import annotations +import builtins from collections.abc import Iterator from typing import TYPE_CHECKING, Any @@ -12,7 +13,7 @@ class BoundQueryProxy: - def __init__(self, owner: ClicksignClient, proxy: QueryProxy) -> None: + def __init__(self, owner: ClicksignClient, proxy: QueryProxy[Any]) -> None: self._owner = owner self._proxy = proxy @@ -36,7 +37,7 @@ def with_includes(self, *types: str) -> BoundQueryProxy: self._proxy.with_includes(*types) return self - def fields(self, **types: list[str]) -> BoundQueryProxy: + def fields(self, **types: builtins.list[str]) -> BoundQueryProxy: self._proxy.fields(**types) return self @@ -45,7 +46,7 @@ def on_page(self, callback: Any) -> BoundQueryProxy: return self @property - def page_responses(self): + def page_responses(self) -> builtins.list[Any]: return self._proxy.page_responses def to_list(self, *, options: RequestOptions | dict[str, Any] | None = None) -> list[Resource]: @@ -110,7 +111,7 @@ def per(self, n: int) -> BoundQueryProxy: def with_includes(self, *types: str) -> BoundQueryProxy: return BoundQueryProxy(self._owner, self._cls.with_includes(*types)) - def fields(self, **types: list[str]) -> BoundQueryProxy: + def fields(self, **types: builtins.list[str]) -> BoundQueryProxy: return BoundQueryProxy(self._owner, self._cls.fields(**types)) def __getattr__(self, name: str) -> Any: diff --git a/src/clicksign/client_scope.py b/src/clicksign/client_scope.py index f93c43a..938eeae 100644 --- a/src/clicksign/client_scope.py +++ b/src/clicksign/client_scope.py @@ -39,8 +39,8 @@ def client_scope( def get_thread_client() -> Client | None: - return threading.current_thread().__dict__.get("_clicksign_client") # type: ignore[return-value] + return threading.current_thread().__dict__.get("_clicksign_client") def get_thread_bulk_client() -> BulkOperationsClient | None: - return threading.current_thread().__dict__.get("_clicksign_bulk_client") # type: ignore[return-value] + return threading.current_thread().__dict__.get("_clicksign_bulk_client") diff --git a/src/clicksign/json_api/bulk_operations_client.py b/src/clicksign/json_api/bulk_operations_client.py index 2da5832..c4f7f71 100644 --- a/src/clicksign/json_api/bulk_operations_client.py +++ b/src/clicksign/json_api/bulk_operations_client.py @@ -197,4 +197,4 @@ def on_success(body: str, _status: int, _headers: dict[str, str]) -> BulkRespons provider_telemetry=self._provider_telemetry, ) self._last_response = result.metadata - return result.data + return result.data # type: ignore[no-any-return] diff --git a/src/clicksign/resource.py b/src/clicksign/resource.py index 59a9ec5..763aa60 100644 --- a/src/clicksign/resource.py +++ b/src/clicksign/resource.py @@ -32,6 +32,7 @@ class Resource: endpoint: str | None = None _base_path: str | None = None _parent_id: str | None = None + _bound_client: Any = None def __init__(self, data: dict[str, Any]) -> None: self._data = data @@ -42,7 +43,7 @@ def included_resources(self) -> builtins.list[Resource]: index = getattr(self, "_included_index", None) if index is None: return [] - return index.to_resources() + return index.to_resources() # type: ignore[no-any-return] @classmethod def _get_resource_type(cls) -> str: @@ -151,13 +152,13 @@ def _resolve_relationship(self, name: str) -> Any: self._resolved_relationships[name] = None return None if isinstance(data, list): - resolved = [self._resolve_resource_ref(ref) for ref in data] - self._resolved_relationships[name] = resolved - return resolved + resolved_list = [self._resolve_resource_ref(ref) for ref in data] + self._resolved_relationships[name] = resolved_list + return resolved_list - resolved = self._resolve_resource_ref(data) - self._resolved_relationships[name] = resolved - return resolved + resolved_single = self._resolve_resource_ref(data) + self._resolved_relationships[name] = resolved_single + return resolved_single def _resolve_resource_ref(self, ref: dict[str, Any]) -> Resource | None: resource_type = ref.get("type") @@ -492,7 +493,7 @@ def _auto_paginate( if self._on_page is not None: self._on_page(page, self._last_response, instances) # type: ignore[arg-type] - yield from instances + yield from instances # type: ignore[misc] if not has_next_page(parsed, len(instances), per): break diff --git a/src/clicksign/resources/acceptance_term/whatsapp.py b/src/clicksign/resources/acceptance_term/whatsapp.py index b4f1c39..90ed9cd 100644 --- a/src/clicksign/resources/acceptance_term/whatsapp.py +++ b/src/clicksign/resources/acceptance_term/whatsapp.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import Any + from ...resource import Resource @@ -7,5 +9,5 @@ class Whatsapp(Resource): resource_type = "acceptance_term_whatsapps" endpoint = "/acceptance_term/whatsapps" - def delete(self) -> None: + def delete(self, *, options: Any = None) -> None: raise NotImplementedError("AcceptanceTerm::Whatsapp does not support delete") diff --git a/src/clicksign/resources/auto_signature/term.py b/src/clicksign/resources/auto_signature/term.py index c4488b6..3a3a5e1 100644 --- a/src/clicksign/resources/auto_signature/term.py +++ b/src/clicksign/resources/auto_signature/term.py @@ -14,11 +14,11 @@ def list(cls) -> list[Term]: # type: ignore[override] raise NotImplementedError("AutoSignature::Term does not support list") @classmethod - def retrieve(cls, resource_id: str) -> Term: + def retrieve(cls, resource_id: str, *, options: Any = None) -> Term: raise NotImplementedError("AutoSignature::Term does not support retrieve") def update(self, **attrs: Any) -> Term: # type: ignore[override] raise NotImplementedError("AutoSignature::Term does not support update") - def delete(self) -> None: + def delete(self, *, options: Any = None) -> None: raise NotImplementedError("AutoSignature::Term does not support delete") diff --git a/src/clicksign/resources/envelope_bulk_creation.py b/src/clicksign/resources/envelope_bulk_creation.py index 9bd7b41..61795fc 100644 --- a/src/clicksign/resources/envelope_bulk_creation.py +++ b/src/clicksign/resources/envelope_bulk_creation.py @@ -18,11 +18,11 @@ def list(cls) -> list[EnvelopeBulkCreation]: # type: ignore[override] raise NotImplementedError("EnvelopeBulkCreation does not support list") @classmethod - def retrieve(cls, resource_id: str) -> EnvelopeBulkCreation: + def retrieve(cls, resource_id: str, *, options: Any = None) -> EnvelopeBulkCreation: raise NotImplementedError("EnvelopeBulkCreation does not support retrieve") def update(self, **attrs: Any) -> EnvelopeBulkCreation: # type: ignore[override] raise NotImplementedError("EnvelopeBulkCreation does not support update") - def delete(self) -> None: + def delete(self, *, options: Any = None) -> None: raise NotImplementedError("EnvelopeBulkCreation does not support delete") From 8e0c912610c485e2d86d90f893ad2bc448315840 Mon Sep 17 00:00:00 2001 From: Danilo Josino Date: Thu, 21 May 2026 13:24:20 -0300 Subject: [PATCH 2/8] =?UTF-8?q?fix(signer):=20Signer.notify=20vira=20class?= =?UTF-8?q?method=20com=20envelope=5Fid=20e=20signer=5Fid=20expl=C3=ADcito?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instância podia não ter _parent_id definido, forçando fallback para rota sem envelope. Agora caller passa envelope_id e signer_id diretamente, eliminando dependência de estado interno da instância. Co-Authored-By: Claude Sonnet 4.6 --- src/clicksign/resources/notarial/signer.py | 25 +++++++++++----------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/src/clicksign/resources/notarial/signer.py b/src/clicksign/resources/notarial/signer.py index 550981a..a6c0015 100644 --- a/src/clicksign/resources/notarial/signer.py +++ b/src/clicksign/resources/notarial/signer.py @@ -82,20 +82,21 @@ def create(cls, envelope_id: str, **attrs: Unpack[SignerCreateParams]) -> Signer def update(self, **attrs: object) -> Signer: # type: ignore[override] raise NotImplementedError("Signer does not support update") - def notify(self, message: str, subject: str | None = None) -> None: + @classmethod + def notify( + cls, + envelope_id: str, + signer_id: str, + message: str, + subject: str | None = None, + ) -> None: from ...json_api.serializer import serialize_create - client = self._get_client() + client = cls._get_client() attrs: dict[str, str | None] = {"message": message} if subject is not None: attrs["subject"] = subject - parent_id = getattr(self, "_parent_id", None) - if parent_id: - client.post( - f"/envelopes/{parent_id}/signers/{self.id}/notifications", - serialize_create("notifications", attrs), - ) - else: - client.post( - f"/signers/{self.id}/notifications", serialize_create("notifications", attrs) - ) + client.post( + f"/envelopes/{envelope_id}/signers/{signer_id}/notifications", + serialize_create("notifications", attrs), + ) From 4e6a85738690f41557dbd79aaa44d9b747ea3af3 Mon Sep 17 00:00:00 2001 From: Danilo Josino Date: Thu, 21 May 2026 13:24:30 -0300 Subject: [PATCH 3/8] fix(requirement): list_for_* e list_requirements usam QueryBuilder para filtros MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Substituí construção manual de {f"filter[{k}]": str(v)} por QueryBuilder().filter(**filters).to_params() — consistente com o resto do SDK e garante encoding correto de valores complexos. Co-Authored-By: Claude Sonnet 4.6 --- src/clicksign/resources/notarial/envelope.py | 3 ++- src/clicksign/resources/notarial/requirement.py | 8 ++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/clicksign/resources/notarial/envelope.py b/src/clicksign/resources/notarial/envelope.py index 4a2b297..ed119a3 100644 --- a/src/clicksign/resources/notarial/envelope.py +++ b/src/clicksign/resources/notarial/envelope.py @@ -124,9 +124,10 @@ def list_signers(cls, envelope_id: str) -> list[Signer]: @classmethod def list_requirements(cls, envelope_id: str, **filters: Any) -> list[Requirement]: + from ...json_api.query_builder import QueryBuilder from .requirement import Requirement - params = {f"filter[{k}]": str(v) for k, v in filters.items()} if filters else None + params = QueryBuilder().filter(**filters).to_params() if filters else None return cls.nested_list(envelope_id, "requirements", as_class=Requirement, params=params) # type: ignore[return-value] @classmethod diff --git a/src/clicksign/resources/notarial/requirement.py b/src/clicksign/resources/notarial/requirement.py index 3690cc1..ab9d61d 100644 --- a/src/clicksign/resources/notarial/requirement.py +++ b/src/clicksign/resources/notarial/requirement.py @@ -99,18 +99,22 @@ def update(self, **attrs: Unpack[RequirementUpdateParams]) -> Requirement: # ty @classmethod def list_for_document(cls, document_id: str, **filters: Any) -> list[Requirement]: + from ...json_api.query_builder import QueryBuilder + client = cls._get_client() path = f"/documents/{document_id}/relationships/requirements" - params = {f"filter[{k}]": str(v) for k, v in filters.items()} if filters else None + params = QueryBuilder().filter(**filters).to_params() if filters else None response = client.get(path, params) instances, _ = cls._parse_response(response) return instances # type: ignore[return-value] @classmethod def list_for_signer(cls, signer_id: str, **filters: Any) -> list[Requirement]: + from ...json_api.query_builder import QueryBuilder + client = cls._get_client() path = f"/signers/{signer_id}/relationships/requirements" - params = {f"filter[{k}]": str(v) for k, v in filters.items()} if filters else None + params = QueryBuilder().filter(**filters).to_params() if filters else None response = client.get(path, params) instances, _ = cls._parse_response(response) return instances # type: ignore[return-value] From 5ddc546ffdef3ece5ceee3987f638884002b10d2 Mon Sep 17 00:00:00 2001 From: Danilo Josino Date: Thu, 21 May 2026 13:24:40 -0300 Subject: [PATCH 4/8] test: expandir cobertura de recursos notariais e clientes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adiciona casos para Signer.notify (classmethod), Requirement.list_for_*, Envelope.list_requirements com filtros, e fluxos de ClicksignClient / AsyncClient que não tinham cobertura. Co-Authored-By: Claude Sonnet 4.6 --- .../resources/notarial/test_envelope.py | 50 +++++++++++++++++ .../resources/notarial/test_requirement.py | 53 +++++++++++++++++++ .../resources/notarial/test_signer.py | 10 ++++ tests/clicksign/test_async_client.py | 26 +++++++++ tests/clicksign/test_clicksign_client.py | 51 ++++++++++++++++++ 5 files changed, 190 insertions(+) diff --git a/tests/clicksign/resources/notarial/test_envelope.py b/tests/clicksign/resources/notarial/test_envelope.py index 3cfd697..91c2192 100644 --- a/tests/clicksign/resources/notarial/test_envelope.py +++ b/tests/clicksign/resources/notarial/test_envelope.py @@ -7,6 +7,7 @@ from tests.support.http_mock import make_http_error, make_response, mock_urlopen UUID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" +ENV_ID = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" def envelope_data(id=UUID, **attrs): @@ -125,3 +126,52 @@ def test_folder_id_from_relationships(): def test_folder_id_none_when_absent(): e = Envelope({"id": UUID, "type": "envelopes", "attributes": {}, "relationships": {}}) assert e.folder_id is None + + +# ── nested list / activate / notify ─────────────────────────────────────── + + +def test_list_documents(): + captured: dict[str, Any] = {} + doc = {"id": "d1", "type": "documents", "attributes": {}, "relationships": {}} + with mock_urlopen(make_response(200, {"data": [doc]}), capture=captured): + items = Envelope.list_documents(ENV_ID) + assert len(items) == 1 + assert f"/envelopes/{ENV_ID}/documents" in captured["url"] + + +def test_list_signers(): + with mock_urlopen(make_response(200, {"data": []})): + Envelope.list_signers(ENV_ID) + + +def test_list_requirements_with_filter(): + captured: dict[str, Any] = {} + with mock_urlopen(make_response(200, {"data": []}), capture=captured): + Envelope.list_requirements(ENV_ID, document_id="d1") + url = captured["url"] + assert "filter%5Bdocument_id%5D=d1" in url or "filter[document_id]=d1" in url + + +def test_list_signature_watchers(): + captured: dict[str, Any] = {} + with mock_urlopen(make_response(200, {"data": []}), capture=captured): + Envelope.list_signature_watchers(ENV_ID) + assert f"/envelopes/{ENV_ID}/signature_watchers" in captured["url"] + + +def test_activate_classmethod(): + with mock_urlopen(make_response(200, {"data": envelope_data(status="running")})): + e = Envelope.activate(ENV_ID) + assert e.status == "running" + + +def test_notify_with_message_and_subject(): + captured: dict[str, Any] = {} + e = Envelope(envelope_data()) + with mock_urlopen(make_response(204, None), capture=captured): + e.notify(message="Hello", subject="Subject") + assert captured["method"] == "POST" + assert captured["url"].endswith(f"/envelopes/{UUID}/notifications") + assert captured["body"]["data"]["attributes"]["message"] == "Hello" + assert captured["body"]["data"]["attributes"]["subject"] == "Subject" diff --git a/tests/clicksign/resources/notarial/test_requirement.py b/tests/clicksign/resources/notarial/test_requirement.py index 31edb32..76775a9 100644 --- a/tests/clicksign/resources/notarial/test_requirement.py +++ b/tests/clicksign/resources/notarial/test_requirement.py @@ -1,3 +1,5 @@ +from typing import Any + import pytest from clicksign.errors import NotFoundError, ValidationError @@ -87,3 +89,54 @@ def test_relationship_accessors(): assert r.envelope_id == ENV_ID assert r.document_id == DOC_ID assert r.signer_id == SIG_ID + + +def test_create_with_signer_id_and_document_id(): + captured: dict[str, Any] = {} + with mock_urlopen(make_response(201, {"data": req_data()}), capture=captured): + r = Requirement.create( + ENV_ID, signer_id=SIG_ID, document_id=DOC_ID, action="agree", role="sign" + ) + assert r.id == UUID + assert r._parent_id == ENV_ID + rels = captured["body"]["data"]["relationships"] + assert rels["signer"]["data"]["id"] == SIG_ID + assert rels["document"]["data"]["id"] == DOC_ID + + +def test_list_for_document(): + payload = {"data": [req_data()], "links": {}} + captured: dict[str, Any] = {} + with mock_urlopen(make_response(200, payload), capture=captured): + results = Requirement.list_for_document(DOC_ID) + assert len(results) == 1 + assert results[0].id == UUID + assert f"/documents/{DOC_ID}/relationships/requirements" in captured["url"] + + +def test_list_for_signer(): + payload = {"data": [req_data()], "links": {}} + captured: dict[str, Any] = {} + with mock_urlopen(make_response(200, payload), capture=captured): + results = Requirement.list_for_signer(SIG_ID) + assert len(results) == 1 + assert results[0].id == UUID + assert f"/signers/{SIG_ID}/relationships/requirements" in captured["url"] + + +def test_list_for_document_with_filters(): + payload = {"data": [], "links": {}} + captured: dict[str, Any] = {} + with mock_urlopen(make_response(200, payload), capture=captured): + Requirement.list_for_document(DOC_ID, action="agree") + url = captured["url"] + assert "filter%5Baction%5D=agree" in url or "filter[action]=agree" in url + + +def test_list_for_signer_with_filters(): + payload = {"data": [], "links": {}} + captured: dict[str, Any] = {} + with mock_urlopen(make_response(200, payload), capture=captured): + Requirement.list_for_signer(SIG_ID, action="agree") + url = captured["url"] + assert "filter%5Baction%5D=agree" in url or "filter[action]=agree" in url diff --git a/tests/clicksign/resources/notarial/test_signer.py b/tests/clicksign/resources/notarial/test_signer.py index ae49a44..eb64046 100644 --- a/tests/clicksign/resources/notarial/test_signer.py +++ b/tests/clicksign/resources/notarial/test_signer.py @@ -60,3 +60,13 @@ def test_create_422(): def test_envelope_id_from_relationships(): s = Signer(signer_data()) assert s.envelope_id == ENV_ID + + +def test_notify_requires_envelope_id_and_signer_id(): + captured: dict[str, object] = {} + with mock_urlopen(make_response(204, None), capture=captured): + Signer.notify(ENV_ID, UUID, message="Reminder", subject="Sign") + assert captured["method"] == "POST" + assert captured["url"].endswith(f"/envelopes/{ENV_ID}/signers/{UUID}/notifications") + assert captured["body"]["data"]["attributes"]["message"] == "Reminder" + assert captured["body"]["data"]["attributes"]["subject"] == "Sign" diff --git a/tests/clicksign/test_async_client.py b/tests/clicksign/test_async_client.py index 7d1732e..ccc28e3 100644 --- a/tests/clicksign/test_async_client.py +++ b/tests/clicksign/test_async_client.py @@ -1,4 +1,5 @@ import json +from unittest.mock import AsyncMock, patch import pytest @@ -67,6 +68,31 @@ async def test_retries_on_timeout(): assert len(fake.calls) == 2 +@pytest.mark.asyncio +async def test_retries_on_429_and_succeeds(): + from tests.support.fake_http_client import http_error + + fake = FakeAsyncHTTPClient( + http_error(429, ""), + http_response(200, {"data": []}), + ) + with patch("clicksign._async.http_executor.asyncio.sleep", new_callable=AsyncMock): + with patch("clicksign.retry_backoff.delay", return_value=0): + await make_client(max_retries=1, http_client=fake).get("/envelopes") + assert len(fake.calls) == 2 + + +@pytest.mark.asyncio +async def test_does_not_retry_on_422(): + from clicksign.errors import ValidationError + from tests.support.fake_http_client import http_error + + fake = FakeAsyncHTTPClient(http_error(422, {"errors": [{"detail": "bad"}]})) + with pytest.raises(ValidationError): + await make_client(max_retries=2, http_client=fake).post("/envelopes", {}) + assert len(fake.calls) == 1 + + @pytest.mark.asyncio async def test_aclose_calls_http_client(): fake = FakeAsyncHTTPClient(http_response(200, {"data": []})) diff --git a/tests/clicksign/test_clicksign_client.py b/tests/clicksign/test_clicksign_client.py index 9e60a39..d8daffb 100644 --- a/tests/clicksign/test_clicksign_client.py +++ b/tests/clicksign/test_clicksign_client.py @@ -173,3 +173,54 @@ def test_deserialize_on_facade(client: ClicksignClient): envelope = client.deserialize(raw, Envelope) assert envelope.id == UUID assert envelope.last_response is not None + + +def test_facade_documents_create_uses_envelope_id_in_path(client: ClicksignClient): + from tests.support.json_api_fixtures import document_response + + captured: dict[str, Any] = {} + with mock_urlopen(make_response(201, document_response()), capture=captured): + client.notarial.documents.create( + ENV_ID, + filename="a.pdf", + content_base64="data:application/pdf;base64,abc", + ) + assert captured["method"] == "POST" + assert f"/envelopes/{ENV_ID}/documents" in captured["url"] + + +def test_facade_signers_create_uses_envelope_id_in_path(client: ClicksignClient): + from tests.support.json_api_fixtures import signer_response + + captured: dict[str, Any] = {} + with mock_urlopen(make_response(201, signer_response()), capture=captured): + client.notarial.signers.create(ENV_ID, name="Alice", email="alice@example.com") + assert captured["method"] == "POST" + assert f"/envelopes/{ENV_ID}/signers" in captured["url"] + + +def test_facade_signers_notify(client: ClicksignClient): + captured: dict[str, Any] = {} + with mock_urlopen(make_response(201, {}), capture=captured): + client.notarial.signers.notify(ENV_ID, SIG_ID, message="Assine o documento") + assert captured["method"] == "POST" + assert f"/envelopes/{ENV_ID}/signers/{SIG_ID}/notifications" in captured["url"] + + +def test_facade_envelopes_notify(client: ClicksignClient): + captured: dict[str, Any] = {} + with mock_urlopen( + make_response(200, envelope_response()), + make_response(204, None), + capture=captured, + ): + client.notarial.envelopes.retrieve(UUID).notify(message="Todos assinem") + assert captured["method"] == "POST" + assert captured["url"].endswith(f"/envelopes/{UUID}/notifications") + + +def test_facade_signers_notify_with_subject(client: ClicksignClient): + captured: dict[str, Any] = {} + with mock_urlopen(make_response(201, {}), capture=captured): + client.notarial.signers.notify(ENV_ID, SIG_ID, message="Assine", subject="Ação necessária") + assert captured["body"]["data"]["attributes"]["subject"] == "Ação necessária" From 3589636966a6d6baa9cc640914f261b6ba0a0b5e Mon Sep 17 00:00:00 2001 From: Danilo Josino Date: Thu, 21 May 2026 13:24:52 -0300 Subject: [PATCH 5/8] =?UTF-8?q?test:=20adicionar=20m=C3=B3dulos=20de=20tes?= =?UTF-8?q?te=20para=20bound=5Fresource,=20transport,=20json=5Fapi=20e=20t?= =?UTF-8?q?ypes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Novos arquivos cobrem BoundResource, AsyncBoundResource, HttpxHTTPClient, HttpxAsyncHTTPClient, http_executor, operações atômicas JSON:API e atributos de tipos TypedDict — áreas sem cobertura até agora. Co-Authored-By: Claude Sonnet 4.6 --- tests/clicksign/test_async_bound_resource.py | 77 ++++++++ tests/clicksign/test_async_http_executor.py | 110 ++++++++++++ tests/clicksign/test_bound_resource.py | 88 +++++++++ tests/clicksign/test_httpx_async_transport.py | 167 ++++++++++++++++++ tests/clicksign/test_httpx_transport.py | 113 ++++++++++++ tests/clicksign/test_json_api_operations.py | 70 ++++++++ tests/clicksign/test_types_attrs.py | 43 +++++ 7 files changed, 668 insertions(+) create mode 100644 tests/clicksign/test_async_bound_resource.py create mode 100644 tests/clicksign/test_async_http_executor.py create mode 100644 tests/clicksign/test_bound_resource.py create mode 100644 tests/clicksign/test_httpx_async_transport.py create mode 100644 tests/clicksign/test_httpx_transport.py create mode 100644 tests/clicksign/test_json_api_operations.py create mode 100644 tests/clicksign/test_types_attrs.py diff --git a/tests/clicksign/test_async_bound_resource.py b/tests/clicksign/test_async_bound_resource.py new file mode 100644 index 0000000..4ec8dfd --- /dev/null +++ b/tests/clicksign/test_async_bound_resource.py @@ -0,0 +1,77 @@ +import pytest + +from clicksign._async.clicksign_client import AsyncClicksignClient +from tests.support.fake_async_http_client import FakeAsyncHTTPClient +from tests.support.fake_http_client import http_response +from tests.support.json_api_fixtures import UUID, UUID2, collection + +BASE = "http://test.clicksign.com/api/v3" + + +@pytest.fixture +def fake() -> FakeAsyncHTTPClient: + return FakeAsyncHTTPClient() + + +@pytest.fixture +def client(fake: FakeAsyncHTTPClient) -> AsyncClicksignClient: + return AsyncClicksignClient(api_key="key", base_url=BASE, max_retries=0, http_client=fake) + + +@pytest.mark.asyncio +async def test_async_bound_filter_chain_first( + client: AsyncClicksignClient, fake: FakeAsyncHTTPClient +): + fake._queue = [http_response(200, collection("envelopes"))] + item = await ( + client.notarial.envelopes.filter(status="draft") + .page(2) + .per(15) + .order("name") + .first() + ) + assert item is not None + url = fake.calls[0]["url"] + assert "filter%5Bstatus%5D=draft" in url or "filter[status]=draft" in url + assert "page%5Bnumber%5D=2" in url or "page[number]=2" in url + assert "sort=name" in url + + +@pytest.mark.asyncio +async def test_async_bound_with_includes_fields_first( + client: AsyncClicksignClient, fake: FakeAsyncHTTPClient +): + fake._queue = [http_response(200, collection("envelopes"))] + item = await client.envelopes.with_includes("folder").fields(envelopes=["name"]).first() + assert item is not None + url = fake.calls[0]["url"] + assert "include=folder" in url + + +@pytest.mark.asyncio +async def test_async_bound_count(client: AsyncClicksignClient, fake: FakeAsyncHTTPClient): + body = collection("envelopes", items=[{"id": UUID}, {"id": UUID2}]) + fake._queue = [http_response(200, body)] + count = await client.envelopes.filter(status="draft").count() + assert count == 2 + + +@pytest.mark.asyncio +async def test_async_bound_page_per_entry_points( + client: AsyncClicksignClient, fake: FakeAsyncHTTPClient +): + fake._queue = [http_response(200, collection("envelopes"))] + await client.envelopes.page(1).per(5).to_list() + url = fake.calls[0]["url"] + assert "page%5Bsize%5D=5" in url or "page[size]=5" in url + + +@pytest.mark.asyncio +async def test_async_bound_last_and_page_responses( + client: AsyncClicksignClient, fake: FakeAsyncHTTPClient +): + fake._queue = [http_response(200, collection("envelopes"))] + proxy = client.envelopes.filter(status="draft") + last = await proxy.last() + assert last is not None + assert proxy.page_responses diff --git a/tests/clicksign/test_async_http_executor.py b/tests/clicksign/test_async_http_executor.py new file mode 100644 index 0000000..3bbe242 --- /dev/null +++ b/tests/clicksign/test_async_http_executor.py @@ -0,0 +1,110 @@ +from unittest.mock import AsyncMock, patch + +import pytest + +from clicksign._async.http_executor import execute_async_http_request +from clicksign.errors import ValidationError +from clicksign.instrumentation import Instrumentation +from clicksign.request_instrumentation import RequestInstrumentation +from tests.support.fake_async_http_client import FakeAsyncHTTPClient +from tests.support.fake_http_client import http_error, http_response + + +class _Publisher(RequestInstrumentation): + pass + + +@pytest.mark.asyncio +async def test_execute_async_success(): + fake = FakeAsyncHTTPClient(http_response(200, {"ok": True})) + result = await execute_async_http_request( + http_client=fake, + method="GET", + url="http://test/api/v3/envelopes", + path="/envelopes", + headers={}, + body=None, + open_timeout=1.0, + read_timeout=2.0, + write_timeout=3.0, + max_retries=0, + instrumentation=Instrumentation(), + logger=None, + publish=_Publisher(), + ) + assert result.data == {"ok": True} + + +@pytest.mark.asyncio +async def test_execute_async_retries_on_429(): + fake = FakeAsyncHTTPClient( + http_error(429, ""), + http_response(200, {"data": []}), + ) + with patch("clicksign._async.http_executor.asyncio.sleep", new_callable=AsyncMock): + with patch("clicksign.retry_backoff.delay", return_value=0): + result = await execute_async_http_request( + http_client=fake, + method="GET", + url="http://test/api/v3/envelopes", + path="/envelopes", + headers={}, + body=None, + open_timeout=1.0, + read_timeout=2.0, + write_timeout=3.0, + max_retries=1, + instrumentation=Instrumentation(), + logger=None, + publish=_Publisher(), + ) + assert result.data == {"data": []} + assert len(fake.calls) == 2 + + +@pytest.mark.asyncio +async def test_execute_async_does_not_retry_on_422(): + fake = FakeAsyncHTTPClient(http_error(422, {"errors": [{"detail": "bad"}]})) + with pytest.raises(ValidationError): + await execute_async_http_request( + http_client=fake, + method="POST", + url="http://test/api/v3/envelopes", + path="/envelopes", + headers={}, + body=b"{}", + open_timeout=1.0, + read_timeout=2.0, + write_timeout=3.0, + max_retries=2, + instrumentation=Instrumentation(), + logger=None, + publish=_Publisher(), + ) + assert len(fake.calls) == 1 + + +@pytest.mark.asyncio +async def test_execute_async_retries_on_timeout(): + fake = FakeAsyncHTTPClient( + TimeoutError("timed out"), + http_response(200, {"data": []}), + ) + with patch("clicksign._async.http_executor.asyncio.sleep", new_callable=AsyncMock): + with patch("clicksign.retry_backoff.delay", return_value=0): + await execute_async_http_request( + http_client=fake, + method="GET", + url="http://test/api/v3/envelopes", + path="/envelopes", + headers={}, + body=None, + open_timeout=1.0, + read_timeout=2.0, + write_timeout=3.0, + max_retries=1, + instrumentation=Instrumentation(), + logger=None, + publish=_Publisher(), + ) + assert len(fake.calls) == 2 diff --git a/tests/clicksign/test_bound_resource.py b/tests/clicksign/test_bound_resource.py new file mode 100644 index 0000000..4dea861 --- /dev/null +++ b/tests/clicksign/test_bound_resource.py @@ -0,0 +1,88 @@ +from typing import Any + +import pytest + +from clicksign.clicksign_client import ClicksignClient +from tests.support.http_mock import make_response, mock_urlopen +from tests.support.json_api_fixtures import UUID, UUID2, collection + +BASE = "http://test.clicksign.com/api/v3" + + +@pytest.fixture +def client() -> ClicksignClient: + return ClicksignClient(api_key="key", base_url=BASE) + + +def test_bound_query_proxy_with_includes_and_fields(client: ClicksignClient): + captured: dict[str, Any] = {} + with mock_urlopen(make_response(200, collection("envelopes")), capture=captured): + client.envelopes.with_includes("folder").fields(envelopes=["name"]).to_list() + url = captured["url"] + assert "include=folder" in url + assert "fields%5Benvelopes%5D" in url or "fields[envelopes]" in url + + +def test_bound_query_proxy_count(client: ClicksignClient): + body = collection("envelopes", items=[{"id": UUID}, {"id": UUID2}]) + with mock_urlopen(make_response(200, body)): + count = client.envelopes.filter(status="draft").count() + assert count == 2 + + +def test_bound_query_proxy_last(client: ClicksignClient): + items = [ + {"id": "11111111-1111-1111-1111-111111111111", "attributes": {"name": "A"}}, + {"id": UUID, "attributes": {"name": "B"}}, + ] + with mock_urlopen(make_response(200, collection("envelopes", items=items))): + last = client.envelopes.filter(status="draft").last() + assert last is not None + assert last.id == UUID + + +def test_bound_query_proxy_iteration(client: ClicksignClient): + with mock_urlopen(make_response(200, collection("envelopes"))): + ids = [e.id for e in client.envelopes.filter(status="draft")] + assert len(ids) == 1 + + +def test_bound_resource_order_entry_point(client: ClicksignClient): + captured: dict[str, Any] = {} + with mock_urlopen(make_response(200, collection("envelopes")), capture=captured): + client.envelopes.order("-created_at").first() + assert "sort=-created_at" in captured["url"] or "sort%3D-created_at" in captured["url"] + + +def test_bound_resource_page_per_entry_points(client: ClicksignClient): + captured: dict[str, Any] = {} + with mock_urlopen(make_response(200, collection("envelopes")), capture=captured): + client.envelopes.page(3).per(25).first() + params = captured["url"].split("?", 1)[1] + assert "page%5Bnumber%5D=3" in params or "page[number]=3" in params + assert "page%5Bsize%5D=25" in params or "page[size]=25" in params + + +def test_bound_classmethod_activate(client: ClicksignClient): + from tests.support.json_api_fixtures import envelope_response + + body = envelope_response() + body["data"]["attributes"]["status"] = "running" + captured: dict[str, Any] = {} + with mock_urlopen(make_response(200, body), capture=captured): + client.notarial.envelopes.activate(UUID) + assert captured["method"] == "POST" + assert captured["url"].endswith(f"/envelopes/{UUID}/activate") + + +def test_bound_on_page_callback(client: ClicksignClient): + pages: list[int] = [] + + def on_page(page: int, _meta: object, _items: object) -> None: + pages.append(page) + + body = collection("envelopes") + body["links"] = {"next": None} + with mock_urlopen(make_response(200, body)): + client.envelopes.filter(status="draft").on_page(on_page).to_list() + assert pages == [1] diff --git a/tests/clicksign/test_httpx_async_transport.py b/tests/clicksign/test_httpx_async_transport.py new file mode 100644 index 0000000..d5b076c --- /dev/null +++ b/tests/clicksign/test_httpx_async_transport.py @@ -0,0 +1,167 @@ +import sys +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from clicksign._http.transport import HTTPStatusError, HttpxAsyncHTTPClient + + +def test_httpx_async_client_requires_dependency(): + import builtins + + real_import = builtins.__import__ + + def fake_import(name: str, *args: object, **kwargs: object): + if name == "httpx": + raise ImportError("No module named 'httpx'") + return real_import(name, *args, **kwargs) + + with patch("builtins.__import__", side_effect=fake_import): + with pytest.raises(ImportError, match="httpx"): + HttpxAsyncHTTPClient() + + +@pytest.mark.asyncio +async def test_httpx_async_client_delegates_to_injected_client(): + mock_httpx = AsyncMock() + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = '{"data":[]}' + mock_response.headers = {} + mock_httpx.request.return_value = mock_response + + transport = HttpxAsyncHTTPClient(client=mock_httpx) + response = await transport.request( + "GET", + "https://app.clicksign.com/api/v3/envelopes", + headers={"Authorization": "token"}, + body=None, + open_timeout=1.0, + read_timeout=2.0, + write_timeout=3.0, + ) + + assert response.body == '{"data":[]}' + mock_httpx.request.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_httpx_async_raises_http_status_error(): + mock_httpx = AsyncMock() + mock_response = MagicMock() + mock_response.status_code = 422 + mock_response.text = '{"errors":[]}' + mock_response.headers = {} + mock_httpx.request.return_value = mock_response + + transport = HttpxAsyncHTTPClient(client=mock_httpx) + with pytest.raises(HTTPStatusError) as exc_info: + await transport.request( + "POST", + "https://example.com/api/v3/envelopes", + headers={}, + body=b"{}", + open_timeout=1.0, + read_timeout=2.0, + write_timeout=3.0, + ) + assert exc_info.value.status == 422 + + +@pytest.mark.asyncio +async def test_httpx_async_aclose_when_owns_client(): + mock_httpx = AsyncMock() + transport = HttpxAsyncHTTPClient(client=mock_httpx) + transport._owns_client = True # type: ignore[attr-defined] + await transport.aclose() + mock_httpx.aclose.assert_awaited_once() + + +def _httpx_named_exc(name: str, response: MagicMock | None = None, message: str = "") -> Exception: + exc_type = type(name, (Exception,), {}) + exc = exc_type(message) + if response is not None: + exc.response = response # type: ignore[attr-defined] + return exc + + +@pytest.mark.asyncio +async def test_httpx_async_owned_client_uses_timeout_and_raises_status(): + mock_httpx = MagicMock() + mock_httpx.Timeout = MagicMock(side_effect=lambda **kw: kw) + mock_client = AsyncMock() + mock_response = MagicMock() + mock_response.status_code = 503 + mock_response.text = "unavailable" + mock_response.headers = {} + mock_client.request.return_value = mock_response + + transport = HttpxAsyncHTTPClient(client=mock_client) + transport._owns_client = True # type: ignore[attr-defined] + + with patch.dict(sys.modules, {"httpx": mock_httpx}): + with pytest.raises(HTTPStatusError) as exc_info: + await transport.request( + "GET", + "https://example.com/api/v3/envelopes", + headers={}, + body=None, + open_timeout=1.0, + read_timeout=2.0, + write_timeout=3.0, + ) + assert exc_info.value.status == 503 + mock_httpx.Timeout.assert_called_once() + + +@pytest.mark.asyncio +async def test_httpx_async_maps_httpx_status_error_exception(): + mock_httpx = MagicMock() + mock_httpx.Timeout = MagicMock(side_effect=lambda **kw: kw) + mock_client = AsyncMock() + inner = MagicMock() + inner.status_code = 401 + inner.text = "unauthorized" + inner.headers = {} + mock_client.request.side_effect = _httpx_named_exc("HTTPStatusError", inner) + + transport = HttpxAsyncHTTPClient(client=mock_client) + transport._owns_client = True # type: ignore[attr-defined] + + with patch.dict(sys.modules, {"httpx": mock_httpx}): + with pytest.raises(HTTPStatusError) as exc_info: + await transport.request( + "GET", + "https://example.com/", + headers={}, + body=None, + open_timeout=1.0, + read_timeout=2.0, + write_timeout=3.0, + ) + assert exc_info.value.status == 401 + + +@pytest.mark.asyncio +async def test_httpx_async_maps_request_error(): + from clicksign._http.transport import HTTPConnectionError + + mock_httpx = MagicMock() + mock_httpx.Timeout = MagicMock(side_effect=lambda **kw: kw) + mock_client = AsyncMock() + mock_client.request.side_effect = _httpx_named_exc("RequestError", message="timeout") + + transport = HttpxAsyncHTTPClient(client=mock_client) + transport._owns_client = True # type: ignore[attr-defined] + + with patch.dict(sys.modules, {"httpx": mock_httpx}): + with pytest.raises(HTTPConnectionError, match="timeout"): + await transport.request( + "GET", + "https://example.com/", + headers={}, + body=None, + open_timeout=1.0, + read_timeout=2.0, + write_timeout=3.0, + ) diff --git a/tests/clicksign/test_httpx_transport.py b/tests/clicksign/test_httpx_transport.py new file mode 100644 index 0000000..f41f1ae --- /dev/null +++ b/tests/clicksign/test_httpx_transport.py @@ -0,0 +1,113 @@ +import sys +from unittest.mock import MagicMock, patch + +import pytest + +from clicksign._http.transport import HTTPConnectionError, HTTPStatusError, HttpxHTTPClient + + +def _httpx_named_exc(name: str, response: MagicMock | None = None, message: str = "") -> Exception: + exc_type = type(name, (Exception,), {}) + exc = exc_type(message) + if response is not None: + exc.response = response # type: ignore[attr-defined] + return exc + + +def test_httpx_owned_client_raises_on_status_code(): + mock_httpx = MagicMock() + mock_httpx.Timeout = MagicMock(side_effect=lambda **kw: kw) + mock_client = MagicMock() + mock_response = MagicMock() + mock_response.status_code = 500 + mock_response.text = "error" + mock_response.headers = {"X-Request-Id": "1"} + mock_client.request.return_value = mock_response + + transport = HttpxHTTPClient(client=mock_client) + transport._owns_client = True # type: ignore[attr-defined] + + with patch.dict(sys.modules, {"httpx": mock_httpx}): + with pytest.raises(HTTPStatusError) as exc_info: + transport.request( + "GET", + "https://example.com/api/v3/envelopes", + headers={}, + body=None, + open_timeout=1.0, + read_timeout=2.0, + write_timeout=3.0, + ) + assert exc_info.value.status == 500 + mock_httpx.Timeout.assert_called_once() + + +def test_httpx_owned_client_maps_httpx_status_error_exception(): + mock_httpx = MagicMock() + mock_httpx.Timeout = MagicMock(side_effect=lambda **kw: kw) + mock_client = MagicMock() + inner_response = MagicMock() + inner_response.status_code = 429 + inner_response.text = "rate limited" + inner_response.headers = {} + mock_client.request.side_effect = _httpx_named_exc("HTTPStatusError", inner_response) + + transport = HttpxHTTPClient(client=mock_client) + transport._owns_client = True # type: ignore[attr-defined] + + with patch.dict(sys.modules, {"httpx": mock_httpx}): + with pytest.raises(HTTPStatusError) as exc_info: + transport.request( + "GET", + "https://example.com/api/v3/x", + headers={}, + body=None, + open_timeout=1.0, + read_timeout=2.0, + write_timeout=3.0, + ) + assert exc_info.value.status == 429 + + +def test_httpx_owned_client_maps_request_error(): + mock_httpx = MagicMock() + mock_httpx.Timeout = MagicMock(side_effect=lambda **kw: kw) + mock_client = MagicMock() + mock_client.request.side_effect = _httpx_named_exc("RequestError", message="connection reset") + + transport = HttpxHTTPClient(client=mock_client) + transport._owns_client = True # type: ignore[attr-defined] + + with patch.dict(sys.modules, {"httpx": mock_httpx}): + with pytest.raises(HTTPConnectionError, match="connection reset"): + transport.request( + "POST", + "https://example.com/api/v3/envelopes", + headers={}, + body=b"{}", + open_timeout=1.0, + read_timeout=2.0, + write_timeout=3.0, + ) + + +def test_httpx_injected_client_uses_max_timeout(): + mock_client = MagicMock() + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = "{}" + mock_response.headers = {} + mock_client.request.return_value = mock_response + + transport = HttpxHTTPClient(client=mock_client) + transport.request( + "GET", + "https://example.com/", + headers={}, + body=None, + open_timeout=1.0, + read_timeout=5.0, + write_timeout=3.0, + ) + _args, kwargs = mock_client.request.call_args + assert kwargs["timeout"] == 5.0 diff --git a/tests/clicksign/test_json_api_operations.py b/tests/clicksign/test_json_api_operations.py new file mode 100644 index 0000000..f04eb7a --- /dev/null +++ b/tests/clicksign/test_json_api_operations.py @@ -0,0 +1,70 @@ +from clicksign.json_api.operations import BulkRequirementOperations + +SIG_ID = "cccccccc-cccc-cccc-cccc-cccccccccccc" +DOC_ID = "dddddddd-dddd-dddd-dddd-dddddddddddd" +REQ_ID = "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee" + + +def test_to_payload_empty(): + ops = BulkRequirementOperations() + assert ops.to_payload() == {"atomic:operations": []} + + +def test_add_agree(): + ops = BulkRequirementOperations() + ops.add_agree(signer_id=SIG_ID, document_id=DOC_ID, role="sign") + payload = ops.to_payload() + assert len(payload["atomic:operations"]) == 1 + op = payload["atomic:operations"][0] + assert op["op"] == "add" + assert op["data"]["attributes"] == {"action": "agree", "role": "sign"} + assert op["data"]["relationships"]["signer"]["data"]["id"] == SIG_ID + assert op["data"]["relationships"]["document"]["data"]["id"] == DOC_ID + + +def test_add_provide_evidence(): + ops = BulkRequirementOperations() + ops.add_provide_evidence(signer_id=SIG_ID, document_id=DOC_ID, auth="email") + op = ops.to_payload()["atomic:operations"][0] + assert op["data"]["attributes"] == {"action": "provide_evidence", "auth": "email"} + + +def test_add_rubricate_minimal(): + ops = BulkRequirementOperations() + ops.add_rubricate(signer_id=SIG_ID, document_id=DOC_ID) + attrs = ops.to_payload()["atomic:operations"][0]["data"]["attributes"] + assert attrs == {"action": "rubricate"} + + +def test_add_rubricate_with_optional_attrs(): + ops = BulkRequirementOperations() + ops.add_rubricate( + signer_id=SIG_ID, + document_id=DOC_ID, + pages="1-3", + rubric_field="signature", + kind="initial", + ) + attrs = ops.to_payload()["atomic:operations"][0]["data"]["attributes"] + assert attrs == { + "action": "rubricate", + "pages": "1-3", + "rubric_field": "signature", + "kind": "initial", + } + + +def test_remove(): + ops = BulkRequirementOperations() + ops.remove(requirement_id=REQ_ID) + op = ops.to_payload()["atomic:operations"][0] + assert op == {"op": "remove", "ref": {"type": "requirements", "id": REQ_ID}} + + +def test_multiple_operations_order(): + ops = BulkRequirementOperations() + ops.add_agree(signer_id=SIG_ID, document_id=DOC_ID, role="sign") + ops.add_provide_evidence(signer_id=SIG_ID, document_id=DOC_ID, auth="email") + ops.remove(requirement_id=REQ_ID) + names = [o["op"] for o in ops.to_payload()["atomic:operations"]] + assert names == ["add", "add", "remove"] diff --git a/tests/clicksign/test_types_attrs.py b/tests/clicksign/test_types_attrs.py new file mode 100644 index 0000000..e651f3f --- /dev/null +++ b/tests/clicksign/test_types_attrs.py @@ -0,0 +1,43 @@ +from clicksign.resources.notarial.envelope import Envelope +from clicksign.types._attrs import bool_attr, dict_attr, int_attr, list_str_attr, str_attr + +UUID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + + +def _envelope(**attrs: object) -> Envelope: + return Envelope( + { + "id": UUID, + "type": "envelopes", + "attributes": attrs, + "relationships": {}, + } + ) + + +def test_str_attr_returns_string(): + assert str_attr(_envelope(name="X"), "name") == "X" + + +def test_str_attr_rejects_non_string(): + assert str_attr(_envelope(name=123), "name") is None + + +def test_bool_attr(): + assert bool_attr(_envelope(auto_close=True), "auto_close") is True + assert bool_attr(_envelope(auto_close="yes"), "auto_close") is None + + +def test_int_attr(): + assert int_attr(_envelope(remind_interval=7), "remind_interval") == 7 + assert int_attr(_envelope(remind_interval=True), "remind_interval") is None + + +def test_list_str_attr(): + assert list_str_attr(_envelope(tags=["a", "b"]), "tags") == ["a", "b"] + assert list_str_attr(_envelope(tags=["a", 2]), "tags") == ["a"] + + +def test_dict_attr(): + assert dict_attr(_envelope(meta={"k": "v"}), "meta") == {"k": "v"} + assert dict_attr(_envelope(meta="x"), "meta") is None From 38fc97ab6e51294d9ee21bd5a4503388135df0f0 Mon Sep 17 00:00:00 2001 From: Danilo Josino Date: Thu, 21 May 2026 13:25:05 -0300 Subject: [PATCH 6/8] =?UTF-8?q?ci:=20adicionar=20ruff=20format=20--check?= =?UTF-8?q?=20e=20cobertura=20m=C3=ADnima=2088%=20no=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ruff format --check garante formatação consistente no pipeline - pytest --cov-fail-under=88 evita regressão de cobertura Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c754153..bab18f2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,10 +21,12 @@ jobs: python -m pip install --upgrade pip pip install -e ".[dev]" - name: Ruff - run: ruff check src tests + run: | + ruff check src tests + ruff format --check src tests - name: Mypy (typed resources) run: mypy - name: Pytest - run: pytest -q + run: pytest -q --cov=clicksign --cov-fail-under=88 env: PYTHONPATH: src From 2c8d021accf05eedc13faaf838558bf182168369 Mon Sep 17 00:00:00 2001 From: Danilo Josino Date: Thu, 21 May 2026 13:25:20 -0300 Subject: [PATCH 7/8] =?UTF-8?q?docs:=20atualizar=20CHANGELOG,=20README=20e?= =?UTF-8?q?=20guias=20t=C3=A9cnicos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CHANGELOG registra fixes de Signer.notify, QueryBuilder, tipos e CI - README reflete assinatura nova de Signer.notify - Guias (PAGINATION, TYPES, WORKFLOW, SDK_CONTRACT, etc.) alinhados com as mudanças de API desta branch - Adiciona docs/CONSIDERACOES.md e exemplo 13-async-fastapi.md Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 30 ++++++- README.md | 25 +++++- docs/CONSIDERACOES.md | 48 ++++++++++ docs/PAGINATION.md | 35 +++++++- docs/README.md | 1 + docs/SDK_CONTRACT.md | 2 + docs/SDK_TEST_MATRIX.md | 24 ++++- docs/SPEC.md | 10 +++ docs/TROUBLESHOOTING.md | 30 +++++++ docs/TYPES.md | 19 +++- docs/WORKFLOW.md | 8 +- docs/examples/04-multi-client.md | 2 +- docs/examples/07-list-and-filter.md | 15 +++- docs/examples/13-async-fastapi.md | 134 ++++++++++++++++++++++++++++ docs/examples/README.md | 1 + 15 files changed, 369 insertions(+), 15 deletions(-) create mode 100644 docs/CONSIDERACOES.md create mode 100644 docs/examples/13-async-fastapi.md diff --git a/CHANGELOG.md b/CHANGELOG.md index c447f7d..3744b29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,32 @@ e este projeto adere ao [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +--- + +## [0.1.0] - 2026-05-21 + +Primeira release do SDK Python para a API Clicksign v3 (JSON:API). Versão alinhada a `REVISION`. + ### Adicionado -- Estrutura inicial do projeto -- `docs/SDK_CONTRACT.md` — especificação comportamental agnóstica de linguagem -- `docs/SDK_TEST_MATRIX.md` — matriz de cobertura de testes obrigatória +- SDK Python para API v3: resources notariais (`Envelope`, `Document`, `Signer`, `Requirement`, `BulkRequirement`, `SignatureWatcher`, eventos aninhados) e administrativos (`Webhook`, `Folder`, `User`, `Template`, `TemplateField`, `Membership`, `Group`, e parciais) +- `configure()` + resources, `ClicksignClient` / `AsyncClicksignClient`, `Services`, webhooks HMAC (`construct_event`, `verify_signature`) +- HTTP stdlib (`UrllibHTTPClient`) e opcional `httpx` / `HttpxAsyncHTTPClient` para sync e async +- Retry com jitter, header `Retry-After`, mapeamento de erros JSON:API, `RequestOptions`, correlation id +- Paginação via `QueryProxy`, `with_includes`, bulk atômico (`BulkRequirement`) +- TypedDicts em `clicksign.types` para resources principais +- Documentação em `docs/` (contrato, workflow, spec, troubleshooting, 11 receitas) +- Suíte de testes com mocks HTTP (483+ testes), CI com ruff, mypy e cobertura mínima 88% + +### Alterado + +- N/A (release inicial) + +### Corrigido + +- N/A (release inicial) + +### Notas de breaking (desde pré-release interna) + +- `Signer.notify(envelope_id, signer_id, message, ...)` — classmethod com rota aninhada; não há `POST /signers/:id/notifications` +- Eventos apenas em rotas aninhadas (`Envelope.list_events`, `Document.list_events`, `Event.create_for_document`) diff --git a/README.md b/README.md index 64054e1..b68f12c 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Cliente Python para a [Clicksign API v3](https://developers.clicksign.com/) (JSO - [Multi-conta](#multi-conta) - [Timeouts, retry e instrumentação](#timeouts-retry-e-instrumentação) - [Início rápido](#início-rápido) -- [Fluxo de assinatura (notarial)](#fluxo-de-assinatura-notarial) +- [Fluxo de assinatura (notarial)](#fluxo-de-assinatura-notarial) (criar → ativar → notificar → eventos) - [Filtros, ordenação e paginação](#filtros-ordenação-e-paginação) - [Outros recursos](#outros-recursos) - [Tratamento de erros](#tratamento-de-erros) @@ -274,9 +274,24 @@ client.notarial.bulk_requirements.create( ```python envelope.update(status="running") +# ou: Envelope.activate(envelope.id) — POST /envelopes/:id/activate ``` -### 6. Monitorar eventos +### 6. Notificar signatários + +```python +from clicksign.resources.notarial.signer import Signer + +# Um signatário (envelope_id e signer_id obrigatórios) +Signer.notify(envelope.id, signer.id, message="Seu contrato está disponível para assinatura.") + +# Todos os pendentes do envelope +envelope.notify(message="Seu contrato está disponível para assinatura.") +``` + +Detalhes: [`docs/WORKFLOW.md`](docs/WORKFLOW.md) (seção 6). + +### 7. Monitorar eventos ```python from clicksign.resources.notarial.envelope import Envelope @@ -428,6 +443,8 @@ client = ClicksignClient(api_key="...", environment="production") ## Async (FastAPI, asyncio) +Requer `pip install clicksign[async]`. Receita completa: [`docs/examples/13-async-fastapi.md`](docs/examples/13-async-fastapi.md). + ```python import asyncio from clicksign import AsyncClicksignClient @@ -439,11 +456,13 @@ async def main(): print(env.id) envelope = await client.envelopes.retrieve("uuid") await envelope.update_async(status="running") + # Ativar: await client.notarial.envelopes.activate(envelope.id) asyncio.run(main()) ``` -Não use `Services.use()` dentro do asyncio — passe `AsyncClicksignClient` explícito por coroutine. +- Não use `Services.use()` no asyncio — use `AsyncClicksignClient` explícito. +- Paginação: `.page(n)` só vale com `.first()`; `.to_list()` auto-pagina desde a página 1 — [`docs/PAGINATION.md`](docs/PAGINATION.md). --- diff --git a/docs/CONSIDERACOES.md b/docs/CONSIDERACOES.md new file mode 100644 index 0000000..bf14c4b --- /dev/null +++ b/docs/CONSIDERACOES.md @@ -0,0 +1,48 @@ +# Considerações — Python SDK (uso interno) + +> **Não linkar** em README nem em `docs/README.md`. Backlog da equipe; matriz de testes em [`SDK_TEST_MATRIX.md`](SDK_TEST_MATRIX.md). + +**Última revisão:** maio/2026 (6ª passagem — refinamento da doc pública). + +--- + +## Snapshot + +| | | +|---|---| +| Testes | **517** · cobertura **93%** · CI OK | +| Qualidade | ruff + mypy | +| Doc pública | Refinada (async, paginação, listagens, imports) | +| Crítico | **Nada** | + +```bash +pytest -q && pytest --cov=clicksign --cov-fail-under=88 -q +ruff check src tests && ruff format --check src tests && mypy +``` + +--- + +## Backlog aberto + +| Item | Prioridade | +|------|------------| +| Tag git `v0.1.0` ao publicar PyPI | Release | +| `resource_type_spec.json` vs API | P2 | +| TypedDicts admin restantes (`AccessControlList`, …) | P2 | +| CI: install mínimo sem `[dev]` | P3 | +| Cobertura admin (`webhook` resource ~76%) | P3 | +| Smoke E2E sandbox | Manual | + +--- + +## Comportamento a não esquecer + +- `Signer.notify(envelope_id, signer_id, ...)` — classmethod. +- Eventos só aninhados; facade `create` com envelope id posicional. +- `page(n)` + `.to_list()` → auto-pagina desde p.1; use `.first()` para página fixa. + +--- + +## Referência (público) + +`SDK_CONTRACT` · `WORKFLOW` · `SPEC` · `PAGINATION` · `TROUBLESHOOTING` · `examples/` diff --git a/docs/PAGINATION.md b/docs/PAGINATION.md index 8f95e3c..3220d62 100644 --- a/docs/PAGINATION.md +++ b/docs/PAGINATION.md @@ -17,6 +17,26 @@ Envelope.filter(status="draft").per(50).to_list() # .per(100) levanta ValueError ``` +## `page(n)` — página explícita vs auto-paginação + +| Terminal | `page[number]` na chain | +|----------|-------------------------| +| `.first()` | **Respeitado** — uma única requisição com os params da chain | +| `.to_list()`, `for ... in proxy`, `.count()`, `.last()` | **Ignorado** na auto-paginação — o SDK começa em página **1** e avança até não haver `links.next` | + +```python +# Uma página fixa (ex.: página 3, 25 itens) — use terminal que não auto-pagina tudo +proxy = Envelope.filter(status="draft").page(3).per(25) +first_item = proxy.first() # GET com page[number]=3 +# proxy.to_list() # evite: percorre TODAS as páginas desde a 1 + +# Todas as páginas da collection filtrada +for envelope in Envelope.filter(status="draft").per(25): + ... +``` + +Async: mesmo contrato em `AsyncQueryProxy` (`await ...first()`, `async for ...`). + ## Auto-paginação `filter(...).to_list()`, `for item in filter(...):`, `.count()` e `.last()` percorrem todas as páginas automaticamente. @@ -53,9 +73,22 @@ Envelope.filter(status="draft").on_page(log_page).to_list() Útil para progresso, métricas ou integração com `instrumentation.on_request` sem duplicar lógica de paginação. +## Listagens aninhadas (não são `filter` na raiz) + +Rotas como `GET /envelopes/:id/signers` **não** usam `QueryProxy` da collection raiz. O SDK expõe métodos de classe equivalentes: + +| Conteúdo | Método A | Método B (mesma rota HTTP) | +|----------|----------|----------------------------| +| Signatários do envelope | `Signer.list_for_envelope(envelope_id)` | `Envelope.list_signers(envelope_id)` | +| Documentos do envelope | `Document.list_for_envelope(envelope_id)` | `Envelope.list_documents(envelope_id)` | +| Requisitos do envelope | — | `Envelope.list_requirements(envelope_id, **filters)` | + +Filtros em `Envelope.list_requirements` usam `QueryBuilder` (`document_id=...`, etc.). +`Requirement.list_for_document` / `list_for_signer` listam via rotas de relacionamento (`/documents/:id/relationships/requirements`). + ## Async -`AsyncClicksignClient` expõe o mesmo comportamento em `AsyncQueryProxy` (`async for`, `on_page`, `page_responses`). +`AsyncClicksignClient` expõe o mesmo comportamento em `AsyncQueryProxy` (`async for`, `on_page`, `page_responses`). Receita: [`examples/13-async-fastapi.md`](examples/13-async-fastapi.md). ## Referências diff --git a/docs/README.md b/docs/README.md index b16a069..c2e196f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -49,6 +49,7 @@ | [`examples/10-observability-opentelemetry.md`](examples/10-observability-opentelemetry.md) | OpenTelemetry manual | | [`examples/11-observability-metrics.md`](examples/11-observability-metrics.md) | Prometheus / StatsD | | [`examples/12-http-connection-pool.md`](examples/12-http-connection-pool.md) | `HttpxHTTPClient` singleton | +| [`examples/13-async-fastapi.md`](examples/13-async-fastapi.md) | FastAPI, lifespan, fluxo notarial async | Índice do examples: [`examples/README.md`](examples/README.md). diff --git a/docs/SDK_CONTRACT.md b/docs/SDK_CONTRACT.md index 8a31f60..21208ea 100644 --- a/docs/SDK_CONTRACT.md +++ b/docs/SDK_CONTRACT.md @@ -171,6 +171,8 @@ fetch_auto_pages(params): **`links.next` tem prioridade.** A heurística de contagem é o fallback para APIs que omitem `links`. Quando `links.next` é null, NÃO faça outra requisição mesmo que `len(items) == per`. +**Página explícita:** em `to_list()` / `for ... in proxy` / `count()` / `last()`, o `page[number]` da chain **não** é usado — a auto-paginação reinicia em `page = 1`. Para buscar uma página fixa, use `.first()` (uma requisição com os params da chain) ou não use auto-paginação. + ### Query chain Builder encadeável que acumula parâmetros antes de executar: diff --git a/docs/SDK_TEST_MATRIX.md b/docs/SDK_TEST_MATRIX.md index a6d2520..273286a 100644 --- a/docs/SDK_TEST_MATRIX.md +++ b/docs/SDK_TEST_MATRIX.md @@ -110,9 +110,12 @@ Cada item deve ter teste em `tests/clicksign/`. Marque `[x]` quando coberto. --- -## Async — `test_async_client.py`, `test_async_clicksign_client.py` +## Async — `test_async_client.py`, `test_async_clicksign_client.py`, `test_async_http_executor.py`, `test_httpx_async_transport.py` - [x] HTTP do AsyncClient; resources e paginação do AsyncClicksignClient +- [x] Retry 429/422/timeout no AsyncClient (`test_async_client.py`) +- [x] `execute_async_http_request` — sucesso, retry 429/timeout, sem retry 422 (`test_async_http_executor.py`) +- [x] `HttpxAsyncHTTPClient` — import guard, delegação, status error, `aclose` (`test_httpx_async_transport.py`) --- @@ -138,6 +141,25 @@ Cobertura por resource (CRUD, filter, erros 404/422 onde aplicável): - [x] Admin: user, template, template_field, membership, group, folder, webhook - [x] Parcial: acceptance_term, auto_signature, access_control_list, envelope_bulk_creation (conforme métodos expostos) +### Notarial — métodos aninhados e notify + +- [x] `Envelope.list_documents` / `list_signers` / `list_requirements` / `list_signature_watchers` (`test_envelope.py`) +- [x] `Envelope.activate` (classe) (`test_envelope.py`) +- [x] `Envelope.notify` (`test_envelope.py`) +- [x] `Signer.notify(envelope_id, signer_id, ...)` — classmethod com ids explícitos (`test_signer.py`) +- [x] Facade `documents.create` / `signers.create` com envelope id posicional (`test_clicksign_client.py`) +- [x] Facade `signers.notify` / `envelopes.notify` (`test_clicksign_client.py`) +- [x] `Requirement.list_for_document` / `list_for_signer` com filtros (`test_requirement.py`) +- [x] `Envelope.list_requirements` com `QueryBuilder` (`test_envelope.py`) + +### Infraestrutura — `test_json_api_operations.py`, `test_bound_resource.py`, `test_async_bound_resource.py`, `test_httpx_transport.py`, `test_httpx_async_transport.py`, `test_types_attrs.py` + +- [x] `BulkRequirementOperations` (agree, evidence, rubricate, remove, payload) +- [x] `BoundQueryProxy` sync: includes, fields, count, last, iter, on_page, page/per/order +- [x] `AsyncBoundQueryProxy`: filter chain, includes, count, page/per +- [x] `HttpxHTTPClient` / `HttpxAsyncHTTPClient`: owned timeout, status 4xx/5xx, exceções httpx +- [x] `types._attrs` helpers + Adicionar testes ao criar novos métodos (ver [`SPEC.md`](SPEC.md) e esta matriz). --- diff --git a/docs/SPEC.md b/docs/SPEC.md index c87b45c..630820d 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -144,6 +144,16 @@ Import direto: `from clicksign import Envelope, Document, Signer` (notarial expo - `Envelope.list_events(envelope_id, **filters)` - `Envelope.list_documents` / `list_signers` / `list_requirements` / `list_signature_watchers` - `envelope.notify(message=..., subject=...)` +- `Signer.notify(envelope_id, signer_id, message=..., subject=...)` — classmethod; ids obrigatórios + +**Listagens equivalentes** (mesma rota HTTP; escolha por estilo): + +| Recurso | Via envelope | Via resource | +|---------|--------------|--------------| +| Signatários | `Envelope.list_signers(envelope_id)` | `Signer.list_for_envelope(envelope_id)` | +| Documentos | `Envelope.list_documents(envelope_id)` | `Document.list_for_envelope(envelope_id)` | + +Detalhes e paginação: [`PAGINATION.md`](PAGINATION.md). --- diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 954badc..108b3dd 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -106,6 +106,22 @@ Receita completa: [`examples/03-webhooks.md`](examples/03-webhooks.md). --- +## Signer.notify — envelope_id e signer_id obrigatórios + +`Signer.notify` é um **classmethod**: sempre informe `envelope_id` e `signer_id` (não há rota na raiz `/signers/{id}/notifications`). + +```python +Signer.notify(envelope.id, signer.id, message="Lembrete") + +# após retrieve — ainda precisa do envelope_id +signer = Signer.retrieve(signer_id) # rota raiz; envelope_id vem de envelope.id ou relationships +Signer.notify(envelope.id, signer.id, message="Lembrete") +``` + +Na facade: `client.notarial.signers.notify(envelope_id, signer_id, message="...")`. + +--- + ## Document / Signer `create` na facade `Document.create` e `Signer.create` recebem o **id do envelope como primeiro argumento posicional**, não como `envelope_id=`: @@ -121,6 +137,20 @@ Document.create(envelope_id=envelope.id, filename="a.pdf") --- +## Paginação — `page(n)` não fixa a página em `to_list()` + +`Envelope.filter(...).page(3).per(25).to_list()` **não** retorna só a página 3: a auto-paginação começa em `page[number]=1` e segue até o fim. + +Para uma página específica: + +```python +Envelope.filter(status="draft").page(3).per(25).first() # uma requisição, página 3 +``` + +Detalhes e listagens aninhadas: [`PAGINATION.md`](PAGINATION.md). + +--- + ## Eventos de envelope/documento Não existe `GET /events` na raiz da conta. Use rotas aninhadas: diff --git a/docs/TYPES.md b/docs/TYPES.md index f5f7d03..2b22b2e 100644 --- a/docs/TYPES.md +++ b/docs/TYPES.md @@ -8,7 +8,7 @@ Resources com **TypedDict** de params e **properties** explícitas nos atributos | Resource | Create / update / filter | |----------|-------------------------| -| **Notarial** | `Envelope`, `Document`, `Signer`, `Requirement`, `SignatureWatcher`, `NotarialEvent` | +| **Notarial** | `Envelope`, `Document`, `Signer`, `Requirement` (`auth` em create, ex. `"email"`), `SignatureWatcher`, `NotarialEvent` | | **Conta** | `Webhook`, `Folder`, `User`, `Template`, `TemplateField`, `Membership`, `Group` | Import: @@ -75,7 +75,22 @@ pytest -q `pyproject.toml` usa `strict = true` nos módulos listados em `[tool.mypy] files`; resources tipados devem passar no mypy sem `# type: ignore` desnecessários. -## Próximos passos (fora do §4) +## Imports fora de `clicksign.__all__` + +Alguns resources existem no pacote mas **não** são reexportados na raiz (`import clicksign`). Use submódulo: + +```python +from clicksign.resources.notarial.event import Event +from clicksign.resources.user import User +from clicksign.resources.template import Template +from clicksign.resources.membership import Membership +from clicksign.resources.group import Group +from clicksign.resources.access_control_list import AccessControlList +``` + +Na raiz hoje: `Envelope`, `Document`, `Signer`, `Requirement`, `BulkRequirement`, `SignatureWatcher`, `Webhook`, `Folder` — ver `clicksign.__all__` em `src/clicksign/__init__.py`. + +## Próximos passos (tipagem) - Resources admin restantes (`AccessControlList`, `EnvelopeBulkCreation`, …) conforme [`SPEC.md`](SPEC.md) - `Required[...]` em create params quando a API publicar obrigatoriedade diff --git a/docs/WORKFLOW.md b/docs/WORKFLOW.md index 1ed8f81..cf64555 100644 --- a/docs/WORKFLOW.md +++ b/docs/WORKFLOW.md @@ -161,8 +161,10 @@ envelope.notify(message="Seu contrato está disponível para assinatura.") ### Um signatário ```python -signer.notify(message="Lembrete: seu documento aguarda assinatura.") -signer.notify( +Signer.notify(envelope.id, signer.id, message="Lembrete: seu documento aguarda assinatura.") +Signer.notify( + envelope.id, + signer.id, message="Por favor, assine até sexta-feira.", subject="Ação necessária: assinatura pendente", ) @@ -203,7 +205,7 @@ Requirement.create(envelope.id, signer_id=signer.id, document_id=document.id, ac envelope.update(status="running") # 6. Notificar -signer.notify(message="Seu contrato ACME está disponível para assinatura.") +Signer.notify(envelope.id, signer.id, message="Seu contrato ACME está disponível para assinatura.") print(f"Envelope {envelope.id} ativo e signatário notificado.") ``` diff --git a/docs/examples/04-multi-client.md b/docs/examples/04-multi-client.md index 8f7e64d..898d31f 100644 --- a/docs/examples/04-multi-client.md +++ b/docs/examples/04-multi-client.md @@ -95,7 +95,7 @@ async def get_client(tenant_id: str) -> AsyncClicksignClient: return AsyncClicksignClient(api_key=tenant.api_key, environment=tenant.environment) ``` -Ver [08-production-limitations.md](08-production-limitations.md). +Receita completa: [13-async-fastapi.md](13-async-fastapi.md) · limitações: [08-production-limitations.md](08-production-limitations.md). --- diff --git a/docs/examples/07-list-and-filter.md b/docs/examples/07-list-and-filter.md index 0235f1c..bf2a2d0 100644 --- a/docs/examples/07-list-and-filter.md +++ b/docs/examples/07-list-and-filter.md @@ -69,7 +69,20 @@ Com `ClicksignClient`: `client.envelopes.filter(...).to_list()`. ## Listagens aninhadas -Métodos como `Envelope.list_documents(envelope_id)` são rotas aninhadas — não passam por `filter` da collection raiz. Ver [`SPEC.md`](../SPEC.md). +Não use `Envelope.filter(...)` para listar signatários **dentro** de um envelope. Use métodos aninhados: + +```python +# Equivalentes (mesma API) +signers_a = Signer.list_for_envelope(envelope.id) +signers_b = Envelope.list_signers(envelope.id) + +docs = Document.list_for_envelope(envelope.id) +# ou Envelope.list_documents(envelope.id) + +reqs = Envelope.list_requirements(envelope.id, document_id=doc.id) +``` + +Tabela completa: [`../PAGINATION.md`](../PAGINATION.md) · rotas: [`../SPEC.md`](../SPEC.md). --- diff --git a/docs/examples/13-async-fastapi.md b/docs/examples/13-async-fastapi.md new file mode 100644 index 0000000..3fe5a8a --- /dev/null +++ b/docs/examples/13-async-fastapi.md @@ -0,0 +1,134 @@ +# FastAPI e asyncio — `AsyncClicksignClient` + +Receita para apps async: lifespan, dependência por request e fluxo notarial mínimo. + +**Requisito:** `pip install clicksign[async]` (httpx). + +--- + +## Regras + +| Faça | Evite | +|------|--------| +| `AsyncClicksignClient` explícito (app state ou `Depends`) | `Services.use()` no event loop | +| `async with client` ou `await client.aclose()` no shutdown | Cliente novo por request sem necessidade | +| `await envelope.update_async(...)` em instâncias async | `envelope.update()` bloqueante no loop | +| `Signer.notify(envelope_id, signer_id, ...)` | `signer.notify(...)` sem ids | + +Ver também: [08-production-limitations.md](08-production-limitations.md) · [12-http-connection-pool.md](12-http-connection-pool.md). + +--- + +## Lifespan (um client por processo) + +```python +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from clicksign import AsyncClicksignClient + +@asynccontextmanager +async def lifespan(app: FastAPI): + client = AsyncClicksignClient( + api_key=settings.clicksign_api_key, + environment="production", + ) + app.state.clicksign = client + try: + yield + finally: + await client.aclose() + +app = FastAPI(lifespan=lifespan) +``` + +`AsyncClicksignClient` já usa `httpx.AsyncClient` com pool no event loop — um client por worker é o padrão recomendado. + +--- + +## Dependência FastAPI + +```python +from fastapi import Depends, Request +from clicksign import AsyncClicksignClient + +def get_clicksign(request: Request) -> AsyncClicksignClient: + return request.app.state.clicksign +``` + +Multi-tenant (client por request): + +```python +async def get_tenant_clicksign(tenant_id: str) -> AsyncClicksignClient: + tenant = await load_tenant(tenant_id) + return AsyncClicksignClient( + api_key=tenant.clicksign_api_key, + environment=tenant.environment, + ) +``` + +Feche com `await client.aclose()` ao final do request se criar instância descartável. + +--- + +## Fluxo notarial (async) + +```python +from clicksign import AsyncClicksignClient +from clicksign.resources.notarial.signer import Signer + +async def run_flow(client: AsyncClicksignClient) -> None: + envelope = await client.notarial.envelopes.create(name="Contrato", locale="pt-BR") + + doc = await client.notarial.documents.create( + envelope.id, + filename="contrato.pdf", + content_base64="data:application/pdf;base64,...", + ) + + signer = await client.notarial.signers.create( + envelope.id, + name="Maria Silva", + email="maria@example.com", + ) + + bulk = await client.notarial.bulk_requirements.create( + envelope.id, + block=lambda ops: ( + ops.add_agree(signer_id=signer.id, document_id=doc.id, role="sign"), + ops.add_provide_evidence( + signer_id=signer.id, document_id=doc.id, auth="email" + ), + ), + ) + if not bulk.success(): + raise RuntimeError("bulk incompleto") + + await envelope.update_async(status="running") + # ou: await client.notarial.envelopes.activate(envelope.id) + + Signer.notify(envelope.id, signer.id, message="Documento disponível para assinatura.") +``` + +--- + +## Listar e paginar + +```python +# Uma página explícita (não auto-pagina todas) +first = await client.envelopes.filter(status="draft").page(2).per(10).first() + +# Todas as páginas +async for envelope in client.envelopes.filter(status="draft"): + print(envelope.id) +``` + +`page(n)` com `.first()` / `.last()` respeita o número da página; `.to_list()` na auto-paginação **reinicia** em `page=1` — ver [`../PAGINATION.md`](../PAGINATION.md). + +--- + +## Referência + +- README (seção Async) · [04-multi-client.md](04-multi-client.md) +- Contrato: [`../SDK_CONTRACT.md`](../SDK_CONTRACT.md) +- Fluxo sync detalhado: [`../WORKFLOW.md`](../WORKFLOW.md) diff --git a/docs/examples/README.md b/docs/examples/README.md index 0768254..6f75064 100644 --- a/docs/examples/README.md +++ b/docs/examples/README.md @@ -14,6 +14,7 @@ Receitas curtas por cenário em Python. Fluxo completo de assinatura: [`WORKFLOW | [Observabilidade — OpenTelemetry](10-observability-opentelemetry.md) | Spans manuais por request/erro | | [Observabilidade — métricas](11-observability-metrics.md) | Prometheus / StatsD via hooks | | [HTTP — connection pool](12-http-connection-pool.md) | `HttpxHTTPClient` singleton por worker | +| [FastAPI / asyncio](13-async-fastapi.md) | `AsyncClicksignClient`, lifespan, fluxo notarial | **Fluxo completo de assinatura:** [`WORKFLOW.md`](../WORKFLOW.md). From 52940e18bc6a8419513ccff48acd605d509a0a45 Mon Sep 17 00:00:00 2001 From: Danilo Josino Date: Thu, 21 May 2026 13:26:54 -0300 Subject: [PATCH 8/8] chore: remover docs/CONSIDERACOES.md do tracking (uso interno) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Arquivo é backlog interno da equipe; .gitignore já o excluía mas não estava staged. Remove do índice git e commita a entrada no .gitignore. Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 1 + docs/CONSIDERACOES.md | 48 ------------------------------------------- 2 files changed, 1 insertion(+), 48 deletions(-) delete mode 100644 docs/CONSIDERACOES.md diff --git a/.gitignore b/.gitignore index 842b0a2..43e5f70 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ htmlcov/ .claude/exec.log .claude/ .idea/ +docs/CONSIDERACOES.md diff --git a/docs/CONSIDERACOES.md b/docs/CONSIDERACOES.md deleted file mode 100644 index bf14c4b..0000000 --- a/docs/CONSIDERACOES.md +++ /dev/null @@ -1,48 +0,0 @@ -# Considerações — Python SDK (uso interno) - -> **Não linkar** em README nem em `docs/README.md`. Backlog da equipe; matriz de testes em [`SDK_TEST_MATRIX.md`](SDK_TEST_MATRIX.md). - -**Última revisão:** maio/2026 (6ª passagem — refinamento da doc pública). - ---- - -## Snapshot - -| | | -|---|---| -| Testes | **517** · cobertura **93%** · CI OK | -| Qualidade | ruff + mypy | -| Doc pública | Refinada (async, paginação, listagens, imports) | -| Crítico | **Nada** | - -```bash -pytest -q && pytest --cov=clicksign --cov-fail-under=88 -q -ruff check src tests && ruff format --check src tests && mypy -``` - ---- - -## Backlog aberto - -| Item | Prioridade | -|------|------------| -| Tag git `v0.1.0` ao publicar PyPI | Release | -| `resource_type_spec.json` vs API | P2 | -| TypedDicts admin restantes (`AccessControlList`, …) | P2 | -| CI: install mínimo sem `[dev]` | P3 | -| Cobertura admin (`webhook` resource ~76%) | P3 | -| Smoke E2E sandbox | Manual | - ---- - -## Comportamento a não esquecer - -- `Signer.notify(envelope_id, signer_id, ...)` — classmethod. -- Eventos só aninhados; facade `create` com envelope id posicional. -- `page(n)` + `.to_list()` → auto-pagina desde p.1; use `.first()` para página fixa. - ---- - -## Referência (público) - -`SDK_CONTRACT` · `WORKFLOW` · `SPEC` · `PAGINATION` · `TROUBLESHOOTING` · `examples/`