tensorfeed 3.0.0__tar.gz → 3.1.0__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tensorfeed
3
- Version: 3.0.0
3
+ Version: 3.1.0
4
4
  Summary: Python SDK for the TensorFeed.ai API: AI news, status, model pricing, premium routing + history series + webhook watches, LangChain/CrewAI tool factories, agent payments via USDC on Base (optional web3 auto-send)
5
5
  Author-email: "TensorFeed.ai" <feedback@tensorfeed.ai>
6
6
  License-Expression: MIT
@@ -137,19 +137,29 @@ print(preview["recommendation"])
137
137
  ## Premium Tier (paid, USDC on Base)
138
138
 
139
139
  ```python
140
+ import os
141
+ from eth_account import Account # pip install eth-account
142
+ from eth_account.messages import encode_defunct
140
143
  from tensorfeed import TensorFeed, PaymentRequired
141
144
 
142
145
  tf = TensorFeed()
146
+ acct = Account.from_key(os.environ["TENSORFEED_PRIVATE_KEY"]) # NEVER hardcode
143
147
 
144
148
  # Step 1: get a 30-minute quote. sender_wallet is the Base wallet you will
145
149
  # send the USDC from (required); quote['wallet'] below is where you send it.
146
- quote = tf.buy_credits(amount_usd=1.00, sender_wallet="0xYourBaseWallet")
150
+ # The server only quotes for a message signed by sender_wallet, so pass a
151
+ # signer: the SDK builds the message and calls it once.
152
+ quote = tf.buy_credits(
153
+ amount_usd=1.00,
154
+ sender_wallet=acct.address,
155
+ sign_message=lambda m: acct.sign_message(encode_defunct(text=m)).signature.hex(),
156
+ )
147
157
  print(f"Send {quote['amount_usd']} USDC on Base to {quote['wallet']}")
148
158
  print(f"Memo: {quote['memo']} (expires in {quote['ttl_seconds']}s)")
149
159
  print(f"Will get: {quote['credits']} credits")
150
160
 
151
- # Step 2: send the USDC tx with your wallet
152
- # (auto-send via web3 is on the roadmap; for v1.1 you sign and send manually)
161
+ # Step 2: send the USDC tx from sender_wallet
162
+ # (or let tf.purchase_credits() below do steps 1 to 3 for you)
153
163
 
154
164
  # Step 3: confirm with the tx hash
155
165
  result = tf.confirm(tx_hash="0xYOUR_TX_HASH", nonce=quote["memo"])
@@ -206,6 +216,34 @@ tf.delete_watch(watch_id) # remove when done
206
216
  print(tf.balance())
207
217
  ```
208
218
 
219
+ ### Signing the quote request
220
+
221
+ `buy_credits()` requires proof that you control `sender_wallet`: an EIP-191 (`personal_sign`) signature by that wallet over this exact message, lines joined with `\n`:
222
+
223
+ ```
224
+ I am requesting a TensorFeed credit quote for this wallet.
225
+
226
+ wallet: <sender_wallet, 0x address>
227
+ timestamp: <ISO 8601, within 10 minutes of now>
228
+ nonce: <16 to 64 hex chars, fresh per quote>
229
+ ```
230
+
231
+ Pass `sign_message=` (any callable that takes the message string and returns a 0x hex signature) and the SDK fills in the timestamp and a random nonce. If your signer lives elsewhere (a hardware wallet, a remote signing service), build the message yourself and pass both halves:
232
+
233
+ ```python
234
+ from tensorfeed import build_quote_message
235
+
236
+ message = build_quote_message(wallet) # timestamp=now, nonce=random
237
+ signature = my_remote_signer.personal_sign(message)
238
+ quote = tf.buy_credits(
239
+ amount_usd=1.00, sender_wallet=wallet, message=message, signature=signature
240
+ )
241
+ ```
242
+
243
+ Each signed message is good for one quote. The server answers `sender_proof_expired` for a stale timestamp, `sender_proof_invalid` when the signature does not match the wallet, and `sender_proof_replayed` for a reused nonce. Calling `buy_credits()` with no signer and no message raises `ValueError` before any request is sent.
244
+
245
+ Only an externally owned account (a plain private key) can produce this signature. Smart contract wallets cannot, and should pay per call through the x402 `X-PAYMENT` flow instead.
246
+
209
247
  ## Auto-Send (optional, web3 extra)
210
248
 
211
249
  The base SDK keeps you dependency-free, but if you want to skip the manual tx step, install with the `web3` extra:
@@ -214,7 +252,7 @@ The base SDK keeps you dependency-free, but if you want to skip the manual tx st
214
252
  pip install 'tensorfeed[web3]'
215
253
  ```
216
254
 
217
- Then `tf.purchase_credits()` quotes, signs, broadcasts, and confirms in one call:
255
+ Then `tf.purchase_credits()` quotes, signs, broadcasts, and confirms in one call. The same key signs the quote request message and the USDC transfer:
218
256
 
219
257
  ```python
220
258
  import os
@@ -317,7 +355,8 @@ except TensorFeedError as e:
317
355
  | `tf.routing_preview(task=)` | Top-1 routing recommendation (5/day/IP) |
318
356
  | `tf.health()` | API health check |
319
357
  | `tf.payment_info()` | Wallet, pricing, supported payment flows |
320
- | `tf.buy_credits(amount_usd=, sender_wallet=)` | Generate a 30-min payment quote |
358
+ | `tf.buy_credits(amount_usd=, sender_wallet=, sign_message=)` | Generate a 30-min payment quote (or pass `message=` and `signature=` instead of `sign_message=`) |
359
+ | `build_quote_message(wallet, timestamp=, nonce=)` | Build the exact quote message `sender_wallet` signs |
321
360
  | `tf.confirm(tx_hash=, nonce=)` | Verify USDC tx, mint credit token |
322
361
 
323
362
  ### Token-required
@@ -367,7 +406,7 @@ Premium API responses are licensed for inference use only. Use of TensorFeed pre
367
406
 
368
407
  ## No refunds; credits do not expire
369
408
 
370
- All credit purchases are final and non-refundable per Section 17.5 of the [Terms](https://tensorfeed.ai/terms#premium). Credits never expire and are jointly redeemable on tensorfeed.ai and terminalfeed.io. The recommended pattern is to buy in small increments (for example, $1 USDC for 50 credits) until call volume is calibrated, then top up as needed.
409
+ All credit purchases are final and non-refundable per Section 17.5 of the [Terms](https://tensorfeed.ai/terms#premium). Credits never expire while the token holds them, and a zero-balance token is deleted 30 days after its last use. The recommended pattern is to buy in small increments (for example, $1 USDC for 50 credits) until call volume is calibrated, then top up as needed.
371
410
 
372
411
  ## Sanctions
373
412
 
@@ -104,19 +104,29 @@ print(preview["recommendation"])
104
104
  ## Premium Tier (paid, USDC on Base)
105
105
 
106
106
  ```python
107
+ import os
108
+ from eth_account import Account # pip install eth-account
109
+ from eth_account.messages import encode_defunct
107
110
  from tensorfeed import TensorFeed, PaymentRequired
108
111
 
109
112
  tf = TensorFeed()
113
+ acct = Account.from_key(os.environ["TENSORFEED_PRIVATE_KEY"]) # NEVER hardcode
110
114
 
111
115
  # Step 1: get a 30-minute quote. sender_wallet is the Base wallet you will
112
116
  # send the USDC from (required); quote['wallet'] below is where you send it.
113
- quote = tf.buy_credits(amount_usd=1.00, sender_wallet="0xYourBaseWallet")
117
+ # The server only quotes for a message signed by sender_wallet, so pass a
118
+ # signer: the SDK builds the message and calls it once.
119
+ quote = tf.buy_credits(
120
+ amount_usd=1.00,
121
+ sender_wallet=acct.address,
122
+ sign_message=lambda m: acct.sign_message(encode_defunct(text=m)).signature.hex(),
123
+ )
114
124
  print(f"Send {quote['amount_usd']} USDC on Base to {quote['wallet']}")
115
125
  print(f"Memo: {quote['memo']} (expires in {quote['ttl_seconds']}s)")
116
126
  print(f"Will get: {quote['credits']} credits")
117
127
 
118
- # Step 2: send the USDC tx with your wallet
119
- # (auto-send via web3 is on the roadmap; for v1.1 you sign and send manually)
128
+ # Step 2: send the USDC tx from sender_wallet
129
+ # (or let tf.purchase_credits() below do steps 1 to 3 for you)
120
130
 
121
131
  # Step 3: confirm with the tx hash
122
132
  result = tf.confirm(tx_hash="0xYOUR_TX_HASH", nonce=quote["memo"])
@@ -173,6 +183,34 @@ tf.delete_watch(watch_id) # remove when done
173
183
  print(tf.balance())
174
184
  ```
175
185
 
186
+ ### Signing the quote request
187
+
188
+ `buy_credits()` requires proof that you control `sender_wallet`: an EIP-191 (`personal_sign`) signature by that wallet over this exact message, lines joined with `\n`:
189
+
190
+ ```
191
+ I am requesting a TensorFeed credit quote for this wallet.
192
+
193
+ wallet: <sender_wallet, 0x address>
194
+ timestamp: <ISO 8601, within 10 minutes of now>
195
+ nonce: <16 to 64 hex chars, fresh per quote>
196
+ ```
197
+
198
+ Pass `sign_message=` (any callable that takes the message string and returns a 0x hex signature) and the SDK fills in the timestamp and a random nonce. If your signer lives elsewhere (a hardware wallet, a remote signing service), build the message yourself and pass both halves:
199
+
200
+ ```python
201
+ from tensorfeed import build_quote_message
202
+
203
+ message = build_quote_message(wallet) # timestamp=now, nonce=random
204
+ signature = my_remote_signer.personal_sign(message)
205
+ quote = tf.buy_credits(
206
+ amount_usd=1.00, sender_wallet=wallet, message=message, signature=signature
207
+ )
208
+ ```
209
+
210
+ Each signed message is good for one quote. The server answers `sender_proof_expired` for a stale timestamp, `sender_proof_invalid` when the signature does not match the wallet, and `sender_proof_replayed` for a reused nonce. Calling `buy_credits()` with no signer and no message raises `ValueError` before any request is sent.
211
+
212
+ Only an externally owned account (a plain private key) can produce this signature. Smart contract wallets cannot, and should pay per call through the x402 `X-PAYMENT` flow instead.
213
+
176
214
  ## Auto-Send (optional, web3 extra)
177
215
 
178
216
  The base SDK keeps you dependency-free, but if you want to skip the manual tx step, install with the `web3` extra:
@@ -181,7 +219,7 @@ The base SDK keeps you dependency-free, but if you want to skip the manual tx st
181
219
  pip install 'tensorfeed[web3]'
182
220
  ```
183
221
 
184
- Then `tf.purchase_credits()` quotes, signs, broadcasts, and confirms in one call:
222
+ Then `tf.purchase_credits()` quotes, signs, broadcasts, and confirms in one call. The same key signs the quote request message and the USDC transfer:
185
223
 
186
224
  ```python
187
225
  import os
@@ -284,7 +322,8 @@ except TensorFeedError as e:
284
322
  | `tf.routing_preview(task=)` | Top-1 routing recommendation (5/day/IP) |
285
323
  | `tf.health()` | API health check |
286
324
  | `tf.payment_info()` | Wallet, pricing, supported payment flows |
287
- | `tf.buy_credits(amount_usd=, sender_wallet=)` | Generate a 30-min payment quote |
325
+ | `tf.buy_credits(amount_usd=, sender_wallet=, sign_message=)` | Generate a 30-min payment quote (or pass `message=` and `signature=` instead of `sign_message=`) |
326
+ | `build_quote_message(wallet, timestamp=, nonce=)` | Build the exact quote message `sender_wallet` signs |
288
327
  | `tf.confirm(tx_hash=, nonce=)` | Verify USDC tx, mint credit token |
289
328
 
290
329
  ### Token-required
@@ -334,7 +373,7 @@ Premium API responses are licensed for inference use only. Use of TensorFeed pre
334
373
 
335
374
  ## No refunds; credits do not expire
336
375
 
337
- All credit purchases are final and non-refundable per Section 17.5 of the [Terms](https://tensorfeed.ai/terms#premium). Credits never expire and are jointly redeemable on tensorfeed.ai and terminalfeed.io. The recommended pattern is to buy in small increments (for example, $1 USDC for 50 credits) until call volume is calibrated, then top up as needed.
376
+ All credit purchases are final and non-refundable per Section 17.5 of the [Terms](https://tensorfeed.ai/terms#premium). Credits never expire while the token holds them, and a zero-balance token is deleted 30 days after its last use. The recommended pattern is to buy in small increments (for example, $1 USDC for 50 credits) until call volume is calibrated, then top up as needed.
338
377
 
339
378
  ## Sanctions
340
379
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "tensorfeed"
7
- version = "3.0.0"
7
+ version = "3.1.0"
8
8
  description = "Python SDK for the TensorFeed.ai API: AI news, status, model pricing, premium routing + history series + webhook watches, LangChain/CrewAI tool factories, agent payments via USDC on Base (optional web3 auto-send)"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -13,6 +13,7 @@ from .client import (
13
13
  TensorFeedError,
14
14
  PaymentRequired,
15
15
  RateLimited,
16
+ build_quote_message,
16
17
  )
17
18
 
18
19
  __all__ = [
@@ -20,5 +21,6 @@ __all__ = [
20
21
  "TensorFeedError",
21
22
  "PaymentRequired",
22
23
  "RateLimited",
24
+ "build_quote_message",
23
25
  ]
24
- __version__ = "3.0.0"
26
+ __version__ = "3.1.0"
@@ -6,19 +6,106 @@ agent activity) plus the paid premium tier (routing, payment flow).
6
6
 
7
7
  Premium endpoints are paid via USDC on Base. No accounts, no API keys,
8
8
  no traditional payment processors. See ``buy_credits()`` and ``confirm()``.
9
+
10
+ A credit quote requires proof that you control the paying wallet:
11
+ ``buy_credits()`` sends a short message signed by ``sender_wallet``
12
+ (EIP-191 personal_sign). Pass ``sign_message=`` and the SDK builds the
13
+ message for you, or build it yourself with ``build_quote_message()``.
14
+ Smart contract wallets cannot produce this signature and should pay
15
+ through the x402 X-PAYMENT flow instead.
9
16
  """
10
17
 
11
18
  from __future__ import annotations
12
19
 
13
20
  import json
21
+ import secrets
14
22
  import urllib.error
15
23
  import urllib.parse
16
24
  import urllib.request
17
- from typing import Any # noqa: F401 (re-exported by purchase_credits return type)
25
+ from datetime import datetime, timezone
26
+ from typing import Any, Callable
18
27
 
19
28
 
20
29
  DEFAULT_BASE_URL = "https://tensorfeed.ai/api"
21
- DEFAULT_USER_AGENT = "TensorFeed-SDK-Python/3.0"
30
+ DEFAULT_USER_AGENT = "TensorFeed-SDK-Python/3.1"
31
+
32
+ # First line of the credit quote message. The server requires it to
33
+ # match exactly, so a message signed for another flow is never accepted.
34
+ QUOTE_PROOF_HEADER = "I am requesting a TensorFeed credit quote for this wallet."
35
+
36
+
37
+ def _utc_now_iso() -> str:
38
+ """Current UTC time as ISO 8601 with milliseconds and a Z suffix."""
39
+ return (
40
+ datetime.now(timezone.utc)
41
+ .isoformat(timespec="milliseconds")
42
+ .replace("+00:00", "Z")
43
+ )
44
+
45
+
46
+ def build_quote_message(
47
+ wallet: str,
48
+ timestamp: str | None = None,
49
+ nonce: str | None = None,
50
+ ) -> str:
51
+ """Build the exact message ``sender_wallet`` signs for a credit quote.
52
+
53
+ The layout is fixed and must be signed byte for byte (lines joined
54
+ with LF)::
55
+
56
+ I am requesting a TensorFeed credit quote for this wallet.
57
+
58
+ wallet: 0x...
59
+ timestamp: 2026-11-06T12:00:00.000Z
60
+ nonce: 3f9c...
61
+
62
+ Sign the returned string with EIP-191 personal_sign using the key
63
+ for ``wallet``, then pass it to ``TensorFeed.buy_credits()`` as
64
+ ``message`` with the resulting ``signature``. Each message works for
65
+ one quote only.
66
+
67
+ Args:
68
+ wallet: The 0x address that will send the USDC (the same value
69
+ you pass as ``sender_wallet``).
70
+ timestamp: ISO 8601 time. Defaults to now in UTC. The server
71
+ accepts it within 10 minutes of its clock, and at most 60
72
+ seconds in the future.
73
+ nonce: 16 to 64 hex characters, fresh per quote. Defaults to
74
+ ``secrets.token_hex(16)``.
75
+ """
76
+ if timestamp is None:
77
+ timestamp = _utc_now_iso()
78
+ if nonce is None:
79
+ nonce = secrets.token_hex(16)
80
+ return "\n".join(
81
+ [
82
+ QUOTE_PROOF_HEADER,
83
+ "",
84
+ f"wallet: {wallet}",
85
+ f"timestamp: {timestamp}",
86
+ f"nonce: {nonce}",
87
+ ]
88
+ )
89
+
90
+
91
+ def _normalize_signature(signature: Any) -> str:
92
+ """Return a signature as a 0x-prefixed hex string.
93
+
94
+ Signers differ: eth_account returns HexBytes, whose ``.hex()`` has
95
+ the 0x prefix on some versions and not on others. Accept raw bytes
96
+ or hex with or without the prefix.
97
+ """
98
+ if isinstance(signature, (bytes, bytearray)):
99
+ return "0x" + bytes(signature).hex()
100
+ if not isinstance(signature, str):
101
+ raise TypeError(
102
+ "signature must be a 0x-prefixed hex string (or bytes), "
103
+ f"got {type(signature).__name__}"
104
+ )
105
+ sig = signature.strip()
106
+ if sig[:2].lower() != "0x":
107
+ sig = "0x" + sig
108
+ return sig
22
109
 
23
110
 
24
111
  class TensorFeedError(Exception):
@@ -59,9 +146,20 @@ class TensorFeed:
59
146
  news = tf.news(limit=10)
60
147
  preview = tf.routing_preview(task="code")
61
148
 
62
- # Paid: buy credits, then call routing
63
- quote = tf.buy_credits(amount_usd=1.00)
64
- # ... send USDC tx ...
149
+ # Paid: buy credits, then call routing. The quote needs a
150
+ # message signed by sender_wallet (see buy_credits()).
151
+ from eth_account import Account
152
+ from eth_account.messages import encode_defunct
153
+
154
+ acct = Account.from_key(os.environ["TENSORFEED_PRIVATE_KEY"])
155
+ quote = tf.buy_credits(
156
+ amount_usd=1.00,
157
+ sender_wallet=acct.address,
158
+ sign_message=lambda m: acct.sign_message(
159
+ encode_defunct(text=m)
160
+ ).signature.hex(),
161
+ )
162
+ # ... send USDC tx from acct.address ...
65
163
  result = tf.confirm(tx_hash="0x...", nonce=quote["memo"])
66
164
  # token is auto-stored on the client
67
165
  rec = tf.routing(task="code", top_n=5)
@@ -608,28 +706,101 @@ class TensorFeed:
608
706
  """
609
707
  return self._get("/payment/info")
610
708
 
611
- def buy_credits(self, *, amount_usd: float, sender_wallet: str) -> dict[str, Any]:
709
+ def buy_credits(
710
+ self,
711
+ *,
712
+ amount_usd: float,
713
+ sender_wallet: str,
714
+ sign_message: Callable[[str], str] | None = None,
715
+ message: str | None = None,
716
+ signature: str | None = None,
717
+ ) -> dict[str, Any]:
612
718
  """Generate a 30-min payment quote bound to a sender wallet.
613
719
 
614
720
  Returns a dict with ``wallet``, ``memo``, ``amount_usd``, ``credits``,
615
721
  ``sender_wallet``, ``expires_at``, ``ttl_seconds``, ``next_step``.
616
722
 
723
+ The server issues a quote only for a message signed by
724
+ ``sender_wallet`` (EIP-191 personal_sign), so supply one of:
725
+
726
+ * ``sign_message``: a callable that takes the message string and
727
+ returns its signature as 0x hex. The SDK builds the message
728
+ (UTC timestamp, fresh random nonce) and calls it once.
729
+ * ``message`` plus ``signature``: a message you built with
730
+ ``build_quote_message()`` and signed yourself. Each signed
731
+ message is single use.
732
+
733
+ With eth_account::
734
+
735
+ from eth_account import Account
736
+ from eth_account.messages import encode_defunct
737
+
738
+ acct = Account.from_key(os.environ["TENSORFEED_PRIVATE_KEY"])
739
+ quote = tf.buy_credits(
740
+ amount_usd=1.00,
741
+ sender_wallet=acct.address,
742
+ sign_message=lambda m: acct.sign_message(
743
+ encode_defunct(text=m)
744
+ ).signature.hex(),
745
+ )
746
+
747
+ Only an externally owned account (a plain private key) can sign
748
+ this. A smart contract wallet cannot, and should pay per call
749
+ through the x402 X-PAYMENT flow instead.
750
+
617
751
  Send the USDC on Base FROM ``sender_wallet`` TO ``wallet``, then
618
752
  call ``confirm()`` with the tx hash and the returned ``memo`` as
619
753
  nonce. The on-chain sender must match ``sender_wallet`` exactly
620
- or ``confirm()`` will reject with ``sender_mismatch``. This is
621
- the post-2026-05-05 binding that closes the public-mempool tx
622
- sniper attack.
754
+ or ``confirm()`` will reject with ``sender_mismatch``.
623
755
 
624
756
  Args:
625
757
  amount_usd: USD value of credits to buy. Must be 0.5 to 10000.
626
758
  Volume discounts apply at $5 (10%), $30 (25%), $200 (40%).
627
759
  sender_wallet: 0x-prefixed 40-char EVM address that will sign
628
760
  the on-chain USDC transfer. Required.
761
+ sign_message: Callable that signs the quote message with the
762
+ key for ``sender_wallet`` and returns the signature (0x
763
+ hex string; raw bytes are also accepted).
764
+ message: A pre-built quote message from
765
+ ``build_quote_message()``. Use with ``signature``.
766
+ signature: The EIP-191 signature of ``message`` by
767
+ ``sender_wallet`` (0x followed by 130 hex characters).
768
+
769
+ Raises:
770
+ ValueError: if no signer and no message plus signature is
771
+ supplied, or if both are. Raised before any HTTP call.
772
+ TensorFeedError: on a server-side rejection, for example
773
+ ``sender_proof_expired`` (timestamp outside the 10 minute
774
+ window), ``sender_proof_invalid`` (signature does not
775
+ match), or ``sender_proof_replayed`` (nonce already used).
629
776
  """
777
+ if sign_message is not None:
778
+ if message is not None or signature is not None:
779
+ raise ValueError(
780
+ "buy_credits(): pass either sign_message or message plus "
781
+ "signature, not both."
782
+ )
783
+ message = build_quote_message(sender_wallet.strip())
784
+ signature = sign_message(message)
785
+ elif message is None or signature is None:
786
+ raise ValueError(
787
+ "buy_credits() needs proof that you control sender_wallet. "
788
+ "TensorFeed now issues a credit quote only for a message "
789
+ "signed by sender_wallet (EIP-191 personal_sign). Pass "
790
+ "sign_message=<callable that signs a str and returns a 0x hex "
791
+ "signature>, or pass message= (from build_quote_message()) "
792
+ "and signature=. Smart contract wallets cannot sign this; "
793
+ "pay through the x402 X-PAYMENT flow instead. See "
794
+ "https://tensorfeed.ai/developers/agent-payments#sender-proof"
795
+ )
630
796
  return self._post(
631
797
  "/payment/buy-credits",
632
- {"amount_usd": amount_usd, "sender_wallet": sender_wallet},
798
+ {
799
+ "amount_usd": amount_usd,
800
+ "sender_wallet": sender_wallet,
801
+ "message": message,
802
+ "signature": _normalize_signature(signature),
803
+ },
633
804
  )
634
805
 
635
806
  def confirm(
@@ -709,9 +880,11 @@ class TensorFeed:
709
880
  """Buy credits end-to-end: quote, sign USDC tx, broadcast, confirm.
710
881
 
711
882
  Convenience wrapper around buy_credits() + raw signing + confirm()
712
- that handles the whole flow in one call. The token is auto-stored
713
- on this client on success, so subsequent ``routing()`` calls work
714
- immediately.
883
+ that handles the whole flow in one call. The same key signs the
884
+ quote request message and the USDC transfer, so it must be an
885
+ externally owned account (not a smart contract wallet). The token
886
+ is auto-stored on this client on success, so subsequent
887
+ ``routing()`` calls work immediately.
715
888
 
716
889
  Requires the optional ``web3`` extra:
717
890
  pip install 'tensorfeed[web3]'
@@ -14,10 +14,12 @@ Security:
14
14
  Cloudflare Workers and similar environments should keep the key in
15
15
  a secret manager.
16
16
 
17
- For higher security, sign transactions externally (e.g., via a
18
- hardware wallet or remote signer) and call ``TensorFeed.confirm()``
19
- with the resulting tx hash directly. The auto-send path here is for
20
- server-side agents that hold their own keys.
17
+ For higher security, sign externally (e.g., via a hardware wallet or
18
+ remote signer): pass that signer to
19
+ ``TensorFeed.buy_credits(sign_message=...)`` for the quote, send the
20
+ USDC transfer yourself, and call ``TensorFeed.confirm()`` with the
21
+ resulting tx hash. The auto-send path here is for server-side agents
22
+ that hold their own keys.
21
23
  """
22
24
 
23
25
  from __future__ import annotations
@@ -63,14 +65,19 @@ def auto_purchase_credits(
63
65
  ) -> dict[str, Any]:
64
66
  """Buy credits end-to-end: quote, sign USDC tx, broadcast, confirm.
65
67
 
68
+ The same key signs the quote request message (EIP-191, required by
69
+ ``buy_credits()``) and the on-chain USDC transfer, so the key must
70
+ belong to an externally owned account. Smart contract wallets cannot
71
+ use this flow; pay through the x402 X-PAYMENT flow instead.
72
+
66
73
  Args:
67
74
  tf: The TensorFeed client. Token is auto-stored on success so
68
75
  subsequent calls to ``tf.routing()`` etc. work immediately.
69
76
  amount_usd: USD value of credits to buy. Must be 0.5 to 10000.
70
77
  private_key: 0x-prefixed Ethereum private key for signing the
71
- USDC transfer. NEVER hardcode this; read from a secure env
72
- var. The key is held in memory only for the duration of
73
- this call.
78
+ quote message and the USDC transfer. NEVER hardcode this;
79
+ read from a secure env var. The key is held in memory only
80
+ for the duration of this call.
74
81
  rpc_url: Base mainnet RPC endpoint. Defaults to the free public
75
82
  Base RPC, which is fine for occasional use. For production
76
83
  use Alchemy or QuickNode.
@@ -91,11 +98,13 @@ def auto_purchase_credits(
91
98
  try:
92
99
  from web3 import Web3
93
100
  from eth_account import Account
101
+ from eth_account.messages import encode_defunct
94
102
  except ImportError as e:
95
103
  raise Web3NotInstalled(
96
104
  "web3 is not installed. Run: pip install 'tensorfeed[web3]'\n"
97
- "Then retry, or use the manual flow: tf.buy_credits() + sign "
98
- "the USDC tx yourself + tf.confirm(tx_hash=..., nonce=...)."
105
+ "Then retry, or use the manual flow: tf.buy_credits(..., "
106
+ "sign_message=...) + sign the USDC tx yourself + "
107
+ "tf.confirm(tx_hash=..., nonce=...)."
99
108
  ) from e
100
109
 
101
110
  rpc = rpc_url or DEFAULT_BASE_RPC
@@ -107,9 +116,19 @@ def auto_purchase_credits(
107
116
  sender = account.address
108
117
 
109
118
  # Step 1: TensorFeed-side quote, bound to this sender wallet so the
110
- # post-2026-05-05 Tx-Sniper guard accepts the confirm. Must pass the
111
- # exact lowercased address that will sign the on-chain transfer.
112
- quote = tf.buy_credits(amount_usd=amount_usd, sender_wallet=sender.lower())
119
+ # confirm accepts the on-chain transfer. Must pass the exact
120
+ # lowercased address that will sign that transfer. The server only
121
+ # quotes for a message signed by that wallet, so sign it here with
122
+ # the same key (EIP-191 personal_sign).
123
+ def _sign_quote_message(message: str) -> str:
124
+ signed = account.sign_message(encode_defunct(text=message))
125
+ return "0x" + bytes(signed.signature).hex()
126
+
127
+ quote = tf.buy_credits(
128
+ amount_usd=amount_usd,
129
+ sender_wallet=sender.lower(),
130
+ sign_message=_sign_quote_message,
131
+ )
113
132
  receiving_wallet = quote["wallet"]
114
133
  memo = quote["memo"]
115
134
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tensorfeed
3
- Version: 3.0.0
3
+ Version: 3.1.0
4
4
  Summary: Python SDK for the TensorFeed.ai API: AI news, status, model pricing, premium routing + history series + webhook watches, LangChain/CrewAI tool factories, agent payments via USDC on Base (optional web3 auto-send)
5
5
  Author-email: "TensorFeed.ai" <feedback@tensorfeed.ai>
6
6
  License-Expression: MIT
@@ -137,19 +137,29 @@ print(preview["recommendation"])
137
137
  ## Premium Tier (paid, USDC on Base)
138
138
 
139
139
  ```python
140
+ import os
141
+ from eth_account import Account # pip install eth-account
142
+ from eth_account.messages import encode_defunct
140
143
  from tensorfeed import TensorFeed, PaymentRequired
141
144
 
142
145
  tf = TensorFeed()
146
+ acct = Account.from_key(os.environ["TENSORFEED_PRIVATE_KEY"]) # NEVER hardcode
143
147
 
144
148
  # Step 1: get a 30-minute quote. sender_wallet is the Base wallet you will
145
149
  # send the USDC from (required); quote['wallet'] below is where you send it.
146
- quote = tf.buy_credits(amount_usd=1.00, sender_wallet="0xYourBaseWallet")
150
+ # The server only quotes for a message signed by sender_wallet, so pass a
151
+ # signer: the SDK builds the message and calls it once.
152
+ quote = tf.buy_credits(
153
+ amount_usd=1.00,
154
+ sender_wallet=acct.address,
155
+ sign_message=lambda m: acct.sign_message(encode_defunct(text=m)).signature.hex(),
156
+ )
147
157
  print(f"Send {quote['amount_usd']} USDC on Base to {quote['wallet']}")
148
158
  print(f"Memo: {quote['memo']} (expires in {quote['ttl_seconds']}s)")
149
159
  print(f"Will get: {quote['credits']} credits")
150
160
 
151
- # Step 2: send the USDC tx with your wallet
152
- # (auto-send via web3 is on the roadmap; for v1.1 you sign and send manually)
161
+ # Step 2: send the USDC tx from sender_wallet
162
+ # (or let tf.purchase_credits() below do steps 1 to 3 for you)
153
163
 
154
164
  # Step 3: confirm with the tx hash
155
165
  result = tf.confirm(tx_hash="0xYOUR_TX_HASH", nonce=quote["memo"])
@@ -206,6 +216,34 @@ tf.delete_watch(watch_id) # remove when done
206
216
  print(tf.balance())
207
217
  ```
208
218
 
219
+ ### Signing the quote request
220
+
221
+ `buy_credits()` requires proof that you control `sender_wallet`: an EIP-191 (`personal_sign`) signature by that wallet over this exact message, lines joined with `\n`:
222
+
223
+ ```
224
+ I am requesting a TensorFeed credit quote for this wallet.
225
+
226
+ wallet: <sender_wallet, 0x address>
227
+ timestamp: <ISO 8601, within 10 minutes of now>
228
+ nonce: <16 to 64 hex chars, fresh per quote>
229
+ ```
230
+
231
+ Pass `sign_message=` (any callable that takes the message string and returns a 0x hex signature) and the SDK fills in the timestamp and a random nonce. If your signer lives elsewhere (a hardware wallet, a remote signing service), build the message yourself and pass both halves:
232
+
233
+ ```python
234
+ from tensorfeed import build_quote_message
235
+
236
+ message = build_quote_message(wallet) # timestamp=now, nonce=random
237
+ signature = my_remote_signer.personal_sign(message)
238
+ quote = tf.buy_credits(
239
+ amount_usd=1.00, sender_wallet=wallet, message=message, signature=signature
240
+ )
241
+ ```
242
+
243
+ Each signed message is good for one quote. The server answers `sender_proof_expired` for a stale timestamp, `sender_proof_invalid` when the signature does not match the wallet, and `sender_proof_replayed` for a reused nonce. Calling `buy_credits()` with no signer and no message raises `ValueError` before any request is sent.
244
+
245
+ Only an externally owned account (a plain private key) can produce this signature. Smart contract wallets cannot, and should pay per call through the x402 `X-PAYMENT` flow instead.
246
+
209
247
  ## Auto-Send (optional, web3 extra)
210
248
 
211
249
  The base SDK keeps you dependency-free, but if you want to skip the manual tx step, install with the `web3` extra:
@@ -214,7 +252,7 @@ The base SDK keeps you dependency-free, but if you want to skip the manual tx st
214
252
  pip install 'tensorfeed[web3]'
215
253
  ```
216
254
 
217
- Then `tf.purchase_credits()` quotes, signs, broadcasts, and confirms in one call:
255
+ Then `tf.purchase_credits()` quotes, signs, broadcasts, and confirms in one call. The same key signs the quote request message and the USDC transfer:
218
256
 
219
257
  ```python
220
258
  import os
@@ -317,7 +355,8 @@ except TensorFeedError as e:
317
355
  | `tf.routing_preview(task=)` | Top-1 routing recommendation (5/day/IP) |
318
356
  | `tf.health()` | API health check |
319
357
  | `tf.payment_info()` | Wallet, pricing, supported payment flows |
320
- | `tf.buy_credits(amount_usd=, sender_wallet=)` | Generate a 30-min payment quote |
358
+ | `tf.buy_credits(amount_usd=, sender_wallet=, sign_message=)` | Generate a 30-min payment quote (or pass `message=` and `signature=` instead of `sign_message=`) |
359
+ | `build_quote_message(wallet, timestamp=, nonce=)` | Build the exact quote message `sender_wallet` signs |
321
360
  | `tf.confirm(tx_hash=, nonce=)` | Verify USDC tx, mint credit token |
322
361
 
323
362
  ### Token-required
@@ -367,7 +406,7 @@ Premium API responses are licensed for inference use only. Use of TensorFeed pre
367
406
 
368
407
  ## No refunds; credits do not expire
369
408
 
370
- All credit purchases are final and non-refundable per Section 17.5 of the [Terms](https://tensorfeed.ai/terms#premium). Credits never expire and are jointly redeemable on tensorfeed.ai and terminalfeed.io. The recommended pattern is to buy in small increments (for example, $1 USDC for 50 credits) until call volume is calibrated, then top up as needed.
409
+ All credit purchases are final and non-refundable per Section 17.5 of the [Terms](https://tensorfeed.ai/terms#premium). Credits never expire while the token holds them, and a zero-balance token is deleted 30 days after its last use. The recommended pattern is to buy in small increments (for example, $1 USDC for 50 credits) until call volume is calibrated, then top up as needed.
371
410
 
372
411
  ## Sanctions
373
412
 
@@ -14,4 +14,5 @@ tensorfeed.egg-info/requires.txt
14
14
  tensorfeed.egg-info/top_level.txt
15
15
  tests/test_premium_auth_header.py
16
16
  tests/test_premium_tools.py
17
+ tests/test_quote_proof.py
17
18
  tests/test_route_verdict.py
@@ -0,0 +1,283 @@
1
+ """Tests for the signed credit quote request (sender proof).
2
+
3
+ /api/payment/buy-credits only issues a quote for a message signed by
4
+ sender_wallet (EIP-191 personal_sign). The server parses the message
5
+ strictly (exact header line, wallet / timestamp / nonce fields), so the
6
+ SDK's builder has to match the template byte for byte, and buy_credits()
7
+ has to post the same message it had signed.
8
+
9
+ Network-free: urlopen is patched and every outgoing request is captured.
10
+ The signature recovery tests need eth_account and skip without it.
11
+
12
+ Run from sdk/python::
13
+
14
+ python -m pytest tests
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import re
21
+ import sys
22
+ import types
23
+ import unittest
24
+ from datetime import datetime, timezone
25
+ from typing import Any
26
+ from unittest import mock
27
+
28
+ import tensorfeed
29
+ from tensorfeed import TensorFeed, build_quote_message
30
+ from tensorfeed import client as client_mod
31
+
32
+ try:
33
+ from eth_account import Account
34
+ from eth_account.messages import encode_defunct
35
+
36
+ HAVE_ETH_ACCOUNT = True
37
+ except ImportError: # pragma: no cover (depends on the environment)
38
+ HAVE_ETH_ACCOUNT = False
39
+
40
+ WALLET = "0x1111111111111111111111111111111111111111"
41
+ FAKE_SIG = "0x" + "ab" * 65
42
+ # Throwaway key built in code so no key-shaped literal sits in source.
43
+ TEST_KEY = "0x" + "11" * 32
44
+
45
+ QUOTE_RESPONSE = {
46
+ "ok": True,
47
+ "wallet": "0x549c82e6bfc54bdae9a2073744cbc2af5d1fc6d1",
48
+ "sender_wallet": WALLET,
49
+ "memo": "tfq_test",
50
+ "amount_usd": 1.0,
51
+ "credits": 50,
52
+ "ttl_seconds": 1800,
53
+ }
54
+
55
+
56
+ class _StubResponse:
57
+ def __init__(self, payload: dict[str, Any]) -> None:
58
+ self._body = json.dumps(payload).encode()
59
+
60
+ def read(self) -> bytes:
61
+ return self._body
62
+
63
+ def __enter__(self) -> "_StubResponse":
64
+ return self
65
+
66
+ def __exit__(self, *exc: object) -> None:
67
+ return None
68
+
69
+
70
+ def _capturing_urlopen(calls: list[dict[str, Any]], payload: dict[str, Any] | None = None):
71
+ def fake_urlopen(req, *a, **kw):
72
+ calls.append(
73
+ {
74
+ "url": req.full_url,
75
+ "method": req.get_method(),
76
+ "headers": {k.lower(): v for k, v in req.header_items()},
77
+ "body": json.loads(req.data.decode()) if req.data else None,
78
+ }
79
+ )
80
+ return _StubResponse(payload or QUOTE_RESPONSE)
81
+
82
+ return fake_urlopen
83
+
84
+
85
+ class BuildQuoteMessage(unittest.TestCase):
86
+ def test_exact_output(self):
87
+ msg = build_quote_message(
88
+ WALLET, timestamp="2026-11-06T12:00:00.000Z", nonce="0123456789abcdef"
89
+ )
90
+ self.assertEqual(
91
+ msg,
92
+ "I am requesting a TensorFeed credit quote for this wallet.\n"
93
+ "\n"
94
+ "wallet: 0x1111111111111111111111111111111111111111\n"
95
+ "timestamp: 2026-11-06T12:00:00.000Z\n"
96
+ "nonce: 0123456789abcdef",
97
+ )
98
+ self.assertNotIn("\r", msg)
99
+ self.assertFalse(msg.endswith("\n"))
100
+
101
+ def test_defaults_use_utc_timestamp_and_fresh_hex_nonce(self):
102
+ before = datetime.now(timezone.utc)
103
+ lines = build_quote_message(WALLET).split("\n")
104
+ after = datetime.now(timezone.utc)
105
+
106
+ self.assertEqual(lines[0], client_mod.QUOTE_PROOF_HEADER)
107
+ self.assertEqual(lines[1], "")
108
+ self.assertEqual(lines[2], f"wallet: {WALLET}")
109
+
110
+ self.assertTrue(lines[3].startswith("timestamp: "))
111
+ ts = lines[3].removeprefix("timestamp: ")
112
+ # Millisecond precision with a Z suffix, the form Date.parse reads.
113
+ self.assertRegex(ts, r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$")
114
+ parsed = datetime.strptime(ts, "%Y-%m-%dT%H:%M:%S.%fZ").replace(tzinfo=timezone.utc)
115
+ # isoformat truncates to the millisecond, so compare at that precision.
116
+ self.assertLessEqual(before.replace(microsecond=before.microsecond // 1000 * 1000), parsed)
117
+ self.assertLessEqual(parsed, after)
118
+
119
+ self.assertTrue(lines[4].startswith("nonce: "))
120
+ nonce = lines[4].removeprefix("nonce: ")
121
+ self.assertRegex(nonce, r"^[0-9a-f]{16,64}$")
122
+ self.assertNotEqual(nonce, build_quote_message(WALLET).split("\n")[4].removeprefix("nonce: "))
123
+
124
+ def test_exported_from_package_root(self):
125
+ self.assertIn("build_quote_message", tensorfeed.__all__)
126
+ self.assertIs(tensorfeed.build_quote_message, client_mod.build_quote_message)
127
+
128
+
129
+ class BuyCreditsSendsTheProof(unittest.TestCase):
130
+ def test_sign_message_posts_all_four_fields(self):
131
+ tf = TensorFeed()
132
+ calls: list[dict[str, Any]] = []
133
+ signed: list[str] = []
134
+
135
+ def signer(message: str) -> str:
136
+ signed.append(message)
137
+ return FAKE_SIG
138
+
139
+ with mock.patch.object(client_mod.urllib.request, "urlopen", _capturing_urlopen(calls)):
140
+ result = tf.buy_credits(amount_usd=1.0, sender_wallet=WALLET, sign_message=signer)
141
+
142
+ self.assertEqual(result, QUOTE_RESPONSE)
143
+ self.assertEqual(len(calls), 1)
144
+ self.assertEqual(len(signed), 1, "the signer must be called exactly once")
145
+ call = calls[0]
146
+ self.assertEqual(call["method"], "POST")
147
+ self.assertTrue(call["url"].endswith("/api/payment/buy-credits"))
148
+ self.assertEqual(
149
+ set(call["body"]), {"amount_usd", "sender_wallet", "message", "signature"}
150
+ )
151
+ self.assertEqual(call["body"]["amount_usd"], 1.0)
152
+ self.assertEqual(call["body"]["sender_wallet"], WALLET)
153
+ self.assertEqual(call["body"]["message"], signed[0])
154
+ self.assertEqual(call["body"]["signature"], FAKE_SIG)
155
+ self.assertEqual(signed[0].split("\n")[2], f"wallet: {WALLET}")
156
+ self.assertNotIn("authorization", call["headers"])
157
+
158
+ def test_signature_is_normalized_to_0x_hex(self):
159
+ tf = TensorFeed()
160
+ bare = "ab" * 65
161
+ for returned in (bare, bytes.fromhex(bare), "0x" + bare):
162
+ calls: list[dict[str, Any]] = []
163
+ with mock.patch.object(client_mod.urllib.request, "urlopen", _capturing_urlopen(calls)):
164
+ tf.buy_credits(
165
+ amount_usd=1.0, sender_wallet=WALLET, sign_message=lambda m, r=returned: r
166
+ )
167
+ self.assertEqual(calls[0]["body"]["signature"], "0x" + bare)
168
+
169
+ def test_prebuilt_message_and_signature_are_posted_unchanged(self):
170
+ tf = TensorFeed()
171
+ msg = build_quote_message(WALLET)
172
+ calls: list[dict[str, Any]] = []
173
+ with mock.patch.object(client_mod.urllib.request, "urlopen", _capturing_urlopen(calls)):
174
+ tf.buy_credits(amount_usd=2.5, sender_wallet=WALLET, message=msg, signature=FAKE_SIG)
175
+ self.assertEqual(calls[0]["body"]["message"], msg)
176
+ self.assertEqual(calls[0]["body"]["signature"], FAKE_SIG)
177
+
178
+
179
+ class BuyCreditsRefusesWithoutProof(unittest.TestCase):
180
+ def _assert_raises_without_http(self, **kwargs: Any) -> ValueError:
181
+ tf = TensorFeed()
182
+ urlopen = mock.MagicMock(side_effect=AssertionError("no HTTP call expected"))
183
+ with mock.patch.object(client_mod.urllib.request, "urlopen", urlopen):
184
+ with self.assertRaises(ValueError) as ctx:
185
+ tf.buy_credits(amount_usd=1.0, sender_wallet=WALLET, **kwargs)
186
+ urlopen.assert_not_called()
187
+ return ctx.exception
188
+
189
+ def test_neither_signer_nor_message(self):
190
+ exc = self._assert_raises_without_http()
191
+ text = str(exc)
192
+ self.assertIn("sign_message", text)
193
+ self.assertIn("signature", text)
194
+ self.assertIn("x402", text)
195
+
196
+ def test_message_without_signature(self):
197
+ self._assert_raises_without_http(message=build_quote_message(WALLET))
198
+
199
+ def test_signature_without_message(self):
200
+ self._assert_raises_without_http(signature=FAKE_SIG)
201
+
202
+ def test_signer_and_message_together(self):
203
+ self._assert_raises_without_http(
204
+ sign_message=lambda m: FAKE_SIG,
205
+ message=build_quote_message(WALLET),
206
+ signature=FAKE_SIG,
207
+ )
208
+
209
+
210
+ @unittest.skipUnless(HAVE_ETH_ACCOUNT, "eth_account is not installed")
211
+ class SignatureRecoversTheWallet(unittest.TestCase):
212
+ def test_eth_account_signer_recovers_sender_wallet(self):
213
+ acct = Account.from_key(TEST_KEY)
214
+ tf = TensorFeed()
215
+ calls: list[dict[str, Any]] = []
216
+
217
+ def signer(message: str) -> str:
218
+ return acct.sign_message(encode_defunct(text=message)).signature.hex()
219
+
220
+ with mock.patch.object(client_mod.urllib.request, "urlopen", _capturing_urlopen(calls)):
221
+ tf.buy_credits(amount_usd=1.0, sender_wallet=acct.address, sign_message=signer)
222
+
223
+ body = calls[0]["body"]
224
+ # Same shape check the server applies before recovery.
225
+ self.assertRegex(body["signature"], r"^0x[0-9a-fA-F]{130}$")
226
+ recovered = Account.recover_message(
227
+ encode_defunct(text=body["message"]), signature=body["signature"]
228
+ )
229
+ self.assertEqual(recovered, acct.address)
230
+ wallet_line = body["message"].split("\n")[2]
231
+ self.assertEqual(wallet_line, f"wallet: {acct.address}")
232
+
233
+ def test_auto_purchase_signs_the_quote_with_its_key(self):
234
+ """purchase_credits() must send a quote proof signed by its own key.
235
+
236
+ web3 is replaced with a stub (only the RPC connectivity check runs
237
+ before the quote), and the transport stops the flow right after
238
+ the buy-credits request, so nothing touches a chain.
239
+ """
240
+
241
+ class _StopAfterQuote(Exception):
242
+ pass
243
+
244
+ class _FakeWeb3:
245
+ class HTTPProvider:
246
+ def __init__(self, *a: object, **kw: object) -> None:
247
+ pass
248
+
249
+ def __init__(self, provider: object) -> None:
250
+ pass
251
+
252
+ def is_connected(self) -> bool:
253
+ return True
254
+
255
+ fake_web3 = types.ModuleType("web3")
256
+ fake_web3.Web3 = _FakeWeb3 # type: ignore[attr-defined]
257
+
258
+ captured: list[dict[str, Any]] = []
259
+
260
+ def fake_urlopen(req, *a, **kw):
261
+ captured.append(json.loads(req.data.decode()))
262
+ raise _StopAfterQuote()
263
+
264
+ acct = Account.from_key(TEST_KEY)
265
+ tf = TensorFeed()
266
+ with mock.patch.dict(sys.modules, {"web3": fake_web3}):
267
+ with mock.patch.object(client_mod.urllib.request, "urlopen", fake_urlopen):
268
+ with self.assertRaises(_StopAfterQuote):
269
+ tf.purchase_credits(amount_usd=1.0, private_key=TEST_KEY)
270
+
271
+ self.assertEqual(len(captured), 1)
272
+ body = captured[0]
273
+ self.assertEqual(body["sender_wallet"], acct.address.lower())
274
+ self.assertRegex(body["signature"], r"^0x[0-9a-fA-F]{130}$")
275
+ recovered = Account.recover_message(
276
+ encode_defunct(text=body["message"]), signature=body["signature"]
277
+ )
278
+ self.assertEqual(recovered.lower(), body["sender_wallet"])
279
+ self.assertTrue(re.match(r"^wallet: 0x[0-9a-f]{40}$", body["message"].split("\n")[2]))
280
+
281
+
282
+ if __name__ == "__main__":
283
+ unittest.main()
File without changes