tiny-erp-py 0.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.
- tiny_erp_py-0.1.0/.claude/settings.json +26 -0
- tiny_erp_py-0.1.0/.gitignore +179 -0
- tiny_erp_py-0.1.0/CLAUDE.md +355 -0
- tiny_erp_py-0.1.0/PKG-INFO +109 -0
- tiny_erp_py-0.1.0/README.md +81 -0
- tiny_erp_py-0.1.0/docs/.nojekyll +0 -0
- tiny_erp_py-0.1.0/docs/README.md +41 -0
- tiny_erp_py-0.1.0/docs/_sidebar.md +22 -0
- tiny_erp_py-0.1.0/docs/client.md +88 -0
- tiny_erp_py-0.1.0/docs/exceptions.md +113 -0
- tiny_erp_py-0.1.0/docs/guides/async.md +92 -0
- tiny_erp_py-0.1.0/docs/guides/fastapi.md +110 -0
- tiny_erp_py-0.1.0/docs/guides/rabbitmq.md +104 -0
- tiny_erp_py-0.1.0/docs/index.html +97 -0
- tiny_erp_py-0.1.0/docs/installation.md +39 -0
- tiny_erp_py-0.1.0/docs/models/order.md +69 -0
- tiny_erp_py-0.1.0/docs/models/product.md +117 -0
- tiny_erp_py-0.1.0/docs/quickstart.md +146 -0
- tiny_erp_py-0.1.0/docs/rate-limiting.md +70 -0
- tiny_erp_py-0.1.0/docs/resources/orders.md +72 -0
- tiny_erp_py-0.1.0/docs/resources/products.md +128 -0
- tiny_erp_py-0.1.0/docs/roadmap.md +254 -0
- tiny_erp_py-0.1.0/pyproject.toml +49 -0
- tiny_erp_py-0.1.0/tests/__init__.py +0 -0
- tiny_erp_py-0.1.0/tests/conftest.py +0 -0
- tiny_erp_py-0.1.0/tests/test_client.py +227 -0
- tiny_erp_py-0.1.0/tests/test_exceptions.py +70 -0
- tiny_erp_py-0.1.0/tests/test_http.py +310 -0
- tiny_erp_py-0.1.0/tests/test_models.py +318 -0
- tiny_erp_py-0.1.0/tests/test_rate_limiter.py +160 -0
- tiny_erp_py-0.1.0/tests/test_resources.py +398 -0
- tiny_erp_py-0.1.0/tiny_py/__init__.py +5 -0
- tiny_erp_py-0.1.0/tiny_py/_async_http.py +135 -0
- tiny_erp_py-0.1.0/tiny_py/_client.py +105 -0
- tiny_erp_py-0.1.0/tiny_py/_http.py +160 -0
- tiny_erp_py-0.1.0/tiny_py/_rate_limiter.py +65 -0
- tiny_erp_py-0.1.0/tiny_py/exceptions.py +27 -0
- tiny_erp_py-0.1.0/tiny_py/models/__init__.py +25 -0
- tiny_erp_py-0.1.0/tiny_py/models/order.py +41 -0
- tiny_erp_py-0.1.0/tiny_py/models/product.py +61 -0
- tiny_erp_py-0.1.0/tiny_py/py.typed +0 -0
- tiny_erp_py-0.1.0/tiny_py/resources/__init__.py +4 -0
- tiny_erp_py-0.1.0/tiny_py/resources/_async_base.py +35 -0
- tiny_erp_py-0.1.0/tiny_py/resources/_base.py +35 -0
- tiny_erp_py-0.1.0/tiny_py/resources/async_orders.py +30 -0
- tiny_erp_py-0.1.0/tiny_py/resources/async_products.py +57 -0
- tiny_erp_py-0.1.0/tiny_py/resources/orders.py +31 -0
- tiny_erp_py-0.1.0/tiny_py/resources/products.py +58 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"permissions": {
|
|
3
|
+
"allow": [
|
|
4
|
+
"Bash(pip install:*)",
|
|
5
|
+
"Bash(python -m pytest tests/ -v)",
|
|
6
|
+
"Bash(python -m pytest tests/test_exceptions.py -v)",
|
|
7
|
+
"Bash(python -m pytest tests/test_rate_limiter.py -v)",
|
|
8
|
+
"Bash(python -m pytest tests/test_models.py -v)",
|
|
9
|
+
"Bash(python -m pytest tests/test_http.py -v)",
|
|
10
|
+
"Bash(python -m pytest tests/test_resources.py -v)",
|
|
11
|
+
"Bash(python -m pytest tests/test_client.py -v)",
|
|
12
|
+
"Bash(pip show:*)",
|
|
13
|
+
"Bash(curl -s https://pypi.org/pypi/tiny-py/json)",
|
|
14
|
+
"Bash(python -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(''''EXISTS:'''', d[''''info''''][''''version'''']\\)\")",
|
|
15
|
+
"Bash(curl -s -o /dev/null -w \"%{http_code}\" https://pypi.org/pypi/tiny-py/json)",
|
|
16
|
+
"Bash(pytest tests/ -q)",
|
|
17
|
+
"Bash(python -m build)",
|
|
18
|
+
"Bash(python -m twine check dist/*)",
|
|
19
|
+
"Bash(TWINE_USERNAME=Joaoaalves TWINE_PASSWORD='mz.@T6?hMJ46!~.' python -m twine upload dist/*)",
|
|
20
|
+
"Bash(TWINE_USERNAME=__token__ TWINE_PASSWORD='pypi-AgEIcHlwaS5vcmcCJGFlMDcwODgwLWVmODctNDk3MC1hYzIxLTY0ODBhMWVmNGU4ZQACKlszLCJmNjVmMTJiYi00MmZkLTQ4OTYtODNlMi0zNjM4NmJkZjVkNTkiXQAABiCUVbejFSVH8tsk5GtzvitQvNVHs2dbHmXxZRscrZ3Niw' python -m twine upload dist/*)",
|
|
21
|
+
"Bash(TWINE_USERNAME=__token__ TWINE_PASSWORD='pypi-AgEIcHlwaS5vcmcCJGFlMDcwODgwLWVmODctNDk3MC1hYzIxLTY0ODBhMWVmNGU4ZQACKlszLCJmNjVmMTJiYi00MmZkLTQ4OTYtODNlMi0zNjM4NmJkZjVkNTkiXQAABiCUVbejFSVH8tsk5GtzvitQvNVHs2dbHmXxZRscrZ3Niw' python -m twine upload dist/* --verbose)",
|
|
22
|
+
"Bash(rm -rf dist/)",
|
|
23
|
+
"Bash(python -m build -q)"
|
|
24
|
+
]
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# Created by https://www.toptal.com/developers/gitignore/api/python
|
|
2
|
+
# Edit at https://www.toptal.com/developers/gitignore?templates=python
|
|
3
|
+
|
|
4
|
+
# IF SOMEONE USES AI
|
|
5
|
+
*/CLAUDE.md
|
|
6
|
+
|
|
7
|
+
### Python ###
|
|
8
|
+
# Byte-compiled / optimized / DLL files
|
|
9
|
+
__pycache__/
|
|
10
|
+
*.py[cod]
|
|
11
|
+
*$py.class
|
|
12
|
+
|
|
13
|
+
# C extensions
|
|
14
|
+
*.so
|
|
15
|
+
|
|
16
|
+
# Distribution / packaging
|
|
17
|
+
.Python
|
|
18
|
+
build/
|
|
19
|
+
develop-eggs/
|
|
20
|
+
dist/
|
|
21
|
+
downloads/
|
|
22
|
+
eggs/
|
|
23
|
+
.eggs/
|
|
24
|
+
lib/
|
|
25
|
+
lib64/
|
|
26
|
+
parts/
|
|
27
|
+
sdist/
|
|
28
|
+
var/
|
|
29
|
+
wheels/
|
|
30
|
+
share/python-wheels/
|
|
31
|
+
*.egg-info/
|
|
32
|
+
.installed.cfg
|
|
33
|
+
*.egg
|
|
34
|
+
MANIFEST
|
|
35
|
+
|
|
36
|
+
# PyInstaller
|
|
37
|
+
# Usually these files are written by a python script from a template
|
|
38
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
39
|
+
*.manifest
|
|
40
|
+
*.spec
|
|
41
|
+
|
|
42
|
+
# Installer logs
|
|
43
|
+
pip-log.txt
|
|
44
|
+
pip-delete-this-directory.txt
|
|
45
|
+
|
|
46
|
+
# Unit test / coverage reports
|
|
47
|
+
htmlcov/
|
|
48
|
+
.tox/
|
|
49
|
+
.nox/
|
|
50
|
+
.coverage
|
|
51
|
+
.coverage.*
|
|
52
|
+
.cache
|
|
53
|
+
nosetests.xml
|
|
54
|
+
coverage.xml
|
|
55
|
+
*.cover
|
|
56
|
+
*.py,cover
|
|
57
|
+
.hypothesis/
|
|
58
|
+
.pytest_cache/
|
|
59
|
+
cover/
|
|
60
|
+
|
|
61
|
+
# Translations
|
|
62
|
+
*.mo
|
|
63
|
+
*.pot
|
|
64
|
+
|
|
65
|
+
# Django stuff:
|
|
66
|
+
*.log
|
|
67
|
+
local_settings.py
|
|
68
|
+
db.sqlite3
|
|
69
|
+
db.sqlite3-journal
|
|
70
|
+
|
|
71
|
+
# Flask stuff:
|
|
72
|
+
instance/
|
|
73
|
+
.webassets-cache
|
|
74
|
+
|
|
75
|
+
# Scrapy stuff:
|
|
76
|
+
.scrapy
|
|
77
|
+
|
|
78
|
+
# Sphinx documentation
|
|
79
|
+
docs/_build/
|
|
80
|
+
|
|
81
|
+
# PyBuilder
|
|
82
|
+
.pybuilder/
|
|
83
|
+
target/
|
|
84
|
+
|
|
85
|
+
# Jupyter Notebook
|
|
86
|
+
.ipynb_checkpoints
|
|
87
|
+
|
|
88
|
+
# IPython
|
|
89
|
+
profile_default/
|
|
90
|
+
ipython_config.py
|
|
91
|
+
|
|
92
|
+
# pyenv
|
|
93
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
94
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
95
|
+
# .python-version
|
|
96
|
+
|
|
97
|
+
# pipenv
|
|
98
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
99
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
100
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
101
|
+
# install all needed dependencies.
|
|
102
|
+
#Pipfile.lock
|
|
103
|
+
|
|
104
|
+
# poetry
|
|
105
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
106
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
107
|
+
# commonly ignored for libraries.
|
|
108
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
109
|
+
#poetry.lock
|
|
110
|
+
|
|
111
|
+
# pdm
|
|
112
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
113
|
+
#pdm.lock
|
|
114
|
+
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
|
115
|
+
# in version control.
|
|
116
|
+
# https://pdm.fming.dev/#use-with-ide
|
|
117
|
+
.pdm.toml
|
|
118
|
+
|
|
119
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
120
|
+
__pypackages__/
|
|
121
|
+
|
|
122
|
+
# Celery stuff
|
|
123
|
+
celerybeat-schedule
|
|
124
|
+
celerybeat.pid
|
|
125
|
+
|
|
126
|
+
# SageMath parsed files
|
|
127
|
+
*.sage.py
|
|
128
|
+
|
|
129
|
+
# Environments
|
|
130
|
+
.env
|
|
131
|
+
.venv
|
|
132
|
+
env/
|
|
133
|
+
venv/
|
|
134
|
+
ENV/
|
|
135
|
+
env.bak/
|
|
136
|
+
venv.bak/
|
|
137
|
+
|
|
138
|
+
# Spyder project settings
|
|
139
|
+
.spyderproject
|
|
140
|
+
.spyproject
|
|
141
|
+
|
|
142
|
+
# Rope project settings
|
|
143
|
+
.ropeproject
|
|
144
|
+
|
|
145
|
+
# mkdocs documentation
|
|
146
|
+
/site
|
|
147
|
+
|
|
148
|
+
# mypy
|
|
149
|
+
.mypy_cache/
|
|
150
|
+
.dmypy.json
|
|
151
|
+
dmypy.json
|
|
152
|
+
|
|
153
|
+
# Pyre type checker
|
|
154
|
+
.pyre/
|
|
155
|
+
|
|
156
|
+
# pytype static type analyzer
|
|
157
|
+
.pytype/
|
|
158
|
+
|
|
159
|
+
# Cython debug symbols
|
|
160
|
+
cython_debug/
|
|
161
|
+
|
|
162
|
+
# PyCharm
|
|
163
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
164
|
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
165
|
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
166
|
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
167
|
+
#.idea/
|
|
168
|
+
|
|
169
|
+
### Python Patch ###
|
|
170
|
+
# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration
|
|
171
|
+
poetry.toml
|
|
172
|
+
|
|
173
|
+
# ruff
|
|
174
|
+
.ruff_cache/
|
|
175
|
+
|
|
176
|
+
# LSP config files
|
|
177
|
+
pyrightconfig.json
|
|
178
|
+
|
|
179
|
+
# End of https://www.toptal.com/developers/gitignore/api/python
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
# tiny-py — Client Library Specification
|
|
2
|
+
|
|
3
|
+
A production-grade Python SDK for the Tiny ERP API v2.
|
|
4
|
+
Designed to be installable via pip, fully typed, and safe for use in FastAPI services and message queue workers (RabbitMQ, Celery, etc.).
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Design Principles
|
|
9
|
+
|
|
10
|
+
- **Single entry point.** All interaction goes through `TinyClient`. No module-level functions or globals.
|
|
11
|
+
- **Instance-scoped state.** Rate limiter, session, and credentials live on the client instance — never as module globals. Multiple tokens and plans can coexist in the same process.
|
|
12
|
+
- **No I/O side effects.** The library only communicates with the Tiny API. File writes, queues, and databases are the caller's responsibility.
|
|
13
|
+
- **Typed contracts.** All public methods accept and return Pydantic v2 models. Raw dicts do not leak outside the library.
|
|
14
|
+
- **Explicit over implicit.** No environment variable sniffing inside the client. Configuration is passed at instantiation.
|
|
15
|
+
- **Sync-first, async-ready.** The default implementation is synchronous (safe for workers). An async variant (`AsyncTinyClient`) shares the same resource interface via an abstract base.
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Package Structure
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
tiny_py/
|
|
23
|
+
├── __init__.py # Exports: TinyClient, AsyncTinyClient, exceptions, models
|
|
24
|
+
├── py.typed # PEP 561 marker
|
|
25
|
+
├── _client.py # TinyClient and AsyncTinyClient
|
|
26
|
+
├── _http.py # HTTPAdapter: session, retry, timeout, rate limiting
|
|
27
|
+
├── _rate_limiter.py # RateLimiter (sync) and AsyncRateLimiter
|
|
28
|
+
├── exceptions.py # Full exception hierarchy
|
|
29
|
+
├── resources/
|
|
30
|
+
│ ├── __init__.py
|
|
31
|
+
│ ├── _base.py # BaseResource — shared _get / _post + pagination helper
|
|
32
|
+
│ ├── products.py # ProductsResource
|
|
33
|
+
│ └── orders.py # OrdersResource
|
|
34
|
+
└── models/
|
|
35
|
+
├── __init__.py
|
|
36
|
+
├── product.py # Product, ProductStock, StockDeposit, StockUpdateRequest, PriceUpdateRequest
|
|
37
|
+
└── order.py # Order, OrderItem, OrderClient, OrderEcommerce, OrderMarker
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## TinyClient Interface
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from tiny_py import TinyClient
|
|
46
|
+
|
|
47
|
+
client = TinyClient(
|
|
48
|
+
token="your_token",
|
|
49
|
+
plan="advanced", # "free" | "basic" | "advanced"
|
|
50
|
+
timeout=(5.0, 30.0), # (connect_timeout, read_timeout)
|
|
51
|
+
max_retries=5,
|
|
52
|
+
base_url="https://api.tiny.com.br/api2", # override for tests
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# Resources are lazy-instantiated attributes
|
|
56
|
+
client.products # -> ProductsResource
|
|
57
|
+
client.orders # -> OrdersResource
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`TinyClient` must not accept `session`, `httpx.Client`, or any transport object at `__init__` — transport is an internal concern managed by `_http.py`.
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## Exception Hierarchy
|
|
65
|
+
|
|
66
|
+
All exceptions inherit from `TinyError` so callers can catch broadly or narrowly.
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
# exceptions.py
|
|
70
|
+
|
|
71
|
+
class TinyError(Exception):
|
|
72
|
+
"""Base for all tiny-py errors."""
|
|
73
|
+
|
|
74
|
+
class TinyAPIError(TinyError):
|
|
75
|
+
"""The API returned status != OK. Business-level error — do not retry."""
|
|
76
|
+
def __init__(self, message: str, endpoint: str, errors: list[str]):
|
|
77
|
+
...
|
|
78
|
+
|
|
79
|
+
class TinyAuthError(TinyAPIError):
|
|
80
|
+
"""Invalid or expired token."""
|
|
81
|
+
|
|
82
|
+
class TinyRateLimitError(TinyError):
|
|
83
|
+
"""HTTP 429 received after all retry attempts are exhausted."""
|
|
84
|
+
|
|
85
|
+
class TinyServerError(TinyError):
|
|
86
|
+
"""HTTP 5xx after all retry attempts are exhausted."""
|
|
87
|
+
|
|
88
|
+
class TinyTimeoutError(TinyError):
|
|
89
|
+
"""Request timed out after all retry attempts."""
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
**Rule for callers (e.g. RabbitMQ workers):**
|
|
93
|
+
- `TinyAPIError` → send to Dead Letter Queue, do not retry.
|
|
94
|
+
- `TinyRateLimitError | TinyServerError | TinyTimeoutError` → re-enqueue with backoff.
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## HTTP Layer (`_http.py`)
|
|
99
|
+
|
|
100
|
+
Wraps `requests.Session` with:
|
|
101
|
+
- Persistent connection pooling (`HTTPAdapter` with `pool_connections`, `pool_maxsize`)
|
|
102
|
+
- Automatic injection of `token` and `formato=json`
|
|
103
|
+
- Retry loop with **exponential backoff** on `429`, `500`, `502`, `503`, `504`
|
|
104
|
+
- Separation of `connect_timeout` and `read_timeout` via tuple
|
|
105
|
+
- Rate limiter call before every request
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
class HTTPAdapter:
|
|
109
|
+
def __init__(self, token: str, rate_limiter: RateLimiter, timeout: tuple, max_retries: int, base_url: str): ...
|
|
110
|
+
def get(self, endpoint: str, params: dict) -> dict: ...
|
|
111
|
+
def post(self, endpoint: str, data: dict) -> dict: ...
|
|
112
|
+
def close(self): ...
|
|
113
|
+
|
|
114
|
+
def __enter__(self): return self
|
|
115
|
+
def __exit__(self, *_): self.close()
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
`HTTPAdapter` returns the inner `retorno` dict on success. It raises typed exceptions — never returns raw error responses.
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
## Rate Limiter (`_rate_limiter.py`)
|
|
123
|
+
|
|
124
|
+
Token bucket per client instance. Plan-based defaults, but fully overridable.
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
PLAN_RPM = {
|
|
128
|
+
"free": 30,
|
|
129
|
+
"basic": 60,
|
|
130
|
+
"advanced": 120,
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
class RateLimiter:
|
|
134
|
+
"""Thread-safe token bucket for synchronous use."""
|
|
135
|
+
def __init__(self, rpm: int): ...
|
|
136
|
+
def acquire(self): ... # blocks until a slot is available
|
|
137
|
+
|
|
138
|
+
class AsyncRateLimiter:
|
|
139
|
+
"""asyncio-safe token bucket for async use."""
|
|
140
|
+
def __init__(self, rpm: int): ...
|
|
141
|
+
async def acquire(self): ...
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
**Note for multi-process workers:** Each process has its own `TinyClient` instance and its own in-memory rate limiter. If many workers run in parallel against the same Tiny token, rate limits are not coordinated across processes. For high-concurrency scenarios, replace the in-process limiter with a Redis-backed sliding window (e.g., `redis-py` with a Lua script) and pass a custom `rpm` override to the client.
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
## Resources
|
|
149
|
+
|
|
150
|
+
### Base Resource (`resources/_base.py`)
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
class BaseResource:
|
|
154
|
+
def __init__(self, http: HTTPAdapter): ...
|
|
155
|
+
|
|
156
|
+
def _paginate(self, endpoint: str, collection_key: str, item_key: str, params: dict) -> Iterator[dict]:
|
|
157
|
+
"""
|
|
158
|
+
Generic pagination iterator. Yields raw item dicts page by page.
|
|
159
|
+
Stops when the current page equals numero_paginas.
|
|
160
|
+
"""
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
All resource methods that return lists use `_paginate` internally. Public methods expose either:
|
|
164
|
+
- A **generator** variant (`iter_*`) for memory-efficient streaming.
|
|
165
|
+
- A **list** variant that materialises the full result set (convenience wrapper around the generator).
|
|
166
|
+
|
|
167
|
+
---
|
|
168
|
+
|
|
169
|
+
### Products Resource (`resources/products.py`)
|
|
170
|
+
|
|
171
|
+
```python
|
|
172
|
+
class ProductsResource(BaseResource):
|
|
173
|
+
|
|
174
|
+
def search(self, situacao: str = "A") -> list[Product]:
|
|
175
|
+
"""Returns all products matching the filter (auto-paginated)."""
|
|
176
|
+
|
|
177
|
+
def iter_search(self, situacao: str = "A") -> Iterator[Product]:
|
|
178
|
+
"""Memory-efficient generator. Preferred for large catalogues."""
|
|
179
|
+
|
|
180
|
+
def get(self, product_id: str) -> Product:
|
|
181
|
+
"""Fetches full product data (produto.obter.php)."""
|
|
182
|
+
|
|
183
|
+
def get_stock(self, product_id: str) -> ProductStock:
|
|
184
|
+
"""Fetches stock per deposit (produto.obter.estoque.php)."""
|
|
185
|
+
|
|
186
|
+
def update_stock(self, product_id: str, request: StockUpdateRequest) -> None:
|
|
187
|
+
"""Updates deposit balances (produto.atualizar.estoque.php)."""
|
|
188
|
+
|
|
189
|
+
def update_price(self, product_id: str, request: PriceUpdateRequest) -> None:
|
|
190
|
+
"""Updates regular and promotional prices (produto.atualizar.preco.php)."""
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
---
|
|
194
|
+
|
|
195
|
+
### Orders Resource (`resources/orders.py`)
|
|
196
|
+
|
|
197
|
+
```python
|
|
198
|
+
class OrdersResource(BaseResource):
|
|
199
|
+
|
|
200
|
+
def search(self, date_from: date, date_to: date) -> list[Order]:
|
|
201
|
+
"""Returns all orders in the date range (auto-paginated)."""
|
|
202
|
+
|
|
203
|
+
def iter_search(self, date_from: date, date_to: date) -> Iterator[Order]:
|
|
204
|
+
"""Memory-efficient generator."""
|
|
205
|
+
|
|
206
|
+
def get(self, order_id: str) -> Order:
|
|
207
|
+
"""Fetches full order details (pedido.obter.php)."""
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
---
|
|
211
|
+
|
|
212
|
+
## Models (`models/`)
|
|
213
|
+
|
|
214
|
+
All models use **Pydantic v2**. Field aliases match the Tiny API's Portuguese naming, with English Python attribute names exposed to callers.
|
|
215
|
+
|
|
216
|
+
```python
|
|
217
|
+
# models/product.py
|
|
218
|
+
class StockDeposit(BaseModel):
|
|
219
|
+
name: str = Field(alias="nome")
|
|
220
|
+
balance: float = Field(alias="saldo")
|
|
221
|
+
ignore: bool = Field(alias="desconsiderar") # "S"/"N" -> bool validator
|
|
222
|
+
company: str = Field(alias="empresa")
|
|
223
|
+
|
|
224
|
+
class ProductStock(BaseModel):
|
|
225
|
+
product_id: str = Field(alias="id")
|
|
226
|
+
sku: str = Field(alias="codigo")
|
|
227
|
+
name: str = Field(alias="nome")
|
|
228
|
+
balance: float = Field(alias="saldo")
|
|
229
|
+
reserved_balance: float = Field(alias="saldoReservado")
|
|
230
|
+
deposits: list[StockDeposit] = Field(alias="depositos", default_factory=list)
|
|
231
|
+
|
|
232
|
+
class Product(BaseModel):
|
|
233
|
+
id: str
|
|
234
|
+
name: str = Field(alias="nome")
|
|
235
|
+
sku: str = Field(alias="codigo")
|
|
236
|
+
price: float = Field(alias="preco")
|
|
237
|
+
promo_price: float | None = Field(alias="preco_promocional", default=None)
|
|
238
|
+
cost_price: float | None = Field(alias="preco_custo", default=None)
|
|
239
|
+
unit: str = Field(alias="unidade")
|
|
240
|
+
gtin: str | None = Field(alias="gtin", default=None)
|
|
241
|
+
ncm: str | None = Field(alias="ncm", default=None)
|
|
242
|
+
status: str = Field(alias="situacao")
|
|
243
|
+
category: str | None = Field(alias="categoria", default=None)
|
|
244
|
+
brand: str | None = Field(alias="marca", default=None)
|
|
245
|
+
|
|
246
|
+
class StockUpdateRequest(BaseModel):
|
|
247
|
+
deposits: list[StockDepositUpdate]
|
|
248
|
+
|
|
249
|
+
class PriceUpdateRequest(BaseModel):
|
|
250
|
+
price: float
|
|
251
|
+
promo_price: float | None = None
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
```python
|
|
255
|
+
# models/order.py
|
|
256
|
+
class OrderItem(BaseModel):
|
|
257
|
+
product_id: str = Field(alias="id_produto")
|
|
258
|
+
sku: str = Field(alias="codigo")
|
|
259
|
+
description: str = Field(alias="descricao")
|
|
260
|
+
quantity: float = Field(alias="quantidade")
|
|
261
|
+
unit_price: float = Field(alias="valor_unitario")
|
|
262
|
+
|
|
263
|
+
class OrderEcommerce(BaseModel):
|
|
264
|
+
id: str
|
|
265
|
+
name: str = Field(alias="nomeEcommerce")
|
|
266
|
+
order_number: str = Field(alias="numeroPedidoEcommerce")
|
|
267
|
+
|
|
268
|
+
class Order(BaseModel):
|
|
269
|
+
id: str
|
|
270
|
+
number: str = Field(alias="numero")
|
|
271
|
+
order_date: date = Field(alias="data_pedido") # validator: "DD/MM/YYYY" -> date
|
|
272
|
+
status: str = Field(alias="situacao")
|
|
273
|
+
items: list[OrderItem] = Field(alias="itens", default_factory=list)
|
|
274
|
+
ecommerce: OrderEcommerce | None = Field(alias="ecommerce", default=None)
|
|
275
|
+
total: float = Field(alias="total_pedido")
|
|
276
|
+
tracking_code: str = Field(alias="codigo_rastreamento", default="")
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
---
|
|
280
|
+
|
|
281
|
+
## Async Client (`AsyncTinyClient`)
|
|
282
|
+
|
|
283
|
+
Mirrors `TinyClient` exactly. Uses `httpx.AsyncClient` internally instead of `requests.Session`, and `AsyncRateLimiter` instead of `RateLimiter`. All resource methods become coroutines.
|
|
284
|
+
|
|
285
|
+
```python
|
|
286
|
+
async with AsyncTinyClient(token="...", plan="advanced") as client:
|
|
287
|
+
products = await client.products.search()
|
|
288
|
+
order = await client.orders.get("970977594")
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
FastAPI endpoints should use `AsyncTinyClient` to avoid blocking the event loop.
|
|
292
|
+
|
|
293
|
+
---
|
|
294
|
+
|
|
295
|
+
## FastAPI Integration Pattern
|
|
296
|
+
|
|
297
|
+
```python
|
|
298
|
+
# deps.py
|
|
299
|
+
from functools import lru_cache
|
|
300
|
+
from tiny_py import AsyncTinyClient
|
|
301
|
+
|
|
302
|
+
@lru_cache(maxsize=1)
|
|
303
|
+
def _client() -> AsyncTinyClient:
|
|
304
|
+
return AsyncTinyClient(token=settings.TINY_TOKEN, plan=settings.TINY_PLAN)
|
|
305
|
+
|
|
306
|
+
async def get_tiny() -> AsyncTinyClient:
|
|
307
|
+
return _client()
|
|
308
|
+
|
|
309
|
+
# router.py
|
|
310
|
+
@router.get("/products/{product_id}/stock")
|
|
311
|
+
async def get_stock(
|
|
312
|
+
product_id: str,
|
|
313
|
+
client: AsyncTinyClient = Depends(get_tiny),
|
|
314
|
+
):
|
|
315
|
+
return await client.products.get_stock(product_id)
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
---
|
|
319
|
+
|
|
320
|
+
## RabbitMQ Worker Pattern
|
|
321
|
+
|
|
322
|
+
```python
|
|
323
|
+
# worker.py
|
|
324
|
+
from tiny_py import TinyClient
|
|
325
|
+
from tiny_py.exceptions import TinyAPIError, TinyRateLimitError
|
|
326
|
+
|
|
327
|
+
client = TinyClient(token=os.environ["TINY_TOKEN"], plan="advanced")
|
|
328
|
+
|
|
329
|
+
def handle_message(body: dict):
|
|
330
|
+
try:
|
|
331
|
+
order = client.orders.get(body["order_id"])
|
|
332
|
+
publish_result(order)
|
|
333
|
+
except TinyAPIError:
|
|
334
|
+
nack(requeue=False) # business error -> DLQ
|
|
335
|
+
except (TinyRateLimitError, TinyServerError, TinyTimeoutError):
|
|
336
|
+
nack(requeue=True) # transient error -> re-enqueue with backoff
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
One `TinyClient` instance per worker process. Never share an instance across process boundaries. For coordinated rate limiting across many workers on the same token, inject a Redis-backed limiter.
|
|
340
|
+
|
|
341
|
+
---
|
|
342
|
+
|
|
343
|
+
## Testing
|
|
344
|
+
|
|
345
|
+
- Unit tests mock `_http.HTTPAdapter` at the resource level — never mock `requests` directly.
|
|
346
|
+
- Fixtures provide pre-built `HTTPAdapter` fakes returning model-valid dicts.
|
|
347
|
+
- Integration tests are marked `@pytest.mark.integration`, skipped in CI unless `TINY_TOKEN` is set.
|
|
348
|
+
- Use `base_url` override in `TinyClient` to point at a local stub server (e.g. `pytest-httpserver`) for contract tests.
|
|
349
|
+
|
|
350
|
+
---
|
|
351
|
+
|
|
352
|
+
## API Version
|
|
353
|
+
|
|
354
|
+
This library targets **Tiny ERP API v2** (`https://api.tiny.com.br/api2`).
|
|
355
|
+
API v3 exists but uses OAuth 2.0 and a different resource structure. Do not mix v2 and v3 calls in the same client.
|
|
@@ -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
|