codespar 0.10.0__tar.gz → 0.10.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. {codespar-0.10.0 → codespar-0.10.2}/PKG-INFO +1 -1
  2. {codespar-0.10.0 → codespar-0.10.2}/pyproject.toml +1 -1
  3. {codespar-0.10.0 → codespar-0.10.2}/src/codespar/__init__.py +59 -1
  4. {codespar-0.10.0 → codespar-0.10.2}/src/codespar/_async_client.py +10 -0
  5. {codespar-0.10.0 → codespar-0.10.2}/src/codespar/_async_session.py +268 -0
  6. {codespar-0.10.0 → codespar-0.10.2}/src/codespar/_sync_client.py +18 -0
  7. {codespar-0.10.0 → codespar-0.10.2}/src/codespar/types.py +323 -0
  8. codespar-0.10.2/tests/_fixtures/shop_canonical.json +52 -0
  9. {codespar-0.10.0 → codespar-0.10.2}/tests/test_config.py +10 -0
  10. {codespar-0.10.0 → codespar-0.10.2}/tests/test_session.py +3 -3
  11. codespar-0.10.2/tests/test_shop.py +212 -0
  12. {codespar-0.10.0 → codespar-0.10.2}/.gitignore +0 -0
  13. {codespar-0.10.0 → codespar-0.10.2}/CHANGELOG.md +0 -0
  14. {codespar-0.10.0 → codespar-0.10.2}/README.md +0 -0
  15. {codespar-0.10.0 → codespar-0.10.2}/examples/README.md +0 -0
  16. {codespar-0.10.0 → codespar-0.10.2}/examples/async_basic.py +0 -0
  17. {codespar-0.10.0 → codespar-0.10.2}/examples/connect_link.py +0 -0
  18. {codespar-0.10.0 → codespar-0.10.2}/examples/ecommerce_checkout.py +0 -0
  19. {codespar-0.10.0 → codespar-0.10.2}/examples/mocks_round_trip.py +0 -0
  20. {codespar-0.10.0 → codespar-0.10.2}/examples/pix_payment.py +0 -0
  21. {codespar-0.10.0 → codespar-0.10.2}/examples/proxy_execute.py +0 -0
  22. {codespar-0.10.0 → codespar-0.10.2}/src/codespar/_http.py +0 -0
  23. {codespar-0.10.0 → codespar-0.10.2}/src/codespar/_presets.py +0 -0
  24. {codespar-0.10.0 → codespar-0.10.2}/src/codespar/errors.py +0 -0
  25. {codespar-0.10.0 → codespar-0.10.2}/src/codespar/py.typed +0 -0
  26. {codespar-0.10.0 → codespar-0.10.2}/src/codespar/tool_result_codes.py +0 -0
  27. {codespar-0.10.0 → codespar-0.10.2}/tests/__init__.py +0 -0
  28. {codespar-0.10.0 → codespar-0.10.2}/tests/_fixtures/mocks_canonical.json +0 -0
  29. {codespar-0.10.0 → codespar-0.10.2}/tests/test_base_url_resolution.py +0 -0
  30. {codespar-0.10.0 → codespar-0.10.2}/tests/test_error_code_precedence.py +0 -0
  31. {codespar-0.10.0 → codespar-0.10.2}/tests/test_forward_mocks.py +0 -0
  32. {codespar-0.10.0 → codespar-0.10.2}/tests/test_mocks_types.py +0 -0
  33. {codespar-0.10.0 → codespar-0.10.2}/tests/test_streaming.py +0 -0
  34. {codespar-0.10.0 → codespar-0.10.2}/tests/test_tool_result_codes.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codespar
3
- Version: 0.10.0
3
+ Version: 0.10.2
4
4
  Summary: Python SDK for CodeSpar — commerce infrastructure for AI agents in Latin America.
5
5
  Project-URL: Homepage, https://codespar.dev
6
6
  Project-URL: Documentation, https://docs.codespar.dev
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "codespar"
7
- version = "0.10.0"
7
+ version = "0.10.2"
8
8
  description = "Python SDK for CodeSpar — commerce infrastructure for AI agents in Latin America."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -58,6 +58,7 @@ from .tool_result_codes import (
58
58
  is_tool_not_mocked,
59
59
  )
60
60
  from .types import (
61
+ AgenticReceipt,
61
62
  AssistantTextEvent,
62
63
  AuthConfig,
63
64
  AuthResult,
@@ -78,6 +79,15 @@ from .types import (
78
79
  DoneEvent,
79
80
  ErrorEvent,
80
81
  HttpMethod,
82
+ IssueAction,
83
+ IssueArgs,
84
+ IssueResult,
85
+ LedgerAction,
86
+ LedgerArgs,
87
+ LedgerLeg,
88
+ LedgerReceiptResult,
89
+ LedgerReceiptsResult,
90
+ LedgerResult,
81
91
  ManageConnections,
82
92
  MockObject,
83
93
  MockValue,
@@ -87,6 +97,11 @@ from .types import (
87
97
  Preset,
88
98
  ProxyRequest,
89
99
  ProxyResult,
100
+ ReceiptDelivery,
101
+ ReceiptException,
102
+ ReceiptMandate,
103
+ ReceiptPayment,
104
+ ReceiptQuote,
90
105
  RequiredSecret,
91
106
  SearchStrategy,
92
107
  SendResult,
@@ -100,6 +115,18 @@ from .types import (
100
115
  ShipItem,
101
116
  ShipResult,
102
117
  ShipServiceLevel,
118
+ ShopAction,
119
+ ShopAddress,
120
+ ShopArgs,
121
+ ShopBuyer,
122
+ ShopCheckoutItem,
123
+ ShopCheckoutResult,
124
+ ShopCheckoutStatus,
125
+ ShopOffer,
126
+ ShopResult,
127
+ ShopSearchResult,
128
+ ShopStatusResult,
129
+ ShopVariant,
103
130
  StreamEvent,
104
131
  Tool,
105
132
  ToolCallRecord,
@@ -113,7 +140,7 @@ from .types import (
113
140
  WizardAction,
114
141
  )
115
142
 
116
- __version__ = "0.10.0"
143
+ __version__ = "0.10.2"
117
144
 
118
145
  __all__ = [
119
146
  "APPROVAL_REQUIRED",
@@ -122,6 +149,8 @@ __all__ = [
122
149
  "POLICY_DENIED",
123
150
  "TOOL_NOT_MOCKED",
124
151
  "TOOL_RESULT_CODES",
152
+ # Agentic receipt (the Control Record)
153
+ "AgenticReceipt",
125
154
  "ApiError",
126
155
  "ApprovalRequiredOutput",
127
156
  "AssistantTextEvent",
@@ -155,6 +184,17 @@ __all__ = [
155
184
  "DoneEvent",
156
185
  "ErrorEvent",
157
186
  "HttpMethod",
187
+ # Issue (codespar_issue)
188
+ "IssueAction",
189
+ "IssueArgs",
190
+ "IssueResult",
191
+ # Ledger (codespar_ledger)
192
+ "LedgerAction",
193
+ "LedgerArgs",
194
+ "LedgerLeg",
195
+ "LedgerReceiptResult",
196
+ "LedgerReceiptsResult",
197
+ "LedgerResult",
158
198
  "ManageConnections",
159
199
  # Test-mode mocks
160
200
  "MockObject",
@@ -171,6 +211,11 @@ __all__ = [
171
211
  # Proxy
172
212
  "ProxyRequest",
173
213
  "ProxyResult",
214
+ "ReceiptDelivery",
215
+ "ReceiptException",
216
+ "ReceiptMandate",
217
+ "ReceiptPayment",
218
+ "ReceiptQuote",
174
219
  "RequiredSecret",
175
220
  "SearchStrategy",
176
221
  "SendResult",
@@ -188,6 +233,19 @@ __all__ = [
188
233
  "ShipItem",
189
234
  "ShipResult",
190
235
  "ShipServiceLevel",
236
+ # Shopping (codespar_shop)
237
+ "ShopAction",
238
+ "ShopAddress",
239
+ "ShopArgs",
240
+ "ShopBuyer",
241
+ "ShopCheckoutItem",
242
+ "ShopCheckoutResult",
243
+ "ShopCheckoutStatus",
244
+ "ShopOffer",
245
+ "ShopResult",
246
+ "ShopSearchResult",
247
+ "ShopStatusResult",
248
+ "ShopVariant",
191
249
  "StreamError",
192
250
  # Streaming events
193
251
  "StreamEvent",
@@ -11,6 +11,7 @@ having to write ``async def``.
11
11
  from __future__ import annotations
12
12
 
13
13
  import os
14
+ import re
14
15
  from types import TracebackType
15
16
 
16
17
  import httpx
@@ -25,6 +26,11 @@ from ._presets import preset_to_servers
25
26
  from .errors import ApiError, ConfigError
26
27
  from .types import SessionConfig
27
28
 
29
+ # Wire format for a project scope. Kept in sync with the TS SDK
30
+ # (`PROJECT_ID_REGEX` in packages/core/src/types.ts) so both clients
31
+ # reject the same project_id inputs.
32
+ _PROJECT_ID_RE = re.compile(r"^prj_[A-Za-z0-9]{16}$")
33
+
28
34
 
29
35
  def _resolve_base_url(explicit: str | None) -> str:
30
36
  """Resolve the effective base URL.
@@ -67,6 +73,10 @@ class AsyncCodeSpar:
67
73
  "api_key is required and must start with 'csk_'. "
68
74
  "Get one from https://dashboard.codespar.dev."
69
75
  )
76
+ if project_id is not None and not _PROJECT_ID_RE.match(project_id):
77
+ raise ConfigError(
78
+ "project_id must match ^prj_[A-Za-z0-9]{16}$ (e.g. 'prj_...')."
79
+ )
70
80
  self._api_key = api_key
71
81
  self._base_url = _resolve_base_url(base_url).rstrip("/")
72
82
  self._project_id = project_id
@@ -35,6 +35,10 @@ from .types import (
35
35
  DiscoverToolMatch,
36
36
  DoneEvent,
37
37
  ErrorEvent,
38
+ IssueArgs,
39
+ IssueResult,
40
+ LedgerArgs,
41
+ LedgerResult,
38
42
  PaymentStatusEvent,
39
43
  PaymentStatusResult,
40
44
  ProxyRequest,
@@ -45,6 +49,13 @@ from .types import (
45
49
  SessionInfo,
46
50
  ShipArgs,
47
51
  ShipResult,
52
+ ShopArgs,
53
+ ShopCheckoutResult,
54
+ ShopOffer,
55
+ ShopResult,
56
+ ShopSearchResult,
57
+ ShopStatusResult,
58
+ ShopVariant,
48
59
  StreamEvent,
49
60
  Tool,
50
61
  ToolCallRecord,
@@ -196,6 +207,91 @@ def _parse_ship_result(raw: dict[str, Any]) -> ShipResult:
196
207
  )
197
208
 
198
209
 
210
+ def _parse_ledger_result(raw: dict[str, Any]) -> LedgerResult:
211
+ return LedgerResult(
212
+ id=raw.get("id"),
213
+ status=raw.get("status"),
214
+ account_id=raw.get("account_id"),
215
+ alias=raw.get("alias"),
216
+ balances=raw.get("balances"),
217
+ raw=raw.get("raw"),
218
+ )
219
+
220
+
221
+ def _parse_issue_result(raw: dict[str, Any]) -> IssueResult:
222
+ return IssueResult(
223
+ id=raw.get("id"),
224
+ status=raw.get("status"),
225
+ card_type=raw.get("card_type"),
226
+ last_four=raw.get("last_four"),
227
+ cardholder_id=raw.get("cardholder_id"),
228
+ program_id=raw.get("program_id"),
229
+ raw=raw.get("raw"),
230
+ )
231
+
232
+
233
+ def _parse_shop_variant(raw: dict[str, Any]) -> ShopVariant:
234
+ return ShopVariant(
235
+ sku_id=str(raw.get("sku_id", "")),
236
+ available=bool(raw.get("available", False)),
237
+ title=raw.get("title"),
238
+ price_minor=raw.get("price_minor"),
239
+ currency=raw.get("currency"),
240
+ )
241
+
242
+
243
+ def _parse_shop_offer(raw: dict[str, Any]) -> ShopOffer:
244
+ variants_raw = raw.get("variants") or []
245
+ return ShopOffer(
246
+ product_id=str(raw.get("product_id", "")),
247
+ available=bool(raw.get("available", False)),
248
+ variants=[
249
+ _parse_shop_variant(v) for v in variants_raw if isinstance(v, dict)
250
+ ],
251
+ sku_id=raw.get("sku_id"),
252
+ title=raw.get("title"),
253
+ price_minor=raw.get("price_minor"),
254
+ currency=raw.get("currency"),
255
+ image=raw.get("image"),
256
+ url=raw.get("url"),
257
+ )
258
+
259
+
260
+ def _parse_shop_result(raw: dict[str, Any], action: str) -> ShopResult:
261
+ """Map the wire payload to the action-correct dataclass.
262
+
263
+ The wire shape is discriminated by the call's ``action`` (search →
264
+ products, checkout → in_progress session, checkout_status → terminal
265
+ poll), mirroring the TS discriminated ``ShopResult`` union.
266
+ """
267
+ if action == "search":
268
+ products_raw = raw.get("products") or []
269
+ return ShopSearchResult(
270
+ rail=str(raw.get("rail", "")),
271
+ products=[
272
+ _parse_shop_offer(p)
273
+ for p in products_raw
274
+ if isinstance(p, dict)
275
+ ],
276
+ )
277
+ if action == "checkout":
278
+ return ShopCheckoutResult(
279
+ checkout_session_id=str(raw.get("checkout_session_id", "")),
280
+ status="in_progress",
281
+ message=raw.get("message"),
282
+ )
283
+ # checkout_status
284
+ return ShopStatusResult(
285
+ checkout_session_id=str(raw.get("checkout_session_id", "")),
286
+ status=raw.get("status", "in_progress"),
287
+ rail=raw.get("rail"),
288
+ total_minor=raw.get("total_minor"),
289
+ pix_copia_e_cola=raw.get("pix_copia_e_cola"),
290
+ order_status=raw.get("order_status"),
291
+ error=raw.get("error"),
292
+ )
293
+
294
+
199
295
  def _parse_stream_event(raw: dict[str, Any]) -> StreamEvent | None:
200
296
  """
201
297
  Normalize a raw SSE payload into a typed ``StreamEvent``.
@@ -521,6 +617,178 @@ class AsyncSession:
521
617
  raise ApiError("ship: malformed response", status=0, body=result.data)
522
618
  return _parse_ship_result(result.data)
523
619
 
620
+ async def ledger(self, args: LedgerArgs) -> LedgerResult:
621
+ """
622
+ Post a double-entry journal entry, read an account's balances,
623
+ or create an account on the tenant's self-hosted Lerian Midaz
624
+ ledger. Typed wrapper around ``execute("codespar_ledger", {...})``.
625
+
626
+ ``args.action`` is one of ``entry`` | ``balance`` | ``account``.
627
+ For ``entry``: asset + source + destination are required (source
628
+ debits must equal destination credits, same asset). For
629
+ ``balance``: ``account`` (id) is required. For ``account``:
630
+ ``asset`` is required. Amounts are in MINOR units (cents). The
631
+ connection (base_url + org_id + ledger_id) is operator-seeded.
632
+ """
633
+ params: dict[str, Any] = {"action": args.action}
634
+ if args.asset is not None:
635
+ params["asset"] = args.asset
636
+ if args.scale is not None:
637
+ params["scale"] = args.scale
638
+ if args.source is not None:
639
+ params["source"] = [
640
+ {"account": leg.account, "amount": leg.amount}
641
+ for leg in args.source
642
+ ]
643
+ if args.destination is not None:
644
+ params["destination"] = [
645
+ {"account": leg.account, "amount": leg.amount}
646
+ for leg in args.destination
647
+ ]
648
+ if args.description is not None:
649
+ params["description"] = args.description
650
+ if args.account is not None:
651
+ params["account"] = args.account
652
+ if args.alias is not None:
653
+ params["alias"] = args.alias
654
+ if args.name is not None:
655
+ params["name"] = args.name
656
+ if args.type is not None:
657
+ params["type"] = args.type
658
+ if args.metadata is not None:
659
+ params["metadata"] = args.metadata
660
+ result = await self.execute("codespar_ledger", params)
661
+ if not result.success:
662
+ raise ApiError(
663
+ f"ledger failed: {result.error or 'unknown'}",
664
+ status=0,
665
+ body=result.error,
666
+ )
667
+ if not isinstance(result.data, dict):
668
+ raise ApiError("ledger: malformed response", status=0, body=result.data)
669
+ return _parse_ledger_result(result.data)
670
+
671
+ async def issue(self, args: IssueArgs) -> IssueResult:
672
+ """
673
+ Issue a virtual/physical card, control (freeze/unfreeze/cancel)
674
+ one, or read its status on the tenant's Pomelo card-issuing
675
+ program. Typed wrapper around ``execute("codespar_issue", {...})``.
676
+
677
+ ``args.action`` is one of ``card-virtual`` | ``card-physical`` |
678
+ ``card-control`` | ``card-get``. cardholder_id + program_id are
679
+ required to issue; card_id is required for control + get;
680
+ card-physical needs shipping_address.
681
+ """
682
+ params: dict[str, Any] = {"action": args.action}
683
+ if args.cardholder_id is not None:
684
+ params["cardholder_id"] = args.cardholder_id
685
+ if args.program_id is not None:
686
+ params["program_id"] = args.program_id
687
+ if args.card_id is not None:
688
+ params["card_id"] = args.card_id
689
+ if args.control is not None:
690
+ params["control"] = args.control
691
+ if args.reason is not None:
692
+ params["reason"] = args.reason
693
+ if args.shipping_address is not None:
694
+ params["shipping_address"] = args.shipping_address
695
+ if args.metadata is not None:
696
+ params["metadata"] = args.metadata
697
+ result = await self.execute("codespar_issue", params)
698
+ if not result.success:
699
+ raise ApiError(
700
+ f"issue failed: {result.error or 'unknown'}",
701
+ status=0,
702
+ body=result.error,
703
+ )
704
+ if not isinstance(result.data, dict):
705
+ raise ApiError("issue: malformed response", status=0, body=result.data)
706
+ return _parse_issue_result(result.data)
707
+
708
+ async def shop(self, args: ShopArgs) -> ShopResult:
709
+ """
710
+ Buy-side shopping: catalog search → async checkout → Pix mint.
711
+ Typed wrapper around ``execute("codespar_shop", {...})``. The
712
+ result type is action-correct: ``search`` → ``ShopSearchResult``,
713
+ ``checkout`` → ``ShopCheckoutResult``, ``checkout_status`` →
714
+ ``ShopStatusResult`` (version-locked to the TS discriminated
715
+ ``ShopResult`` union).
716
+
717
+ Checkout is async: ``action="checkout"`` returns
718
+ ``{checkout_session_id, status:"in_progress"}``; poll
719
+ ``action="checkout_status"`` until ``ready_for_payment`` (carries
720
+ ``pix_copia_e_cola``) or ``canceled`` (carries ``error``). Settle
721
+ the returned Pix via a separate payment tool — settlement and
722
+ governance are out of this contract.
723
+
724
+ Requires a runtime that implements the ``codespar_shop``
725
+ meta-tool; a self-hosted OSS runtime with no registered
726
+ implementation returns "Tool not registered".
727
+ """
728
+ params: dict[str, Any] = {"action": args.action}
729
+ if args.action == "search":
730
+ if args.query is not None:
731
+ params["query"] = args.query
732
+ if args.limit is not None:
733
+ params["limit"] = args.limit
734
+ if args.merchant is not None:
735
+ params["merchant"] = args.merchant
736
+ elif args.action == "checkout":
737
+ if args.merchant is not None:
738
+ params["merchant"] = args.merchant
739
+ if args.items is not None:
740
+ params["items"] = [
741
+ {
742
+ "variant_id": it.variant_id,
743
+ **(
744
+ {"quantity": it.quantity}
745
+ if it.quantity is not None
746
+ else {}
747
+ ),
748
+ **({"seller": it.seller} if it.seller else {}),
749
+ }
750
+ for it in args.items
751
+ ]
752
+ if args.url is not None:
753
+ params["url"] = args.url
754
+ if args.consumer_id is not None:
755
+ params["consumer_id"] = args.consumer_id
756
+ if args.buyer is not None:
757
+ params["buyer"] = {
758
+ **({"name": args.buyer.name} if args.buyer.name else {}),
759
+ **({"email": args.buyer.email} if args.buyer.email else {}),
760
+ **({"cpf": args.buyer.cpf} if args.buyer.cpf else {}),
761
+ **({"phone": args.buyer.phone} if args.buyer.phone else {}),
762
+ }
763
+ if args.address is not None:
764
+ a = args.address
765
+ params["address"] = {
766
+ "cep": a.cep,
767
+ **({"street": a.street} if a.street else {}),
768
+ **({"number": a.number} if a.number else {}),
769
+ **({"complement": a.complement} if a.complement else {}),
770
+ **(
771
+ {"neighborhood": a.neighborhood}
772
+ if a.neighborhood
773
+ else {}
774
+ ),
775
+ **({"city": a.city} if a.city else {}),
776
+ **({"state": a.state} if a.state else {}),
777
+ }
778
+ else: # checkout_status
779
+ if args.checkout_session_id is not None:
780
+ params["checkout_session_id"] = args.checkout_session_id
781
+ result = await self.execute("codespar_shop", params)
782
+ if not result.success:
783
+ raise ApiError(
784
+ f"shop failed: {result.error or 'unknown'}",
785
+ status=0,
786
+ body=result.error,
787
+ )
788
+ if not isinstance(result.data, dict):
789
+ raise ApiError("shop: malformed response", status=0, body=result.data)
790
+ return _parse_shop_result(result.data, args.action)
791
+
524
792
  async def verification_status(
525
793
  self, tool_call_id: str
526
794
  ) -> VerificationStatusResult:
@@ -31,6 +31,10 @@ from .types import (
31
31
  ConnectionWizardResult,
32
32
  DiscoverOptions,
33
33
  DiscoverResult,
34
+ IssueArgs,
35
+ IssueResult,
36
+ LedgerArgs,
37
+ LedgerResult,
34
38
  PaymentStatusResult,
35
39
  ProxyRequest,
36
40
  ProxyResult,
@@ -40,6 +44,8 @@ from .types import (
40
44
  SessionInfo,
41
45
  ShipArgs,
42
46
  ShipResult,
47
+ ShopArgs,
48
+ ShopResult,
43
49
  StreamEvent,
44
50
  Tool,
45
51
  ToolResult,
@@ -173,6 +179,18 @@ class Session:
173
179
  """Sync wrapper around ``AsyncSession.ship``. See that for docs."""
174
180
  return self._runner.run(self._async.ship(args))
175
181
 
182
+ def ledger(self, args: LedgerArgs) -> LedgerResult:
183
+ """Sync wrapper around ``AsyncSession.ledger``. See that for docs."""
184
+ return self._runner.run(self._async.ledger(args))
185
+
186
+ def issue(self, args: IssueArgs) -> IssueResult:
187
+ """Sync wrapper around ``AsyncSession.issue``. See that for docs."""
188
+ return self._runner.run(self._async.issue(args))
189
+
190
+ def shop(self, args: ShopArgs) -> ShopResult:
191
+ """Sync wrapper around ``AsyncSession.shop``. See that for docs."""
192
+ return self._runner.run(self._async.shop(args))
193
+
176
194
  def proxy_execute(self, request: ProxyRequest) -> ProxyResult:
177
195
  return self._runner.run(self._async.proxy_execute(request))
178
196
 
@@ -459,6 +459,329 @@ class ShipResult:
459
459
  raw: Any = None
460
460
 
461
461
 
462
+ # ── codespar_ledger wire shape ─────────────────────────────────────
463
+
464
+
465
+ LedgerAction = Literal["entry", "balance", "account", "receipt", "receipts"]
466
+
467
+
468
+ @dataclass(slots=True)
469
+ class LedgerLeg:
470
+ """One side of a journal entry. ``account`` is a Midaz account alias
471
+ (e.g. ``@wallet/user_123``); ``amount`` is in minor units (cents)."""
472
+
473
+ account: str
474
+ amount: int
475
+
476
+
477
+ @dataclass(slots=True)
478
+ class LedgerArgs:
479
+ """Input shape for ``Session.ledger``.
480
+
481
+ Three actions over a tenant's self-hosted double-entry ledger
482
+ (Lerian Midaz). Mirrors the backend's MetaLedgerArgs so the wire
483
+ payload matches byte-for-byte. The connection (base_url + org_id +
484
+ ledger_id) is operator-seeded, never passed by the agent. Amounts
485
+ are in MINOR units (cents).
486
+ - ``entry`` Post an n:n journal transaction (source debits must
487
+ equal destination credits, same asset)
488
+ - ``balance`` Read an account's balances
489
+ - ``account`` Create an account for an asset
490
+ """
491
+
492
+ action: LedgerAction
493
+ asset: str | None = None
494
+ scale: int | None = None
495
+ source: list[LedgerLeg] | None = None
496
+ destination: list[LedgerLeg] | None = None
497
+ description: str | None = None
498
+ account: str | None = None
499
+ alias: str | None = None
500
+ name: str | None = None
501
+ type: str | None = None
502
+ receipt_id: str | None = None
503
+ consumer_id: str | None = None
504
+ limit: int | None = None
505
+ metadata: dict[str, Any] | None = None
506
+
507
+
508
+ @dataclass(slots=True)
509
+ class LedgerResult:
510
+ id: str | None = None
511
+ status: str | None = None
512
+ account_id: str | None = None
513
+ alias: str | None = None
514
+ balances: Any = None
515
+ raw: Any = None
516
+
517
+
518
+ # ── Agentic receipt (the Control Record) ───────────────────────────
519
+
520
+
521
+ @dataclass(slots=True)
522
+ class ReceiptException:
523
+ """A settle-time policy divergence recorded on a receipt (OBSERVE mode) —
524
+ e.g. the settled amount/payee diverging from the bound quote."""
525
+
526
+ code: str
527
+ detail: str
528
+ at: str
529
+
530
+
531
+ @dataclass(slots=True)
532
+ class ReceiptMandate:
533
+ id: str
534
+ nonce: str
535
+ scope: str
536
+ currency: str
537
+ sig: str
538
+
539
+
540
+ @dataclass(slots=True)
541
+ class ReceiptQuote:
542
+ seller: str | None = None
543
+ resource: str | None = None
544
+ price_minor: int | None = None
545
+ payee: str | None = None
546
+ session_id: str | None = None
547
+ sig: str | None = None
548
+ at: str | None = None
549
+
550
+
551
+ @dataclass(slots=True)
552
+ class ReceiptPayment:
553
+ rail: str
554
+ amount_minor: int
555
+ attempt_id: str
556
+ money_moved: bool
557
+ provider: str | None = None
558
+ tx_id: str | None = None
559
+ at: str | None = None
560
+
561
+
562
+ @dataclass(slots=True)
563
+ class ReceiptDelivery:
564
+ result: str | None = None
565
+ proof: str | None = None
566
+ kind: str | None = None
567
+ nfe_chave: str | None = None
568
+ at: str | None = None
569
+
570
+
571
+ @dataclass(slots=True)
572
+ class AgenticReceipt:
573
+ """The canonical agentic receipt — the Control Record of one spend.
574
+
575
+ Binds mandate -> quote -> payment -> delivery into a tamper-evident
576
+ chain (SHA-256 over the RFC 8785 canonical JSON of the records
577
+ present); ``receipt_sig`` is the consumer HMAC over ``chain``. The
578
+ structure is rail-agnostic; only the delivery proof artifact differs
579
+ (NF-e + Pix endToEndId for the fiscal path, the x402 resource
580
+ response for crypto). Returned by ``codespar_ledger`` action=receipt.
581
+ """
582
+
583
+ receipt_id: str
584
+ state: Literal["paid", "exception", "delivered", "voided"]
585
+ mandate: ReceiptMandate
586
+ payment: ReceiptPayment
587
+ chain: str
588
+ receipt_sig: str
589
+ quote: ReceiptQuote | None = None
590
+ delivery: ReceiptDelivery | None = None
591
+ exceptions: list[ReceiptException] = field(default_factory=list)
592
+
593
+
594
+ @dataclass(slots=True)
595
+ class LedgerReceiptResult:
596
+ """Result of ``codespar_ledger`` action=receipt. A missing receipt
597
+ returns ``found=False`` (not an error)."""
598
+
599
+ found: bool
600
+ receipt: AgenticReceipt | None = None
601
+ receipt_id: str | None = None
602
+
603
+
604
+ @dataclass(slots=True)
605
+ class LedgerReceiptsResult:
606
+ """Result of ``codespar_ledger`` action=receipts."""
607
+
608
+ receipts: list[AgenticReceipt] = field(default_factory=list)
609
+ count: int = 0
610
+
611
+
612
+ # ── codespar_issue wire shape ──────────────────────────────────────
613
+
614
+
615
+ IssueAction = Literal["card-virtual", "card-physical", "card-control", "card-get"]
616
+
617
+
618
+ @dataclass(slots=True)
619
+ class IssueArgs:
620
+ """Input shape for ``Session.issue`` (Pomelo card issuing).
621
+
622
+ Mirrors the backend's MetaIssueArgs. Asset-agnostic — the program
623
+ currency is set on the card program, not per call.
624
+ - ``card-virtual`` Issue a virtual card (active immediately)
625
+ - ``card-physical`` Issue a physical card (needs shipping_address)
626
+ - ``card-control`` Freeze / unfreeze / cancel an existing card
627
+ - ``card-get`` Read a card's status
628
+ """
629
+
630
+ action: IssueAction
631
+ cardholder_id: str | None = None
632
+ program_id: str | None = None
633
+ card_id: str | None = None
634
+ control: Literal["freeze", "unfreeze", "cancel"] | None = None
635
+ reason: str | None = None
636
+ shipping_address: dict[str, Any] | None = None
637
+ metadata: dict[str, Any] | None = None
638
+
639
+
640
+ @dataclass(slots=True)
641
+ class IssueResult:
642
+ id: str | None = None
643
+ status: str | None = None
644
+ card_type: str | None = None
645
+ last_four: str | None = None
646
+ cardholder_id: str | None = None
647
+ program_id: str | None = None
648
+ raw: Any = None
649
+
650
+
651
+ # ── codespar_shop wire shape ───────────────────────────────────────
652
+
653
+ ShopAction = Literal["search", "checkout", "checkout_status"]
654
+ ShopCheckoutStatus = Literal["in_progress", "ready_for_payment", "canceled"]
655
+
656
+
657
+ @dataclass(slots=True)
658
+ class ShopVariant:
659
+ """One buyable SKU under a ``ShopOffer``.
660
+
661
+ Note the field-name asymmetry the contract documents (not a bug):
662
+ pass this ``sku_id`` as the checkout item's ``variant_id``. The
663
+ product id is NOT buyable — only the SKU is.
664
+ """
665
+
666
+ sku_id: str
667
+ available: bool
668
+ title: str | None = None
669
+ price_minor: int | None = None # integer minor units (centavos)
670
+ currency: str | None = None # ISO-4217, default "BRL"
671
+
672
+
673
+ @dataclass(slots=True)
674
+ class ShopOffer:
675
+ """A flattened catalog offer returned by ``search``."""
676
+
677
+ product_id: str
678
+ available: bool
679
+ variants: list[ShopVariant]
680
+ sku_id: str | None = None
681
+ title: str | None = None
682
+ price_minor: int | None = None
683
+ currency: str | None = None
684
+ image: str | None = None
685
+ url: str | None = None
686
+
687
+
688
+ @dataclass(slots=True)
689
+ class ShopCheckoutItem:
690
+ """A line item for a VTEX-rail ``checkout``."""
691
+
692
+ variant_id: str # pass ShopVariant.sku_id here
693
+ quantity: int | None = None # defaults to 1 when omitted
694
+ seller: str | None = None # VTEX marketplace sub-seller id
695
+
696
+
697
+ @dataclass(slots=True)
698
+ class ShopBuyer:
699
+ """Optional vaulted buyer profile, merged with the saved profile."""
700
+
701
+ name: str | None = None
702
+ email: str | None = None
703
+ cpf: str | None = None
704
+ phone: str | None = None
705
+
706
+
707
+ @dataclass(slots=True)
708
+ class ShopAddress:
709
+ """Optional vaulted delivery address; ``cep`` required when present."""
710
+
711
+ cep: str
712
+ street: str | None = None
713
+ number: str | None = None
714
+ complement: str | None = None
715
+ neighborhood: str | None = None
716
+ city: str | None = None
717
+ state: str | None = None
718
+
719
+
720
+ @dataclass(slots=True)
721
+ class ShopArgs:
722
+ """Input shape for ``Session.shop``.
723
+
724
+ The closed action set is ``search | checkout | checkout_status``
725
+ (default ``search``). Per-action field requirements:
726
+ - ``search``: ``query`` required; ``limit`` clamped 1..20.
727
+ - ``checkout``: ``items`` XOR ``url``, gated by rail (``items``
728
+ for VTEX, ``url`` for the Mercado Livre PDP path).
729
+ - ``checkout_status``: ``checkout_session_id`` required.
730
+
731
+ Mirrors the TS ``ShopArgs`` discriminated union as a single
732
+ dataclass; the ``shop()`` wrapper serializes only the fields the
733
+ given action uses, matching the TS facade's wire payload.
734
+ """
735
+
736
+ action: ShopAction
737
+ query: str | None = None
738
+ limit: int | None = None
739
+ merchant: str | None = None
740
+ items: list[ShopCheckoutItem] | None = None
741
+ url: str | None = None
742
+ consumer_id: str | None = None
743
+ buyer: ShopBuyer | None = None
744
+ address: ShopAddress | None = None
745
+ checkout_session_id: str | None = None
746
+
747
+
748
+ @dataclass(slots=True)
749
+ class ShopSearchResult:
750
+ """Result of ``search``. Zero results is ``products=[]``, not error."""
751
+
752
+ rail: str
753
+ products: list[ShopOffer]
754
+
755
+
756
+ @dataclass(slots=True)
757
+ class ShopCheckoutResult:
758
+ """Result of ``checkout`` — always async, status ``in_progress``."""
759
+
760
+ checkout_session_id: str
761
+ status: Literal["in_progress"]
762
+ message: str | None = None
763
+
764
+
765
+ @dataclass(slots=True)
766
+ class ShopStatusResult:
767
+ """Result of ``checkout_status``.
768
+
769
+ ``pix_copia_e_cola`` + ``total_minor`` are present only at
770
+ ``ready_for_payment``; ``error`` only at ``canceled``.
771
+ """
772
+
773
+ checkout_session_id: str
774
+ status: ShopCheckoutStatus
775
+ rail: str | None = None
776
+ total_minor: int | None = None
777
+ pix_copia_e_cola: str | None = None
778
+ order_status: str | None = None
779
+ error: str | None = None
780
+
781
+
782
+ ShopResult = ShopSearchResult | ShopCheckoutResult | ShopStatusResult
783
+
784
+
462
785
  # ── /v1/tool-calls/:id/payment-status wire shape ───────────────────
463
786
 
464
787
 
@@ -0,0 +1,52 @@
1
+ {
2
+ "search": {
3
+ "rail": "vtex",
4
+ "products": [
5
+ {
6
+ "product_id": "prod_1",
7
+ "sku_id": "sku_1",
8
+ "title": "Ração para gato 1kg",
9
+ "price_minor": 4990,
10
+ "currency": "BRL",
11
+ "image": "https://example.test/img/sku_1.jpg",
12
+ "url": "https://cobasi.test/p/sku_1",
13
+ "available": true,
14
+ "variants": [
15
+ {
16
+ "sku_id": "sku_1",
17
+ "title": "1kg",
18
+ "price_minor": 4990,
19
+ "currency": "BRL",
20
+ "available": true
21
+ }
22
+ ]
23
+ }
24
+ ]
25
+ },
26
+ "search_empty": {
27
+ "rail": "vtex",
28
+ "products": []
29
+ },
30
+ "checkout": {
31
+ "checkout_session_id": "cks_abc123",
32
+ "status": "in_progress",
33
+ "message": "checkout started"
34
+ },
35
+ "status_in_progress": {
36
+ "checkout_session_id": "cks_abc123",
37
+ "status": "in_progress"
38
+ },
39
+ "status_ready_for_payment": {
40
+ "checkout_session_id": "cks_abc123",
41
+ "status": "ready_for_payment",
42
+ "rail": "vtex",
43
+ "total_minor": 4990,
44
+ "pix_copia_e_cola": "00020126580014br.gov.bcb.pix0136cks-fixture5204000053039865802BR6304ABCD",
45
+ "order_status": "pending"
46
+ },
47
+ "status_canceled": {
48
+ "checkout_session_id": "cks_def456",
49
+ "status": "canceled",
50
+ "error": "browser_worker_checkout_failed"
51
+ }
52
+ }
@@ -34,6 +34,16 @@ def test_async_client_stores_project_id() -> None:
34
34
  assert cs.project_id == "prj_0123456789abcdef"
35
35
 
36
36
 
37
+ def test_async_client_rejects_bad_project_id_format() -> None:
38
+ with pytest.raises(ConfigError):
39
+ AsyncCodeSpar(api_key="csk_live_abc", project_id="not-a-project-id")
40
+
41
+
42
+ def test_sync_client_rejects_bad_project_id_format() -> None:
43
+ with pytest.raises(ConfigError):
44
+ CodeSpar(api_key="csk_live_abc", project_id="prj_tooshort")
45
+
46
+
37
47
  def test_sync_client_requires_api_key() -> None:
38
48
  with pytest.raises(ConfigError):
39
49
  CodeSpar(api_key="")
@@ -87,14 +87,14 @@ async def test_session_level_project_id_wins(httpx_mock: HTTPXMock) -> None:
87
87
  )
88
88
 
89
89
  async with AsyncCodeSpar(
90
- api_key="csk_test_x", project_id="prj_clientlevel000000"
90
+ api_key="csk_test_x", project_id="prj_clientlevel00000"
91
91
  ) as cs:
92
- await cs.create("user_123", preset="brazilian", project_id="prj_sessionlevel00000")
92
+ await cs.create("user_123", preset="brazilian", project_id="prj_sessionlevel0000")
93
93
 
94
94
  request = httpx_mock.get_request()
95
95
  assert request is not None
96
96
  # session override wins
97
- assert request.headers["x-codespar-project"] == "prj_sessionlevel00000"
97
+ assert request.headers["x-codespar-project"] == "prj_sessionlevel0000"
98
98
 
99
99
 
100
100
  async def test_send_returns_tool_calls(httpx_mock: HTTPXMock) -> None:
@@ -0,0 +1,212 @@
1
+ """
2
+ codespar_shop Python parity tests.
3
+
4
+ Two layers:
5
+ 1. Round-trip: parse each canonical wire fixture
6
+ (tests/_fixtures/shop_canonical.json — mirrored from the TS
7
+ packages/types/src/testing/shop-fixtures.ts) into the dataclass
8
+ result and assert every field maps one-to-one. This is the
9
+ dict->dataclass parse-helper round-trip the contract requires
10
+ and the guard against TS<->Python wire drift.
11
+ 2. Dispatch: drive ``session.shop(...)`` over a mocked backend and
12
+ assert the action-correct result type + the wire params the
13
+ facade sends.
14
+
15
+ The fixtures are the same JSON the TS conformance fixtures encode, so
16
+ both language surfaces are proven behaviorally identical for the same
17
+ wire payload.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ from pathlib import Path
24
+ from typing import Any
25
+
26
+ import pytest
27
+ from pytest_httpx import HTTPXMock
28
+
29
+ from codespar import (
30
+ AsyncCodeSpar,
31
+ ShopArgs,
32
+ ShopCheckoutItem,
33
+ ShopCheckoutResult,
34
+ ShopSearchResult,
35
+ ShopStatusResult,
36
+ )
37
+ from codespar._async_session import _parse_shop_result
38
+
39
+ FIXTURE_PATH = Path(__file__).parent / "_fixtures" / "shop_canonical.json"
40
+ FIXTURES: dict[str, Any] = json.loads(FIXTURE_PATH.read_text(encoding="utf-8"))
41
+
42
+
43
+ def test_search_fixture_round_trips() -> None:
44
+ result = _parse_shop_result(FIXTURES["search"], "search")
45
+ assert isinstance(result, ShopSearchResult)
46
+ assert result.rail == "vtex"
47
+ assert len(result.products) == 1
48
+ offer = result.products[0]
49
+ assert offer.product_id == "prod_1"
50
+ assert offer.sku_id == "sku_1"
51
+ assert offer.price_minor == 4990
52
+ assert offer.currency == "BRL"
53
+ assert offer.available is True
54
+ assert len(offer.variants) == 1
55
+ # The buyable SKU is on the variant — pass it as checkout variant_id.
56
+ assert offer.variants[0].sku_id == "sku_1"
57
+ assert offer.variants[0].available is True
58
+
59
+
60
+ def test_empty_search_is_success_not_error() -> None:
61
+ result = _parse_shop_result(FIXTURES["search_empty"], "search")
62
+ assert isinstance(result, ShopSearchResult)
63
+ assert result.products == []
64
+
65
+
66
+ def test_checkout_fixture_round_trips() -> None:
67
+ result = _parse_shop_result(FIXTURES["checkout"], "checkout")
68
+ assert isinstance(result, ShopCheckoutResult)
69
+ assert result.checkout_session_id == "cks_abc123"
70
+ assert result.status == "in_progress"
71
+ assert result.message == "checkout started"
72
+
73
+
74
+ def test_status_ready_for_payment_exposes_typed_pix() -> None:
75
+ result = _parse_shop_result(
76
+ FIXTURES["status_ready_for_payment"], "checkout_status"
77
+ )
78
+ assert isinstance(result, ShopStatusResult)
79
+ assert result.status == "ready_for_payment"
80
+ assert result.total_minor == 4990
81
+ assert result.pix_copia_e_cola is not None
82
+ assert "br.gov.bcb.pix" in result.pix_copia_e_cola
83
+ assert result.order_status == "pending"
84
+ assert result.error is None
85
+
86
+
87
+ def test_status_canceled_exposes_typed_error() -> None:
88
+ result = _parse_shop_result(
89
+ FIXTURES["status_canceled"], "checkout_status"
90
+ )
91
+ assert isinstance(result, ShopStatusResult)
92
+ assert result.status == "canceled"
93
+ assert result.error == "browser_worker_checkout_failed"
94
+ assert result.pix_copia_e_cola is None
95
+
96
+
97
+ def _session_json(**overrides: Any) -> dict[str, Any]:
98
+ return {
99
+ "id": "ses_shop",
100
+ "org_id": "org_test",
101
+ "user_id": "consumer_123",
102
+ "servers": ["cobasi"],
103
+ "status": "active",
104
+ "created_at": "2026-04-21T12:00:00Z",
105
+ "closed_at": None,
106
+ **overrides,
107
+ }
108
+
109
+
110
+ def _execute_response_json(data: Any) -> dict[str, Any]:
111
+ return {
112
+ "success": True,
113
+ "data": data,
114
+ "error": None,
115
+ "duration": 5,
116
+ "server": "_codespar",
117
+ "tool": "codespar_shop",
118
+ }
119
+
120
+
121
+ @pytest.mark.asyncio
122
+ async def test_shop_search_dispatch(httpx_mock: HTTPXMock) -> None:
123
+ httpx_mock.add_response(
124
+ url="https://api.codespar.dev/v1/sessions",
125
+ method="POST",
126
+ json=_session_json(),
127
+ )
128
+ httpx_mock.add_response(
129
+ url="https://api.codespar.dev/v1/sessions/ses_shop/execute",
130
+ method="POST",
131
+ json=_execute_response_json(FIXTURES["search"]),
132
+ )
133
+
134
+ async with AsyncCodeSpar(api_key="csk_test_x") as cs:
135
+ session = await cs.create("consumer_123", preset="brazilian")
136
+ result = await session.shop(
137
+ ShopArgs(action="search", query="ração para gato", merchant="cobasi", limit=10)
138
+ )
139
+ assert isinstance(result, ShopSearchResult)
140
+ assert result.products[0].variants[0].sku_id == "sku_1"
141
+
142
+ # The facade serialized only the search fields.
143
+ requests = httpx_mock.get_requests()
144
+ exec_req = next(r for r in requests if r.url.path.endswith("/execute"))
145
+ body = json.loads(exec_req.content)
146
+ assert body["tool"] == "codespar_shop"
147
+ assert body["input"] == {
148
+ "action": "search",
149
+ "query": "ração para gato",
150
+ "limit": 10,
151
+ "merchant": "cobasi",
152
+ }
153
+
154
+
155
+ @pytest.mark.asyncio
156
+ async def test_shop_checkout_serializes_items(httpx_mock: HTTPXMock) -> None:
157
+ httpx_mock.add_response(
158
+ url="https://api.codespar.dev/v1/sessions",
159
+ method="POST",
160
+ json=_session_json(),
161
+ )
162
+ httpx_mock.add_response(
163
+ url="https://api.codespar.dev/v1/sessions/ses_shop/execute",
164
+ method="POST",
165
+ json=_execute_response_json(FIXTURES["checkout"]),
166
+ )
167
+
168
+ async with AsyncCodeSpar(api_key="csk_test_x") as cs:
169
+ session = await cs.create("consumer_123", preset="brazilian")
170
+ result = await session.shop(
171
+ ShopArgs(
172
+ action="checkout",
173
+ merchant="cobasi",
174
+ items=[ShopCheckoutItem(variant_id="sku_1", quantity=1)],
175
+ consumer_id="consumer_123",
176
+ )
177
+ )
178
+ assert isinstance(result, ShopCheckoutResult)
179
+ assert result.status == "in_progress"
180
+
181
+ requests = httpx_mock.get_requests()
182
+ exec_req = next(r for r in requests if r.url.path.endswith("/execute"))
183
+ body = json.loads(exec_req.content)
184
+ assert body["input"]["action"] == "checkout"
185
+ assert body["input"]["items"] == [{"variant_id": "sku_1", "quantity": 1}]
186
+ assert body["input"]["consumer_id"] == "consumer_123"
187
+
188
+
189
+ @pytest.mark.asyncio
190
+ async def test_shop_failure_raises(httpx_mock: HTTPXMock) -> None:
191
+ httpx_mock.add_response(
192
+ url="https://api.codespar.dev/v1/sessions",
193
+ method="POST",
194
+ json=_session_json(),
195
+ )
196
+ httpx_mock.add_response(
197
+ url="https://api.codespar.dev/v1/sessions/ses_shop/execute",
198
+ method="POST",
199
+ json={
200
+ "success": False,
201
+ "data": None,
202
+ "error": "invalid_args",
203
+ "duration": 1,
204
+ "server": "_codespar",
205
+ "tool": "codespar_shop",
206
+ },
207
+ )
208
+
209
+ async with AsyncCodeSpar(api_key="csk_test_x") as cs:
210
+ session = await cs.create("consumer_123", preset="brazilian")
211
+ with pytest.raises(Exception, match="shop failed: invalid_args"):
212
+ await session.shop(ShopArgs(action="search", query="x"))
File without changes
File without changes
File without changes
File without changes
File without changes