tiny-erp-py 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,109 @@
1
+ Metadata-Version: 2.4
2
+ Name: tiny-erp-py
3
+ Version: 0.1.0
4
+ Summary: Production-grade Python SDK for the Tiny ERP API v2
5
+ Author: Joaoaalves
6
+ License: MIT
7
+ Keywords: api,erp,sdk,tiny,tiny-erp
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
16
+ Classifier: Typing :: Typed
17
+ Requires-Python: >=3.11
18
+ Requires-Dist: httpx>=0.27
19
+ Requires-Dist: pydantic>=2.0
20
+ Requires-Dist: requests>=2.31
21
+ Provides-Extra: dev
22
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
23
+ Requires-Dist: pytest-httpserver>=1.0; extra == 'dev'
24
+ Requires-Dist: pytest>=8.0; extra == 'dev'
25
+ Requires-Dist: responses>=0.25; extra == 'dev'
26
+ Requires-Dist: respx>=0.20; extra == 'dev'
27
+ Description-Content-Type: text/markdown
28
+
29
+ # tiny-py
30
+
31
+ > Production-grade Python SDK for the **Tiny ERP API v2**.
32
+ > Fully typed, async-ready, and safe for FastAPI services and message queue workers.
33
+
34
+ [![PyPI version](https://img.shields.io/pypi/v/tiny-erp-py.svg)](https://pypi.org/project/tiny-erp-py/)
35
+ [![Python](https://img.shields.io/pypi/pyversions/tiny-erp-py.svg)](https://pypi.org/project/tiny-erp-py/)
36
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
37
+
38
+ ---
39
+
40
+ ## Features
41
+
42
+ - **Single entry point** — all interaction through `TinyClient` or `AsyncTinyClient`
43
+ - **Typed contracts** — every method accepts and returns Pydantic v2 models
44
+ - **Automatic rate limiting** — token bucket per client instance, plan-aware (30 / 60 / 120 RPM)
45
+ - **Retry with exponential backoff** — automatic on 429 / 5xx responses
46
+ - **Sync + Async** — `TinyClient` for workers, `AsyncTinyClient` for FastAPI
47
+ - **No global state** — multiple tokens and plans can coexist in the same process
48
+
49
+ ## Installation
50
+
51
+ ```bash
52
+ pip install tiny-py
53
+ ```
54
+
55
+ Requires Python 3.11+.
56
+
57
+ ## Quick example
58
+
59
+ ```python
60
+ from tiny_py import TinyClient
61
+
62
+ client = TinyClient(token="your_token", plan="advanced")
63
+
64
+ # Stream all active products
65
+ for product in client.products.iter_search():
66
+ print(product.sku, product.name, product.price)
67
+
68
+ # Fetch a single order
69
+ order = client.orders.get("970977594")
70
+ print(order.number, order.total)
71
+ ```
72
+
73
+ ### Async (FastAPI)
74
+
75
+ ```python
76
+ from tiny_py import AsyncTinyClient
77
+
78
+ async with AsyncTinyClient(token="your_token", plan="advanced") as client:
79
+ products = await client.products.search()
80
+ order = await client.orders.get("970977594")
81
+ ```
82
+
83
+ ## API coverage (v0.1.0)
84
+
85
+ | Resource | Methods |
86
+ |----------|---------|
87
+ | **Products** | `search`, `iter_search`, `get`, `get_stock`, `update_stock`, `update_price` |
88
+ | **Orders** | `search`, `iter_search`, `get` |
89
+
90
+ See the [roadmap](https://github.com/your-org/tiny-py) for planned resources.
91
+
92
+ ## Error handling
93
+
94
+ ```python
95
+ from tiny_py.exceptions import TinyAPIError, TinyRateLimitError, TinyServerError, TinyTimeoutError
96
+
97
+ try:
98
+ order = client.orders.get(order_id)
99
+ except TinyAPIError:
100
+ # Business error — do not retry (send to DLQ)
101
+ ...
102
+ except (TinyRateLimitError, TinyServerError, TinyTimeoutError):
103
+ # Transient error — re-enqueue with backoff
104
+ ...
105
+ ```
106
+
107
+ ## License
108
+
109
+ MIT
@@ -0,0 +1,20 @@
1
+ tiny_py/__init__.py,sha256=b1gPw92cDAC4CjiLN8aG6hEkrSVPrkF--qNSL1Tplso,188
2
+ tiny_py/_async_http.py,sha256=Cl1gH5tMMIrboTyQf2xC6QKpvWRetfq6PKHHgLlLeUI,4848
3
+ tiny_py/_client.py,sha256=DDRu0xbHnJmy-7r14Eb98tn-W5vjXfJYV--8U0jf0D4,3359
4
+ tiny_py/_http.py,sha256=u0-kHyItwkr2MaRh0BrBQ2tdg1CM4j1Sc-WkMJeoXg8,5665
5
+ tiny_py/_rate_limiter.py,sha256=N1RRkPD0ko0Er39I_x-S48km_PaSRqKFBgccOUVXGCo,2101
6
+ tiny_py/exceptions.py,sha256=L9KcgdnZCbrdYYPViM9Qynezu_mJ8IZO41nOODqFm5U,750
7
+ tiny_py/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ tiny_py/models/__init__.py,sha256=znKw9DF6F-0krR1Lh2mvjacoE9nmJGcvlH2QD4O6vFE,468
9
+ tiny_py/models/order.py,sha256=m0WaL4aSWgEQssbmoGIPOT4uR9buVSephaWBnwXlri8,1411
10
+ tiny_py/models/product.py,sha256=v5Wy8O-5K1gVn40k81wEbP5p_MVTl6aWlN_f8wEuMqY,1953
11
+ tiny_py/resources/__init__.py,sha256=nMJ3Pf6xYn3anZ25AGcggzibWQIXNyt8aCWCl83egM4,162
12
+ tiny_py/resources/_async_base.py,sha256=pEb1MtEdjvVKC9fkFvrCpH9hTC9aQkFyd-Bn24J0Z-4,1181
13
+ tiny_py/resources/_base.py,sha256=dWTvEjDSFLbbxGdgQSD6i9NrwQzStoTMVE_V0fFEm5Y,1132
14
+ tiny_py/resources/async_orders.py,sha256=CpP0xDfUJN6x2fJf_M3UrwSpDcdOZjr1VMmiDKKr7QA,1241
15
+ tiny_py/resources/async_products.py,sha256=dBOZxMj-Bhf5QgyxiUd2OYUTUmxjP1m_9OqFktVm7d4,2416
16
+ tiny_py/resources/orders.py,sha256=td6vVMKabnz1hZw6Hs5LJQyQ6I_xCFRZQKmVWfAxtH0,1200
17
+ tiny_py/resources/products.py,sha256=1T8bZu9nt9Skww0RQ3uBE6QkTUSVWvN_h8cY-doPlVE,2339
18
+ tiny_erp_py-0.1.0.dist-info/METADATA,sha256=5mtk33Ohir-lneDiOuVBUUu3kcKGdwBNEM2nr-ZK3XE,3357
19
+ tiny_erp_py-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
20
+ tiny_erp_py-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
tiny_py/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ from tiny_py._client import AsyncTinyClient, TinyClient
2
+ from tiny_py import exceptions
3
+ from tiny_py import models
4
+
5
+ __all__ = ["TinyClient", "AsyncTinyClient", "exceptions", "models"]
tiny_py/_async_http.py ADDED
@@ -0,0 +1,135 @@
1
+ import asyncio
2
+ import time
3
+ from typing import Any
4
+
5
+ import httpx
6
+
7
+ from tiny_py._rate_limiter import AsyncRateLimiter
8
+ from tiny_py.exceptions import (
9
+ TinyAPIError,
10
+ TinyAuthError,
11
+ TinyRateLimitError,
12
+ TinyServerError,
13
+ TinyTimeoutError,
14
+ )
15
+
16
+ _RETRYABLE_STATUS = frozenset({429, 500, 502, 503, 504})
17
+
18
+
19
+ class AsyncHTTPAdapter:
20
+ def __init__(
21
+ self,
22
+ token: str,
23
+ rate_limiter: AsyncRateLimiter,
24
+ timeout: tuple[float, float],
25
+ max_retries: int,
26
+ base_url: str,
27
+ ) -> None:
28
+ self._token = token
29
+ self._rate_limiter = rate_limiter
30
+ self._timeout = httpx.Timeout(timeout[1], connect=timeout[0])
31
+ self._max_retries = max_retries
32
+ self._base_url = base_url.rstrip("/")
33
+ self._client = httpx.AsyncClient()
34
+
35
+ def _url(self, endpoint: str) -> str:
36
+ return f"{self._base_url}/{endpoint.lstrip('/')}"
37
+
38
+ def _base_params(self) -> dict[str, str]:
39
+ return {"token": self._token, "formato": "json"}
40
+
41
+ def _parse_retorno(self, data: dict[str, Any], endpoint: str) -> dict[str, Any]:
42
+ retorno: dict[str, Any] = data.get("retorno", {})
43
+ status = retorno.get("status", "").lower()
44
+ if status != "ok":
45
+ erros_raw = retorno.get("erros", [])
46
+ errors: list[str] = []
47
+ for e in erros_raw:
48
+ if isinstance(e, dict):
49
+ errors.append(e.get("erro", str(e)))
50
+ else:
51
+ errors.append(str(e))
52
+ message = errors[0] if errors else "Unknown error"
53
+ if any(
54
+ "token" in err.lower()
55
+ or "autenticação" in err.lower()
56
+ or "autenticacao" in err.lower()
57
+ for err in errors
58
+ ):
59
+ raise TinyAuthError(message, endpoint, errors)
60
+ raise TinyAPIError(message, endpoint, errors)
61
+ return retorno
62
+
63
+ async def _request_with_retry(
64
+ self, method: str, endpoint: str, **kwargs: Any
65
+ ) -> dict[str, Any]:
66
+ url = self._url(endpoint)
67
+ backoff = 1.0
68
+ last_exc: Exception | None = None
69
+
70
+ for attempt in range(self._max_retries + 1):
71
+ if attempt > 0:
72
+ await asyncio.sleep(backoff)
73
+ backoff = min(backoff * 2, 60.0)
74
+
75
+ await self._rate_limiter.acquire()
76
+
77
+ try:
78
+ resp = await self._client.request(
79
+ method, url, timeout=self._timeout, **kwargs
80
+ )
81
+ except httpx.TimeoutException as exc:
82
+ last_exc = exc
83
+ if attempt >= self._max_retries:
84
+ raise TinyTimeoutError(
85
+ f"Request to {endpoint} timed out"
86
+ ) from exc
87
+ continue
88
+ except httpx.RequestError as exc:
89
+ raise TinyServerError(f"Request failed: {exc}") from exc
90
+
91
+ if resp.status_code in _RETRYABLE_STATUS:
92
+ if attempt < self._max_retries:
93
+ last_exc = (
94
+ TinyRateLimitError(f"Rate limit on {endpoint}")
95
+ if resp.status_code == 429
96
+ else TinyServerError(f"Server error on {endpoint}")
97
+ )
98
+ continue
99
+ if resp.status_code == 429:
100
+ raise TinyRateLimitError(
101
+ f"Rate limit exceeded on {endpoint} after {self._max_retries + 1} attempts"
102
+ )
103
+ raise TinyServerError(
104
+ f"Server error {resp.status_code} on {endpoint} after {self._max_retries + 1} attempts"
105
+ )
106
+
107
+ try:
108
+ resp.raise_for_status()
109
+ except httpx.HTTPStatusError as exc:
110
+ raise TinyServerError(f"HTTP error on {endpoint}: {exc}") from exc
111
+
112
+ return self._parse_retorno(resp.json(), endpoint)
113
+
114
+ if last_exc is not None:
115
+ raise last_exc
116
+ raise TinyServerError(
117
+ f"All {self._max_retries + 1} attempts failed for {endpoint}"
118
+ )
119
+
120
+ async def get(
121
+ self, endpoint: str, params: dict[str, Any] | None = None
122
+ ) -> dict[str, Any]:
123
+ all_params = {**self._base_params(), **(params or {})}
124
+ return await self._request_with_retry("GET", endpoint, params=all_params)
125
+
126
+ async def post(
127
+ self, endpoint: str, data: dict[str, Any] | None = None
128
+ ) -> dict[str, Any]:
129
+ all_params = self._base_params()
130
+ return await self._request_with_retry(
131
+ "POST", endpoint, params=all_params, data=data or {}
132
+ )
133
+
134
+ async def close(self) -> None:
135
+ await self._client.aclose()
tiny_py/_client.py ADDED
@@ -0,0 +1,105 @@
1
+ from typing import TYPE_CHECKING, Literal
2
+
3
+ from tiny_py._http import HTTPAdapter
4
+ from tiny_py._rate_limiter import PLAN_RPM, AsyncRateLimiter, RateLimiter
5
+ from tiny_py.resources.orders import OrdersResource
6
+ from tiny_py.resources.products import ProductsResource
7
+
8
+ if TYPE_CHECKING:
9
+ from tiny_py._async_http import AsyncHTTPAdapter
10
+ from tiny_py.resources.async_orders import AsyncOrdersResource
11
+ from tiny_py.resources.async_products import AsyncProductsResource
12
+
13
+ Plan = Literal["free", "basic", "advanced"]
14
+
15
+ DEFAULT_BASE_URL = "https://api.tiny.com.br/api2"
16
+ DEFAULT_TIMEOUT: tuple[float, float] = (5.0, 30.0)
17
+ DEFAULT_MAX_RETRIES = 3
18
+
19
+
20
+ class TinyClient:
21
+ def __init__(
22
+ self,
23
+ token: str,
24
+ plan: Plan = "advanced",
25
+ timeout: tuple[float, float] = DEFAULT_TIMEOUT,
26
+ max_retries: int = DEFAULT_MAX_RETRIES,
27
+ base_url: str = DEFAULT_BASE_URL,
28
+ ) -> None:
29
+ rpm = PLAN_RPM[plan]
30
+ self._http = HTTPAdapter(
31
+ token=token,
32
+ rate_limiter=RateLimiter(rpm),
33
+ timeout=timeout,
34
+ max_retries=max_retries,
35
+ base_url=base_url,
36
+ )
37
+ self._products: ProductsResource | None = None
38
+ self._orders: OrdersResource | None = None
39
+
40
+ @property
41
+ def products(self) -> ProductsResource:
42
+ if self._products is None:
43
+ self._products = ProductsResource(self._http)
44
+ return self._products
45
+
46
+ @property
47
+ def orders(self) -> OrdersResource:
48
+ if self._orders is None:
49
+ self._orders = OrdersResource(self._http)
50
+ return self._orders
51
+
52
+ def close(self) -> None:
53
+ self._http.close()
54
+
55
+ def __enter__(self) -> "TinyClient":
56
+ return self
57
+
58
+ def __exit__(self, *_: object) -> None:
59
+ self.close()
60
+
61
+
62
+ class AsyncTinyClient:
63
+ def __init__(
64
+ self,
65
+ token: str,
66
+ plan: Plan = "advanced",
67
+ timeout: tuple[float, float] = DEFAULT_TIMEOUT,
68
+ max_retries: int = DEFAULT_MAX_RETRIES,
69
+ base_url: str = DEFAULT_BASE_URL,
70
+ ) -> None:
71
+ from tiny_py._async_http import AsyncHTTPAdapter
72
+
73
+ rpm = PLAN_RPM[plan]
74
+ self._http: AsyncHTTPAdapter = AsyncHTTPAdapter(
75
+ token=token,
76
+ rate_limiter=AsyncRateLimiter(rpm),
77
+ timeout=timeout,
78
+ max_retries=max_retries,
79
+ base_url=base_url,
80
+ )
81
+ self._products: AsyncProductsResource | None = None
82
+ self._orders: AsyncOrdersResource | None = None
83
+
84
+ @property
85
+ def products(self) -> "AsyncProductsResource":
86
+ if self._products is None:
87
+ from tiny_py.resources.async_products import AsyncProductsResource
88
+ self._products = AsyncProductsResource(self._http)
89
+ return self._products
90
+
91
+ @property
92
+ def orders(self) -> "AsyncOrdersResource":
93
+ if self._orders is None:
94
+ from tiny_py.resources.async_orders import AsyncOrdersResource
95
+ self._orders = AsyncOrdersResource(self._http)
96
+ return self._orders
97
+
98
+ async def close(self) -> None:
99
+ await self._http.close()
100
+
101
+ async def __aenter__(self) -> "AsyncTinyClient":
102
+ return self
103
+
104
+ async def __aexit__(self, *_: object) -> None:
105
+ await self.close()
tiny_py/_http.py ADDED
@@ -0,0 +1,160 @@
1
+ import time
2
+ from typing import Any
3
+
4
+ import requests
5
+ from requests.adapters import HTTPAdapter as RequestsHTTPAdapter
6
+
7
+ from tiny_py._rate_limiter import RateLimiter
8
+ from tiny_py.exceptions import (
9
+ TinyAPIError,
10
+ TinyAuthError,
11
+ TinyRateLimitError,
12
+ TinyServerError,
13
+ TinyTimeoutError,
14
+ )
15
+
16
+ _RETRYABLE_STATUS = frozenset({429, 500, 502, 503, 504})
17
+
18
+
19
+ class HTTPAdapter:
20
+ def __init__(
21
+ self,
22
+ token: str,
23
+ rate_limiter: RateLimiter,
24
+ timeout: tuple[float, float],
25
+ max_retries: int,
26
+ base_url: str,
27
+ ) -> None:
28
+ self._token = token
29
+ self._rate_limiter = rate_limiter
30
+ self._timeout = timeout
31
+ self._max_retries = max_retries
32
+ self._base_url = base_url.rstrip("/")
33
+ self._session = self._build_session()
34
+
35
+ def _build_session(self) -> requests.Session:
36
+ session = requests.Session()
37
+ adapter = RequestsHTTPAdapter(
38
+ pool_connections=4,
39
+ pool_maxsize=10,
40
+ )
41
+ session.mount("https://", adapter)
42
+ session.mount("http://", adapter)
43
+ return session
44
+
45
+ def _url(self, endpoint: str) -> str:
46
+ return f"{self._base_url}/{endpoint.lstrip('/')}"
47
+
48
+ def _base_params(self) -> dict[str, str]:
49
+ return {"token": self._token, "formato": "json"}
50
+
51
+ def _parse_retorno(self, response: requests.Response, endpoint: str) -> dict[str, Any]:
52
+ data: dict[str, Any] = response.json()
53
+ retorno: dict[str, Any] = data.get("retorno", {})
54
+ status = retorno.get("status", "").lower()
55
+ if status != "ok":
56
+ erros_raw = retorno.get("erros", [])
57
+ errors: list[str] = []
58
+ for e in erros_raw:
59
+ if isinstance(e, dict):
60
+ errors.append(e.get("erro", str(e)))
61
+ else:
62
+ errors.append(str(e))
63
+ message = (
64
+ errors[0]
65
+ if errors
66
+ else retorno.get("status_processamento", "Unknown error")
67
+ )
68
+ if any(
69
+ "token" in err.lower()
70
+ or "autenticação" in err.lower()
71
+ or "autenticacao" in err.lower()
72
+ for err in errors
73
+ ):
74
+ raise TinyAuthError(message, endpoint, errors)
75
+ raise TinyAPIError(message, endpoint, errors)
76
+ return retorno
77
+
78
+ def _request_with_retry(
79
+ self, method: str, endpoint: str, **kwargs: Any
80
+ ) -> dict[str, Any]:
81
+ url = self._url(endpoint)
82
+ last_exc: Exception | None = None
83
+ backoff = 1.0
84
+
85
+ for attempt in range(self._max_retries + 1):
86
+ if attempt > 0:
87
+ time.sleep(backoff)
88
+ backoff = min(backoff * 2, 60.0)
89
+
90
+ self._rate_limiter.acquire()
91
+
92
+ try:
93
+ resp = self._session.request(
94
+ method, url, timeout=self._timeout, **kwargs
95
+ )
96
+ except requests.exceptions.Timeout as exc:
97
+ last_exc = exc
98
+ if attempt >= self._max_retries:
99
+ raise TinyTimeoutError(
100
+ f"Request to {endpoint} timed out"
101
+ ) from exc
102
+ continue
103
+ except requests.exceptions.RequestException as exc:
104
+ raise TinyServerError(f"Request failed: {exc}") from exc
105
+
106
+ if resp.status_code in _RETRYABLE_STATUS:
107
+ if attempt < self._max_retries:
108
+ last_exc = (
109
+ TinyRateLimitError(f"Rate limit on {endpoint}")
110
+ if resp.status_code == 429
111
+ else TinyServerError(f"Server error on {endpoint}")
112
+ )
113
+ continue
114
+ # Last attempt — raise the appropriate error
115
+ if resp.status_code == 429:
116
+ raise TinyRateLimitError(
117
+ f"Rate limit exceeded on {endpoint} after {self._max_retries + 1} attempts"
118
+ )
119
+ raise TinyServerError(
120
+ f"Server error {resp.status_code} on {endpoint} after {self._max_retries + 1} attempts"
121
+ )
122
+
123
+ # Non-retryable status: let _parse_retorno handle business errors
124
+ # or raise for unexpected HTTP errors
125
+ try:
126
+ resp.raise_for_status()
127
+ except requests.exceptions.HTTPError as exc:
128
+ raise TinyServerError(f"HTTP error on {endpoint}: {exc}") from exc
129
+
130
+ return self._parse_retorno(resp, endpoint)
131
+
132
+ # Should never reach here but be safe
133
+ if last_exc is not None:
134
+ raise last_exc
135
+ raise TinyServerError(
136
+ f"All {self._max_retries + 1} attempts failed for {endpoint}"
137
+ )
138
+
139
+ def get(
140
+ self, endpoint: str, params: dict[str, Any] | None = None
141
+ ) -> dict[str, Any]:
142
+ all_params = {**self._base_params(), **(params or {})}
143
+ return self._request_with_retry("GET", endpoint, params=all_params)
144
+
145
+ def post(
146
+ self, endpoint: str, data: dict[str, Any] | None = None
147
+ ) -> dict[str, Any]:
148
+ all_params = self._base_params()
149
+ return self._request_with_retry(
150
+ "POST", endpoint, params=all_params, data=data or {}
151
+ )
152
+
153
+ def close(self) -> None:
154
+ self._session.close()
155
+
156
+ def __enter__(self) -> "HTTPAdapter":
157
+ return self
158
+
159
+ def __exit__(self, *_: object) -> None:
160
+ self.close()
@@ -0,0 +1,65 @@
1
+ import asyncio
2
+ import threading
3
+ import time
4
+
5
+ PLAN_RPM: dict[str, int] = {
6
+ "free": 30,
7
+ "basic": 60,
8
+ "advanced": 120,
9
+ }
10
+
11
+
12
+ class RateLimiter:
13
+ """Thread-safe token bucket for synchronous use."""
14
+
15
+ def __init__(self, rpm: int) -> None:
16
+ self._rate = rpm / 60.0 # tokens per second
17
+ self._tokens: float = float(rpm)
18
+ self._max_tokens: float = float(rpm)
19
+ self._last_refill: float = time.monotonic()
20
+ self._lock = threading.Lock()
21
+
22
+ def acquire(self) -> None:
23
+ """Block until a token is available."""
24
+ while True:
25
+ with self._lock:
26
+ now = time.monotonic()
27
+ elapsed = now - self._last_refill
28
+ self._tokens = min(
29
+ self._max_tokens,
30
+ self._tokens + elapsed * self._rate,
31
+ )
32
+ self._last_refill = now
33
+ if self._tokens >= 1.0:
34
+ self._tokens -= 1.0
35
+ return
36
+ wait = (1.0 - self._tokens) / self._rate
37
+ time.sleep(wait)
38
+
39
+
40
+ class AsyncRateLimiter:
41
+ """asyncio-safe token bucket for async use."""
42
+
43
+ def __init__(self, rpm: int) -> None:
44
+ self._rate = rpm / 60.0
45
+ self._tokens: float = float(rpm)
46
+ self._max_tokens: float = float(rpm)
47
+ self._last_refill: float = time.monotonic()
48
+ self._lock = asyncio.Lock()
49
+
50
+ async def acquire(self) -> None:
51
+ """Async-safe wait until a token is available."""
52
+ while True:
53
+ async with self._lock:
54
+ now = time.monotonic()
55
+ elapsed = now - self._last_refill
56
+ self._tokens = min(
57
+ self._max_tokens,
58
+ self._tokens + elapsed * self._rate,
59
+ )
60
+ self._last_refill = now
61
+ if self._tokens >= 1.0:
62
+ self._tokens -= 1.0
63
+ return
64
+ wait = (1.0 - self._tokens) / self._rate
65
+ await asyncio.sleep(wait)
tiny_py/exceptions.py ADDED
@@ -0,0 +1,27 @@
1
+ class TinyError(Exception):
2
+ """Base for all tiny-py errors."""
3
+
4
+
5
+ class TinyAPIError(TinyError):
6
+ """The API returned status != OK. Business-level error — do not retry."""
7
+
8
+ def __init__(self, message: str, endpoint: str, errors: list[str]) -> None:
9
+ super().__init__(message)
10
+ self.endpoint = endpoint
11
+ self.errors = errors
12
+
13
+
14
+ class TinyAuthError(TinyAPIError):
15
+ """Invalid or expired token."""
16
+
17
+
18
+ class TinyRateLimitError(TinyError):
19
+ """HTTP 429 received after all retry attempts are exhausted."""
20
+
21
+
22
+ class TinyServerError(TinyError):
23
+ """HTTP 5xx after all retry attempts are exhausted."""
24
+
25
+
26
+ class TinyTimeoutError(TinyError):
27
+ """Request timed out after all retry attempts."""
@@ -0,0 +1,25 @@
1
+ from tiny_py.models.product import (
2
+ Product,
3
+ ProductStock,
4
+ StockDeposit,
5
+ StockDepositUpdate,
6
+ StockUpdateRequest,
7
+ PriceUpdateRequest,
8
+ )
9
+ from tiny_py.models.order import (
10
+ Order,
11
+ OrderItem,
12
+ OrderEcommerce,
13
+ )
14
+
15
+ __all__ = [
16
+ "Product",
17
+ "ProductStock",
18
+ "StockDeposit",
19
+ "StockDepositUpdate",
20
+ "StockUpdateRequest",
21
+ "PriceUpdateRequest",
22
+ "Order",
23
+ "OrderItem",
24
+ "OrderEcommerce",
25
+ ]
@@ -0,0 +1,41 @@
1
+ from datetime import date
2
+ from pydantic import BaseModel, Field, field_validator
3
+
4
+
5
+ class OrderItem(BaseModel):
6
+ model_config = {"populate_by_name": True}
7
+
8
+ product_id: str = Field(alias="id_produto")
9
+ sku: str = Field(alias="codigo")
10
+ description: str = Field(alias="descricao")
11
+ quantity: float = Field(alias="quantidade")
12
+ unit_price: float = Field(alias="valor_unitario")
13
+
14
+
15
+ class OrderEcommerce(BaseModel):
16
+ model_config = {"populate_by_name": True}
17
+
18
+ id: str
19
+ name: str = Field(alias="nomeEcommerce")
20
+ order_number: str = Field(alias="numeroPedidoEcommerce")
21
+
22
+
23
+ class Order(BaseModel):
24
+ model_config = {"populate_by_name": True}
25
+
26
+ id: str
27
+ number: str = Field(alias="numero")
28
+ order_date: date = Field(alias="data_pedido")
29
+ status: str = Field(alias="situacao")
30
+ items: list[OrderItem] = Field(alias="itens", default_factory=list)
31
+ ecommerce: OrderEcommerce | None = Field(alias="ecommerce", default=None)
32
+ total: float = Field(alias="total_pedido")
33
+ tracking_code: str = Field(alias="codigo_rastreamento", default="")
34
+
35
+ @field_validator("order_date", mode="before")
36
+ @classmethod
37
+ def parse_date(cls, v: object) -> date:
38
+ if isinstance(v, str):
39
+ from datetime import datetime
40
+ return datetime.strptime(v, "%d/%m/%Y").date()
41
+ return v # type: ignore[return-value]
@@ -0,0 +1,61 @@
1
+ from pydantic import BaseModel, Field, field_validator
2
+
3
+
4
+ class StockDeposit(BaseModel):
5
+ model_config = {"populate_by_name": True}
6
+
7
+ name: str = Field(alias="nome")
8
+ balance: float = Field(alias="saldo")
9
+ ignore: bool = Field(alias="desconsiderar")
10
+ company: str = Field(alias="empresa")
11
+
12
+ @field_validator("ignore", mode="before")
13
+ @classmethod
14
+ def parse_sn(cls, v: object) -> bool:
15
+ if isinstance(v, str):
16
+ return v.upper() == "S"
17
+ return bool(v)
18
+
19
+
20
+ class StockDepositUpdate(BaseModel):
21
+ model_config = {"populate_by_name": True}
22
+
23
+ deposit_id: str = Field(alias="idDeposito")
24
+ balance: float = Field(alias="saldo")
25
+
26
+
27
+ class ProductStock(BaseModel):
28
+ model_config = {"populate_by_name": True}
29
+
30
+ product_id: str = Field(alias="id")
31
+ sku: str = Field(alias="codigo")
32
+ name: str = Field(alias="nome")
33
+ balance: float = Field(alias="saldo")
34
+ reserved_balance: float = Field(alias="saldoReservado")
35
+ deposits: list[StockDeposit] = Field(alias="depositos", default_factory=list)
36
+
37
+
38
+ class Product(BaseModel):
39
+ model_config = {"populate_by_name": True}
40
+
41
+ id: str
42
+ name: str = Field(alias="nome")
43
+ sku: str = Field(alias="codigo")
44
+ price: float = Field(alias="preco")
45
+ promo_price: float | None = Field(alias="preco_promocional", default=None)
46
+ cost_price: float | None = Field(alias="preco_custo", default=None)
47
+ unit: str = Field(alias="unidade")
48
+ gtin: str | None = Field(alias="gtin", default=None)
49
+ ncm: str | None = Field(alias="ncm", default=None)
50
+ status: str = Field(alias="situacao")
51
+ category: str | None = Field(alias="categoria", default=None)
52
+ brand: str | None = Field(alias="marca", default=None)
53
+
54
+
55
+ class StockUpdateRequest(BaseModel):
56
+ deposits: list[StockDepositUpdate]
57
+
58
+
59
+ class PriceUpdateRequest(BaseModel):
60
+ price: float
61
+ promo_price: float | None = None
tiny_py/py.typed ADDED
File without changes
@@ -0,0 +1,4 @@
1
+ from tiny_py.resources.products import ProductsResource
2
+ from tiny_py.resources.orders import OrdersResource
3
+
4
+ __all__ = ["ProductsResource", "OrdersResource"]
@@ -0,0 +1,35 @@
1
+ from collections.abc import AsyncIterator
2
+ from typing import Any, TYPE_CHECKING
3
+
4
+ if TYPE_CHECKING:
5
+ from tiny_py._async_http import AsyncHTTPAdapter
6
+
7
+
8
+ class AsyncBaseResource:
9
+ def __init__(self, http: "AsyncHTTPAdapter") -> None:
10
+ self._http = http
11
+
12
+ async def _paginate(
13
+ self,
14
+ endpoint: str,
15
+ collection_key: str,
16
+ item_key: str,
17
+ params: dict[str, Any],
18
+ ) -> AsyncIterator[dict[str, Any]]:
19
+ """
20
+ Generic async pagination iterator. Yields raw item dicts page by page.
21
+ Stops when the current page equals numero_paginas.
22
+ """
23
+ page = 1
24
+ while True:
25
+ retorno = await self._http.get(endpoint, {**params, "pagina": page})
26
+ numero_paginas = int(retorno.get("numero_paginas", 1))
27
+ collection = retorno.get(collection_key, [])
28
+ for wrapper in collection:
29
+ if isinstance(wrapper, dict) and item_key in wrapper:
30
+ yield wrapper[item_key]
31
+ else:
32
+ yield wrapper
33
+ if page >= numero_paginas:
34
+ break
35
+ page += 1
@@ -0,0 +1,35 @@
1
+ from collections.abc import Iterator
2
+ from typing import Any, TYPE_CHECKING
3
+
4
+ if TYPE_CHECKING:
5
+ from tiny_py._http import HTTPAdapter
6
+
7
+
8
+ class BaseResource:
9
+ def __init__(self, http: "HTTPAdapter") -> None:
10
+ self._http = http
11
+
12
+ def _paginate(
13
+ self,
14
+ endpoint: str,
15
+ collection_key: str,
16
+ item_key: str,
17
+ params: dict[str, Any],
18
+ ) -> Iterator[dict[str, Any]]:
19
+ """
20
+ Generic pagination iterator. Yields raw item dicts page by page.
21
+ Stops when the current page equals numero_paginas.
22
+ """
23
+ page = 1
24
+ while True:
25
+ retorno = self._http.get(endpoint, {**params, "pagina": page})
26
+ numero_paginas = int(retorno.get("numero_paginas", 1))
27
+ collection = retorno.get(collection_key, [])
28
+ for wrapper in collection:
29
+ if isinstance(wrapper, dict) and item_key in wrapper:
30
+ yield wrapper[item_key]
31
+ else:
32
+ yield wrapper
33
+ if page >= numero_paginas:
34
+ break
35
+ page += 1
@@ -0,0 +1,30 @@
1
+ from collections.abc import AsyncIterator
2
+ from datetime import date
3
+
4
+ from tiny_py.models.order import Order
5
+ from tiny_py.resources._async_base import AsyncBaseResource
6
+
7
+
8
+ class AsyncOrdersResource(AsyncBaseResource):
9
+ async def search(self, date_from: date, date_to: date) -> list[Order]:
10
+ """Returns all orders in the date range (auto-paginated)."""
11
+ return [o async for o in self.iter_search(date_from=date_from, date_to=date_to)]
12
+
13
+ async def iter_search(self, date_from: date, date_to: date) -> AsyncIterator[Order]:
14
+ """Memory-efficient async generator."""
15
+ params = {
16
+ "dataInicial": date_from.strftime("%d/%m/%Y"),
17
+ "dataFinal": date_to.strftime("%d/%m/%Y"),
18
+ }
19
+ async for item in self._paginate(
20
+ endpoint="pedido.pesquisa.php",
21
+ collection_key="pedidos",
22
+ item_key="pedido",
23
+ params=params,
24
+ ):
25
+ yield Order.model_validate(item)
26
+
27
+ async def get(self, order_id: str) -> Order:
28
+ """Fetches full order details (pedido.obter.php)."""
29
+ retorno = await self._http.get("pedido.obter.php", {"id": order_id})
30
+ return Order.model_validate(retorno["pedido"])
@@ -0,0 +1,57 @@
1
+ import json
2
+ from collections.abc import AsyncIterator
3
+
4
+ from tiny_py.models.product import (
5
+ PriceUpdateRequest,
6
+ Product,
7
+ ProductStock,
8
+ StockUpdateRequest,
9
+ )
10
+ from tiny_py.resources._async_base import AsyncBaseResource
11
+
12
+
13
+ class AsyncProductsResource(AsyncBaseResource):
14
+ async def search(self, situacao: str = "A") -> list[Product]:
15
+ """Returns all products matching the filter (auto-paginated)."""
16
+ return [p async for p in self.iter_search(situacao=situacao)]
17
+
18
+ async def iter_search(self, situacao: str = "A") -> AsyncIterator[Product]:
19
+ """Memory-efficient async generator. Preferred for large catalogues."""
20
+ async for item in self._paginate(
21
+ endpoint="produto.pesquisa.php",
22
+ collection_key="produtos",
23
+ item_key="produto",
24
+ params={"situacao": situacao},
25
+ ):
26
+ yield Product.model_validate(item)
27
+
28
+ async def get(self, product_id: str) -> Product:
29
+ """Fetches full product data (produto.obter.php)."""
30
+ retorno = await self._http.get("produto.obter.php", {"id": product_id})
31
+ return Product.model_validate(retorno["produto"])
32
+
33
+ async def get_stock(self, product_id: str) -> ProductStock:
34
+ """Fetches stock per deposit (produto.obter.estoque.php)."""
35
+ retorno = await self._http.get("produto.obter.estoque.php", {"id": product_id})
36
+ return ProductStock.model_validate(retorno["produto"])
37
+
38
+ async def update_stock(self, product_id: str, request: StockUpdateRequest) -> None:
39
+ """Updates deposit balances (produto.atualizar.estoque.php)."""
40
+ deposits_data = [
41
+ {"idDeposito": d.deposit_id, "saldo": d.balance}
42
+ for d in request.deposits
43
+ ]
44
+ await self._http.post(
45
+ "produto.atualizar.estoque.php",
46
+ {
47
+ "id": product_id,
48
+ "depositos": json.dumps(deposits_data),
49
+ },
50
+ )
51
+
52
+ async def update_price(self, product_id: str, request: PriceUpdateRequest) -> None:
53
+ """Updates regular and promotional prices (produto.atualizar.preco.php)."""
54
+ data: dict = {"id": product_id, "preco": request.price}
55
+ if request.promo_price is not None:
56
+ data["preco_promocional"] = request.promo_price
57
+ await self._http.post("produto.atualizar.preco.php", data)
@@ -0,0 +1,31 @@
1
+ from collections.abc import Iterator
2
+ from datetime import date
3
+
4
+ from tiny_py._http import HTTPAdapter
5
+ from tiny_py.models.order import Order
6
+ from tiny_py.resources._base import BaseResource
7
+
8
+
9
+ class OrdersResource(BaseResource):
10
+ def search(self, date_from: date, date_to: date) -> list[Order]:
11
+ """Returns all orders in the date range (auto-paginated)."""
12
+ return list(self.iter_search(date_from=date_from, date_to=date_to))
13
+
14
+ def iter_search(self, date_from: date, date_to: date) -> Iterator[Order]:
15
+ """Memory-efficient generator."""
16
+ params = {
17
+ "dataInicial": date_from.strftime("%d/%m/%Y"),
18
+ "dataFinal": date_to.strftime("%d/%m/%Y"),
19
+ }
20
+ for item in self._paginate(
21
+ endpoint="pedido.pesquisa.php",
22
+ collection_key="pedidos",
23
+ item_key="pedido",
24
+ params=params,
25
+ ):
26
+ yield Order.model_validate(item)
27
+
28
+ def get(self, order_id: str) -> Order:
29
+ """Fetches full order details (pedido.obter.php)."""
30
+ retorno = self._http.get("pedido.obter.php", {"id": order_id})
31
+ return Order.model_validate(retorno["pedido"])
@@ -0,0 +1,58 @@
1
+ import json
2
+ from collections.abc import Iterator
3
+
4
+ from tiny_py._http import HTTPAdapter
5
+ from tiny_py.models.product import (
6
+ PriceUpdateRequest,
7
+ Product,
8
+ ProductStock,
9
+ StockUpdateRequest,
10
+ )
11
+ from tiny_py.resources._base import BaseResource
12
+
13
+
14
+ class ProductsResource(BaseResource):
15
+ def search(self, situacao: str = "A") -> list[Product]:
16
+ """Returns all products matching the filter (auto-paginated)."""
17
+ return list(self.iter_search(situacao=situacao))
18
+
19
+ def iter_search(self, situacao: str = "A") -> Iterator[Product]:
20
+ """Memory-efficient generator. Preferred for large catalogues."""
21
+ for item in self._paginate(
22
+ endpoint="produto.pesquisa.php",
23
+ collection_key="produtos",
24
+ item_key="produto",
25
+ params={"situacao": situacao},
26
+ ):
27
+ yield Product.model_validate(item)
28
+
29
+ def get(self, product_id: str) -> Product:
30
+ """Fetches full product data (produto.obter.php)."""
31
+ retorno = self._http.get("produto.obter.php", {"id": product_id})
32
+ return Product.model_validate(retorno["produto"])
33
+
34
+ def get_stock(self, product_id: str) -> ProductStock:
35
+ """Fetches stock per deposit (produto.obter.estoque.php)."""
36
+ retorno = self._http.get("produto.obter.estoque.php", {"id": product_id})
37
+ return ProductStock.model_validate(retorno["produto"])
38
+
39
+ def update_stock(self, product_id: str, request: StockUpdateRequest) -> None:
40
+ """Updates deposit balances (produto.atualizar.estoque.php)."""
41
+ deposits_data = [
42
+ {"idDeposito": d.deposit_id, "saldo": d.balance}
43
+ for d in request.deposits
44
+ ]
45
+ self._http.post(
46
+ "produto.atualizar.estoque.php",
47
+ {
48
+ "id": product_id,
49
+ "depositos": json.dumps(deposits_data),
50
+ },
51
+ )
52
+
53
+ def update_price(self, product_id: str, request: PriceUpdateRequest) -> None:
54
+ """Updates regular and promotional prices (produto.atualizar.preco.php)."""
55
+ data: dict = {"id": product_id, "preco": request.price}
56
+ if request.promo_price is not None:
57
+ data["preco_promocional"] = request.promo_price
58
+ self._http.post("produto.atualizar.preco.php", data)