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.
- tiny_erp_py-0.1.0.dist-info/METADATA +109 -0
- tiny_erp_py-0.1.0.dist-info/RECORD +20 -0
- tiny_erp_py-0.1.0.dist-info/WHEEL +4 -0
- tiny_py/__init__.py +5 -0
- tiny_py/_async_http.py +135 -0
- tiny_py/_client.py +105 -0
- tiny_py/_http.py +160 -0
- tiny_py/_rate_limiter.py +65 -0
- tiny_py/exceptions.py +27 -0
- tiny_py/models/__init__.py +25 -0
- tiny_py/models/order.py +41 -0
- tiny_py/models/product.py +61 -0
- tiny_py/py.typed +0 -0
- tiny_py/resources/__init__.py +4 -0
- tiny_py/resources/_async_base.py +35 -0
- tiny_py/resources/_base.py +35 -0
- tiny_py/resources/async_orders.py +30 -0
- tiny_py/resources/async_products.py +57 -0
- tiny_py/resources/orders.py +31 -0
- tiny_py/resources/products.py +58 -0
|
@@ -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
|
+
[](https://pypi.org/project/tiny-erp-py/)
|
|
35
|
+
[](https://pypi.org/project/tiny-erp-py/)
|
|
36
|
+
[](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,,
|
tiny_py/__init__.py
ADDED
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()
|
tiny_py/_rate_limiter.py
ADDED
|
@@ -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
|
+
]
|
tiny_py/models/order.py
ADDED
|
@@ -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,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)
|