fiscalisto 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.
- fiscalisto-0.1.0/.gitignore +83 -0
- fiscalisto-0.1.0/LICENSE +21 -0
- fiscalisto-0.1.0/PKG-INFO +59 -0
- fiscalisto-0.1.0/README.md +42 -0
- fiscalisto-0.1.0/pyproject.toml +38 -0
- fiscalisto-0.1.0/src/fiscalisto/__init__.py +61 -0
- fiscalisto-0.1.0/src/fiscalisto/_core.py +330 -0
- fiscalisto-0.1.0/src/fiscalisto/_resources.py +2851 -0
- fiscalisto-0.1.0/src/fiscalisto/_webhooks.py +104 -0
- fiscalisto-0.1.0/src/fiscalisto/error_codes.py +333 -0
- fiscalisto-0.1.0/src/fiscalisto/py.typed +0 -0
- fiscalisto-0.1.0/src/fiscalisto/types.py +5533 -0
- fiscalisto-0.1.0/tests/test_sdk.py +272 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
|
2
|
+
|
|
3
|
+
# dependencies
|
|
4
|
+
/node_modules
|
|
5
|
+
/.pnp
|
|
6
|
+
.pnp.*
|
|
7
|
+
.yarn/*
|
|
8
|
+
!.yarn/patches
|
|
9
|
+
!.yarn/plugins
|
|
10
|
+
!.yarn/releases
|
|
11
|
+
!.yarn/versions
|
|
12
|
+
|
|
13
|
+
# testing
|
|
14
|
+
/coverage
|
|
15
|
+
|
|
16
|
+
# next.js
|
|
17
|
+
/.next/
|
|
18
|
+
/out/
|
|
19
|
+
|
|
20
|
+
# production
|
|
21
|
+
/build
|
|
22
|
+
|
|
23
|
+
# misc
|
|
24
|
+
.DS_Store
|
|
25
|
+
*.pem
|
|
26
|
+
# Public CA roots the local runtime needs (not keys).
|
|
27
|
+
!certs/*.pem
|
|
28
|
+
|
|
29
|
+
# debug
|
|
30
|
+
npm-debug.log*
|
|
31
|
+
yarn-debug.log*
|
|
32
|
+
yarn-error.log*
|
|
33
|
+
.pnpm-debug.log*
|
|
34
|
+
|
|
35
|
+
# env files (can opt-in for committing if needed)
|
|
36
|
+
.env*
|
|
37
|
+
|
|
38
|
+
# vercel
|
|
39
|
+
.vercel
|
|
40
|
+
|
|
41
|
+
# typescript
|
|
42
|
+
*.tsbuildinfo
|
|
43
|
+
next-env.d.ts
|
|
44
|
+
|
|
45
|
+
# sqlite database
|
|
46
|
+
/data/
|
|
47
|
+
|
|
48
|
+
# auth schema (generated at Docker build time)
|
|
49
|
+
schema.auth.sql
|
|
50
|
+
|
|
51
|
+
# cloudflare / vite
|
|
52
|
+
.dev.vars
|
|
53
|
+
.wrangler/
|
|
54
|
+
dist/
|
|
55
|
+
src/routeTree.gen.ts
|
|
56
|
+
.dev-login.txt
|
|
57
|
+
|
|
58
|
+
# agent worktrees
|
|
59
|
+
.claude/worktrees/
|
|
60
|
+
|
|
61
|
+
# Agents' scratch files
|
|
62
|
+
/.scratch/
|
|
63
|
+
|
|
64
|
+
# local QA runs (screenshots, scripts)
|
|
65
|
+
.qa/
|
|
66
|
+
|
|
67
|
+
.qa/
|
|
68
|
+
.tanstack/tmp/
|
|
69
|
+
test-results/
|
|
70
|
+
playwright-report/
|
|
71
|
+
# Local marketing material (screenshots, store listings), not part of the app
|
|
72
|
+
marketing-screenshots/
|
|
73
|
+
|
|
74
|
+
# Fumadocs MDX compilation cache
|
|
75
|
+
.source/
|
|
76
|
+
|
|
77
|
+
# SDKs
|
|
78
|
+
__pycache__/
|
|
79
|
+
*.egg-info/
|
|
80
|
+
/sdk/python/dist/
|
|
81
|
+
/sdk/php/vendor/
|
|
82
|
+
/sdk/php/composer.lock
|
|
83
|
+
/sdk/dist-packages/
|
fiscalisto-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Fiscalisto
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: fiscalisto
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: SDK de la API de facturación electrónica (CFDI 4.0) de Fiscalisto, para Python.
|
|
5
|
+
Project-URL: Homepage, https://fiscalisto.com/docs/api/sdk/python
|
|
6
|
+
Project-URL: Documentation, https://fiscalisto.com/docs/api
|
|
7
|
+
Project-URL: Source, https://github.com/fiscalisto/fiscalisto-python
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: cfdi,factura,facturacion,fiscalisto,mexico,sat,timbrado
|
|
11
|
+
Classifier: Natural Language :: Spanish
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Typing :: Typed
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# fiscalisto
|
|
19
|
+
|
|
20
|
+
El SDK de la [API de facturación de Fiscalisto](https://fiscalisto.com/docs/api): timbre CFDI 4.0 (facturas, pagos, notas de crédito, nómina, retenciones y lotes) desde Python.
|
|
21
|
+
|
|
22
|
+
- Tipos (`TypedDict`) para cada campo, con su descripción y su nombre en el XML del SAT; `mypy` y su editor los revisan.
|
|
23
|
+
- `Idempotency-Key` automática en cada `POST`: un reintento nunca timbra, crea ni manda nada dos veces.
|
|
24
|
+
- Reintentos solo de lo que es seguro repetir.
|
|
25
|
+
- Listas que se recorren solas con `for`.
|
|
26
|
+
- Webhooks verificados en una línea.
|
|
27
|
+
- Sin dependencias: Python 3.10 o más reciente.
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install fiscalisto
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from fiscalisto import Fiscalisto
|
|
35
|
+
|
|
36
|
+
fiscalisto = Fiscalisto() # la llave viene de FISCALISTO_API_KEY
|
|
37
|
+
|
|
38
|
+
invoice = fiscalisto.invoices.create(
|
|
39
|
+
{
|
|
40
|
+
"customer": {"tax_id": "ICV060329BY0", "legal_name": "INMOBILIARIA CVA", "tax_system": "601", "zip": "33826"},
|
|
41
|
+
"payment_form": "03",
|
|
42
|
+
"items": [{"description": "Mensualidad", "product_key": "86121700", "quantity": 2, "unit_price": 2500}],
|
|
43
|
+
},
|
|
44
|
+
idempotency_key="pedido-A-1042",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
print(invoice["uuid"], invoice["total"])
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
La guía completa está en [fiscalisto.com/docs/api/sdk](https://fiscalisto.com/docs/api/sdk) y cada página de la [referencia](https://fiscalisto.com/docs/api) trae su ejemplo con el SDK.
|
|
51
|
+
|
|
52
|
+
## Desarrollo
|
|
53
|
+
|
|
54
|
+
`src/fiscalisto/types.py` y `src/fiscalisto/_resources.py` se generan desde la especificación OpenAPI con `bun run openapi` en la raíz del repositorio; no los edite. Lo demás se escribe a mano.
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
PYTHONPATH=src python3 -m unittest discover -s tests
|
|
58
|
+
mypy --strict src && ruff check src tests
|
|
59
|
+
```
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# fiscalisto
|
|
2
|
+
|
|
3
|
+
El SDK de la [API de facturación de Fiscalisto](https://fiscalisto.com/docs/api): timbre CFDI 4.0 (facturas, pagos, notas de crédito, nómina, retenciones y lotes) desde Python.
|
|
4
|
+
|
|
5
|
+
- Tipos (`TypedDict`) para cada campo, con su descripción y su nombre en el XML del SAT; `mypy` y su editor los revisan.
|
|
6
|
+
- `Idempotency-Key` automática en cada `POST`: un reintento nunca timbra, crea ni manda nada dos veces.
|
|
7
|
+
- Reintentos solo de lo que es seguro repetir.
|
|
8
|
+
- Listas que se recorren solas con `for`.
|
|
9
|
+
- Webhooks verificados en una línea.
|
|
10
|
+
- Sin dependencias: Python 3.10 o más reciente.
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install fiscalisto
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
from fiscalisto import Fiscalisto
|
|
18
|
+
|
|
19
|
+
fiscalisto = Fiscalisto() # la llave viene de FISCALISTO_API_KEY
|
|
20
|
+
|
|
21
|
+
invoice = fiscalisto.invoices.create(
|
|
22
|
+
{
|
|
23
|
+
"customer": {"tax_id": "ICV060329BY0", "legal_name": "INMOBILIARIA CVA", "tax_system": "601", "zip": "33826"},
|
|
24
|
+
"payment_form": "03",
|
|
25
|
+
"items": [{"description": "Mensualidad", "product_key": "86121700", "quantity": 2, "unit_price": 2500}],
|
|
26
|
+
},
|
|
27
|
+
idempotency_key="pedido-A-1042",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
print(invoice["uuid"], invoice["total"])
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
La guía completa está en [fiscalisto.com/docs/api/sdk](https://fiscalisto.com/docs/api/sdk) y cada página de la [referencia](https://fiscalisto.com/docs/api) trae su ejemplo con el SDK.
|
|
34
|
+
|
|
35
|
+
## Desarrollo
|
|
36
|
+
|
|
37
|
+
`src/fiscalisto/types.py` y `src/fiscalisto/_resources.py` se generan desde la especificación OpenAPI con `bun run openapi` en la raíz del repositorio; no los edite. Lo demás se escribe a mano.
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
PYTHONPATH=src python3 -m unittest discover -s tests
|
|
41
|
+
mypy --strict src && ruff check src tests
|
|
42
|
+
```
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.24"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "fiscalisto"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "SDK de la API de facturación electrónica (CFDI 4.0) de Fiscalisto, para Python."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
dependencies = []
|
|
14
|
+
keywords = ["cfdi", "factura", "facturacion", "sat", "mexico", "timbrado", "fiscalisto"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Natural Language :: Spanish",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Typing :: Typed",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://fiscalisto.com/docs/api/sdk/python"
|
|
24
|
+
Documentation = "https://fiscalisto.com/docs/api"
|
|
25
|
+
Source = "https://github.com/fiscalisto/fiscalisto-python"
|
|
26
|
+
|
|
27
|
+
[tool.hatch.build.targets.wheel]
|
|
28
|
+
packages = ["src/fiscalisto"]
|
|
29
|
+
|
|
30
|
+
[tool.ruff]
|
|
31
|
+
line-length = 100
|
|
32
|
+
target-version = "py310"
|
|
33
|
+
extend-exclude = ["src/fiscalisto/types.py", "src/fiscalisto/_resources.py"]
|
|
34
|
+
|
|
35
|
+
[tool.mypy]
|
|
36
|
+
strict = true
|
|
37
|
+
python_version = "3.10"
|
|
38
|
+
mypy_path = "src"
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""El SDK de la API de facturación de Fiscalisto.
|
|
2
|
+
|
|
3
|
+
from fiscalisto import Fiscalisto
|
|
4
|
+
|
|
5
|
+
fiscalisto = Fiscalisto() # la llave viene de FISCALISTO_API_KEY
|
|
6
|
+
factura = fiscalisto.invoices.create({
|
|
7
|
+
"customer": {"tax_id": "ICV060329BY0", "legal_name": "INMOBILIARIA CVA", "tax_system": "601", "zip": "33826"},
|
|
8
|
+
"payment_form": "03",
|
|
9
|
+
"items": [{"description": "Consultoría", "product_key": "80101500", "unit_price": 1000}],
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
https://fiscalisto.com/docs/api
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from functools import cached_property
|
|
18
|
+
|
|
19
|
+
from ._core import (
|
|
20
|
+
VERSION,
|
|
21
|
+
ErrorType,
|
|
22
|
+
FiscalistoError,
|
|
23
|
+
HttpRequest,
|
|
24
|
+
HttpResponse,
|
|
25
|
+
ListObject,
|
|
26
|
+
Transport,
|
|
27
|
+
urllib_transport,
|
|
28
|
+
)
|
|
29
|
+
from ._resources import FiscalistoApi
|
|
30
|
+
from .error_codes import ErrorCode, ErrorCodeValue
|
|
31
|
+
from ._webhooks import FiscalistoWebhookError, Webhooks, verify_webhook
|
|
32
|
+
|
|
33
|
+
__version__ = VERSION
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Fiscalisto(FiscalistoApi):
|
|
37
|
+
"""El cliente de la API: Fiscalisto("fk_test_…") o, sin llave, FISCALISTO_API_KEY.
|
|
38
|
+
|
|
39
|
+
Opciones: base_url, max_retries (2), timeout (80 s) y transport (otro cliente HTTP)."""
|
|
40
|
+
|
|
41
|
+
@cached_property
|
|
42
|
+
def webhooks(self) -> Webhooks:
|
|
43
|
+
"""Verificar lo que llega a su endpoint."""
|
|
44
|
+
return Webhooks()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"VERSION",
|
|
49
|
+
"ErrorCode",
|
|
50
|
+
"ErrorCodeValue",
|
|
51
|
+
"ErrorType",
|
|
52
|
+
"Fiscalisto",
|
|
53
|
+
"FiscalistoError",
|
|
54
|
+
"FiscalistoWebhookError",
|
|
55
|
+
"HttpRequest",
|
|
56
|
+
"HttpResponse",
|
|
57
|
+
"ListObject",
|
|
58
|
+
"Transport",
|
|
59
|
+
"urllib_transport",
|
|
60
|
+
"verify_webhook",
|
|
61
|
+
]
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"""Lo que hace cada llamada del SDK: la llave, la Idempotency-Key, los reintentos seguros, los
|
|
2
|
+
errores y las listas que se recorren solas. Los recursos (_resources.py) solo dicen qué ruta
|
|
3
|
+
llamar."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import random
|
|
10
|
+
import re
|
|
11
|
+
import time
|
|
12
|
+
import uuid
|
|
13
|
+
from collections.abc import Callable, Iterator, Mapping
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import Any, Generic, Literal, TypeVar
|
|
16
|
+
from urllib.error import HTTPError
|
|
17
|
+
from urllib.parse import urlencode, urljoin
|
|
18
|
+
from urllib.request import Request as _UrllibRequest
|
|
19
|
+
from urllib.request import urlopen
|
|
20
|
+
|
|
21
|
+
VERSION = "0.1.0"
|
|
22
|
+
DEFAULT_BASE_URL = "https://api.fiscalisto.com"
|
|
23
|
+
|
|
24
|
+
ErrorType = Literal[
|
|
25
|
+
"invalid_request_error",
|
|
26
|
+
"authentication_error",
|
|
27
|
+
"permission_error",
|
|
28
|
+
"not_found_error",
|
|
29
|
+
"idempotency_error",
|
|
30
|
+
"rate_limit_error",
|
|
31
|
+
"invoice_error",
|
|
32
|
+
"balance_error",
|
|
33
|
+
"api_error",
|
|
34
|
+
"connection_error",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class FiscalistoError(Exception):
|
|
39
|
+
"""Un error de la API (o de la conexión, con type connection_error). code es estable:
|
|
40
|
+
decida con él; el mensaje es para mostrarlo a una persona."""
|
|
41
|
+
|
|
42
|
+
type: ErrorType
|
|
43
|
+
code: str
|
|
44
|
+
message: str
|
|
45
|
+
#: El campo que hay que corregir (items.0.unit_price).
|
|
46
|
+
param: str | None
|
|
47
|
+
#: El código del SAT o del PAC cuando ellos lo rechazaron (CFDI40145).
|
|
48
|
+
sat_code: str | None
|
|
49
|
+
#: El comprobante rechazado (inv_…), cuando lo hay.
|
|
50
|
+
invoice: str | None
|
|
51
|
+
doc_url: str | None
|
|
52
|
+
#: Búsquelo en Desarrolladores › Solicitudes o mándelo a soporte.
|
|
53
|
+
request_id: str | None
|
|
54
|
+
#: El código HTTP (sin él, la solicitud no llegó).
|
|
55
|
+
status: int | None
|
|
56
|
+
#: El objeto error tal como llegó.
|
|
57
|
+
raw: dict[str, Any]
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self, raw: Mapping[str, Any], status: int | None = None, request_id: str | None = None
|
|
61
|
+
) -> None:
|
|
62
|
+
def text(k: str) -> str | None:
|
|
63
|
+
v = raw.get(k)
|
|
64
|
+
return v if isinstance(v, str) else None
|
|
65
|
+
|
|
66
|
+
self.raw = dict(raw)
|
|
67
|
+
self.message = text("message") or "Error de la API de Fiscalisto"
|
|
68
|
+
self.type = text("type") or "api_error" # type: ignore[assignment]
|
|
69
|
+
self.code = text("code") or "unknown"
|
|
70
|
+
self.param = text("param")
|
|
71
|
+
self.sat_code = text("sat_code")
|
|
72
|
+
self.invoice = text("invoice")
|
|
73
|
+
self.doc_url = text("doc_url")
|
|
74
|
+
self.request_id = text("request_id") or request_id
|
|
75
|
+
self.status = status
|
|
76
|
+
super().__init__(self.message)
|
|
77
|
+
|
|
78
|
+
def __repr__(self) -> str:
|
|
79
|
+
return (
|
|
80
|
+
f"FiscalistoError(code={self.code!r}, status={self.status!r}, message={self.message!r})"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class HttpRequest:
|
|
86
|
+
"""Lo que el SDK manda (para un transport propio)."""
|
|
87
|
+
|
|
88
|
+
method: str
|
|
89
|
+
url: str
|
|
90
|
+
headers: dict[str, str]
|
|
91
|
+
body: bytes | None
|
|
92
|
+
timeout: float
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass
|
|
96
|
+
class HttpResponse:
|
|
97
|
+
"""Lo que el transport contesta: headers con nombres en minúsculas."""
|
|
98
|
+
|
|
99
|
+
status: int
|
|
100
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
101
|
+
body: bytes = b""
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
Transport = Callable[[HttpRequest], HttpResponse]
|
|
105
|
+
"""Otro cliente HTTP (pruebas, proxies). Lanza OSError (o TimeoutError) si no hubo respuesta."""
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def urllib_transport(request: HttpRequest) -> HttpResponse:
|
|
109
|
+
"""El transport de siempre: urllib de la biblioteca estándar."""
|
|
110
|
+
req = _UrllibRequest(
|
|
111
|
+
request.url, data=request.body, headers=request.headers, method=request.method
|
|
112
|
+
)
|
|
113
|
+
try:
|
|
114
|
+
with urlopen(req, timeout=request.timeout) as res:
|
|
115
|
+
return HttpResponse(
|
|
116
|
+
res.status, {k.lower(): v for k, v in res.headers.items()}, res.read()
|
|
117
|
+
)
|
|
118
|
+
except HTTPError as error:
|
|
119
|
+
with error:
|
|
120
|
+
return HttpResponse(
|
|
121
|
+
error.code,
|
|
122
|
+
{k.lower(): v for k, v in (error.headers or {}).items()},
|
|
123
|
+
error.read(),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
T = TypeVar("T")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class ListObject(Generic[T]):
|
|
131
|
+
"""Una lista: data y has_more son la primera página; recorrerla con for da todos los
|
|
132
|
+
objetos, página por página."""
|
|
133
|
+
|
|
134
|
+
def __init__(self, page: Callable[[str | None], dict[str, Any]]) -> None:
|
|
135
|
+
self._page = page
|
|
136
|
+
self._first: dict[str, Any] | None = None
|
|
137
|
+
|
|
138
|
+
def first_page(self) -> dict[str, Any]:
|
|
139
|
+
"""La primera página, como la contesta la API (object, data, has_more, url)."""
|
|
140
|
+
if self._first is None:
|
|
141
|
+
self._first = self._page(None)
|
|
142
|
+
return self._first
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def data(self) -> list[T]:
|
|
146
|
+
return self.first_page()["data"] # type: ignore[no-any-return]
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def has_more(self) -> bool:
|
|
150
|
+
return bool(self.first_page()["has_more"])
|
|
151
|
+
|
|
152
|
+
def __iter__(self) -> Iterator[T]:
|
|
153
|
+
page = self.first_page()
|
|
154
|
+
while True:
|
|
155
|
+
items = page["data"]
|
|
156
|
+
yield from items
|
|
157
|
+
if not page["has_more"] or not items:
|
|
158
|
+
return
|
|
159
|
+
page = self._page(items[-1]["id"])
|
|
160
|
+
|
|
161
|
+
def to_list(self, max: int = 10_000) -> list[T]:
|
|
162
|
+
"""Todos los objetos en una lista, hasta max (por omisión 10,000)."""
|
|
163
|
+
out: list[T] = []
|
|
164
|
+
for item in self:
|
|
165
|
+
if len(out) >= max:
|
|
166
|
+
break
|
|
167
|
+
out.append(item)
|
|
168
|
+
return out
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _backoff(attempt: int) -> float:
|
|
172
|
+
"""0.5 s, 1 s, 2 s… hasta 8 s, con algo de azar para no llegar todos juntos."""
|
|
173
|
+
return min(8.0, 0.5 * 2.0**attempt) * (0.75 + random.random() / 2)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _query_value(value: Any) -> str:
|
|
177
|
+
if value is True:
|
|
178
|
+
return "true"
|
|
179
|
+
if value is False:
|
|
180
|
+
return "false"
|
|
181
|
+
return str(value)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
_KEY = re.compile(r"^fk_(live|test)_")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class Core:
|
|
188
|
+
"""El cliente: la llave, la dirección de la API, los reintentos y el transport."""
|
|
189
|
+
|
|
190
|
+
def __init__(
|
|
191
|
+
self,
|
|
192
|
+
api_key: str | None = None,
|
|
193
|
+
*,
|
|
194
|
+
base_url: str | None = None,
|
|
195
|
+
max_retries: int = 2,
|
|
196
|
+
timeout: float = 80.0,
|
|
197
|
+
transport: Transport | None = None,
|
|
198
|
+
sleep: Callable[[float], None] | None = None,
|
|
199
|
+
) -> None:
|
|
200
|
+
"""api_key: su llave fk_test_… o fk_live_…; sin ella, la variable FISCALISTO_API_KEY.
|
|
201
|
+
timeout: segundos para cada respuesta (80 por omisión: el PAC puede tardar)."""
|
|
202
|
+
key = api_key if api_key is not None else os.environ.get("FISCALISTO_API_KEY")
|
|
203
|
+
if not key:
|
|
204
|
+
raise ValueError(
|
|
205
|
+
'Falta la llave de la API: Fiscalisto("fk_test_…") o la variable '
|
|
206
|
+
"FISCALISTO_API_KEY. Créela en Desarrolladores › Llaves."
|
|
207
|
+
)
|
|
208
|
+
if not _KEY.match(key):
|
|
209
|
+
raise ValueError(
|
|
210
|
+
"La llave de la API empieza con fk_live_ o fk_test_. (Un secreto whsec_ es "
|
|
211
|
+
"para verificar webhooks.)"
|
|
212
|
+
)
|
|
213
|
+
self._key = key
|
|
214
|
+
self._base_url = base_url or DEFAULT_BASE_URL
|
|
215
|
+
self._max_retries = max_retries
|
|
216
|
+
self._timeout = timeout
|
|
217
|
+
self._transport = transport or urllib_transport
|
|
218
|
+
self._sleep = sleep or time.sleep
|
|
219
|
+
|
|
220
|
+
@property
|
|
221
|
+
def livemode(self) -> bool:
|
|
222
|
+
"""True con una llave fk_live_ (producción), False con fk_test_ (pruebas)."""
|
|
223
|
+
return self._key.startswith("fk_live_")
|
|
224
|
+
|
|
225
|
+
def request(
|
|
226
|
+
self,
|
|
227
|
+
method: str,
|
|
228
|
+
path: str,
|
|
229
|
+
*,
|
|
230
|
+
body: Any = None,
|
|
231
|
+
query: Mapping[str, Any] | None = None,
|
|
232
|
+
idempotent: bool = False,
|
|
233
|
+
result: Literal["json", "bytes", "text"] = "json",
|
|
234
|
+
idempotency_key: str | None = None,
|
|
235
|
+
timeout: float | None = None,
|
|
236
|
+
max_retries: int | None = None,
|
|
237
|
+
) -> Any:
|
|
238
|
+
url = urljoin(self._base_url, path)
|
|
239
|
+
if query:
|
|
240
|
+
pairs = [(k, _query_value(v)) for k, v in query.items() if v is not None]
|
|
241
|
+
if pairs:
|
|
242
|
+
url = f"{url}?{urlencode(pairs)}"
|
|
243
|
+
headers = {
|
|
244
|
+
"authorization": f"Bearer {self._key}",
|
|
245
|
+
"user-agent": f"fiscalisto-python/{VERSION}",
|
|
246
|
+
"accept": "application/json",
|
|
247
|
+
}
|
|
248
|
+
key = (idempotency_key or str(uuid.uuid4())) if idempotent else None
|
|
249
|
+
if key:
|
|
250
|
+
headers["idempotency-key"] = key
|
|
251
|
+
data: bytes | None = None
|
|
252
|
+
if method == "POST":
|
|
253
|
+
headers["content-type"] = "application/json"
|
|
254
|
+
data = json.dumps(body if body is not None else {}, ensure_ascii=False).encode()
|
|
255
|
+
# Only what can't do something twice is repeated after a failure we can't see through:
|
|
256
|
+
# reads, deletes and requests with an Idempotency-Key. A 429 was never processed.
|
|
257
|
+
safe = method in ("GET", "DELETE") or key is not None
|
|
258
|
+
retries = self._max_retries if max_retries is None else max_retries
|
|
259
|
+
wait = self._timeout if timeout is None else timeout
|
|
260
|
+
|
|
261
|
+
attempt = 0
|
|
262
|
+
while True:
|
|
263
|
+
try:
|
|
264
|
+
res = self._transport(HttpRequest(method, url, dict(headers), data, wait))
|
|
265
|
+
except OSError as error:
|
|
266
|
+
if safe and attempt < retries:
|
|
267
|
+
self._sleep(_backoff(attempt))
|
|
268
|
+
attempt += 1
|
|
269
|
+
continue
|
|
270
|
+
timed_out = isinstance(error, TimeoutError) or "timed out" in str(error)
|
|
271
|
+
hint = (
|
|
272
|
+
f" Repita con la misma Idempotency-Key ({key}) para saber si se timbró."
|
|
273
|
+
if key
|
|
274
|
+
else ""
|
|
275
|
+
)
|
|
276
|
+
raise FiscalistoError(
|
|
277
|
+
{
|
|
278
|
+
"type": "connection_error",
|
|
279
|
+
"code": "timeout" if timed_out else "connection_failed",
|
|
280
|
+
"message": f"La API no contestó en {wait:g} s.{hint}"
|
|
281
|
+
if timed_out
|
|
282
|
+
else f"No se pudo conectar con la API: {error}",
|
|
283
|
+
"idempotency_key": key,
|
|
284
|
+
}
|
|
285
|
+
) from error
|
|
286
|
+
request_id = res.headers.get("request-id")
|
|
287
|
+
if 200 <= res.status < 300:
|
|
288
|
+
if result == "bytes":
|
|
289
|
+
return res.body
|
|
290
|
+
if result == "text":
|
|
291
|
+
return res.body.decode("utf-8")
|
|
292
|
+
return json.loads(res.body)
|
|
293
|
+
try:
|
|
294
|
+
parsed = json.loads(res.body)
|
|
295
|
+
raw = parsed.get("error") if isinstance(parsed, dict) else None
|
|
296
|
+
except ValueError:
|
|
297
|
+
raw = None
|
|
298
|
+
raw = raw if isinstance(raw, dict) else {}
|
|
299
|
+
try:
|
|
300
|
+
retry_after = float(res.headers.get("retry-after", ""))
|
|
301
|
+
except ValueError:
|
|
302
|
+
retry_after = 0.0
|
|
303
|
+
retryable = (
|
|
304
|
+
res.status == 429
|
|
305
|
+
or raw.get("code") == "request_in_progress"
|
|
306
|
+
or (safe and res.status >= 500)
|
|
307
|
+
)
|
|
308
|
+
if retryable and attempt < retries and not retry_after > 60:
|
|
309
|
+
self._sleep(retry_after if retry_after > 0 else _backoff(attempt))
|
|
310
|
+
attempt += 1
|
|
311
|
+
continue
|
|
312
|
+
raise FiscalistoError(raw, res.status, request_id)
|
|
313
|
+
|
|
314
|
+
def list(
|
|
315
|
+
self,
|
|
316
|
+
path: str,
|
|
317
|
+
query: Mapping[str, Any] | None,
|
|
318
|
+
*,
|
|
319
|
+
timeout: float | None = None,
|
|
320
|
+
max_retries: int | None = None,
|
|
321
|
+
) -> ListObject[Any]:
|
|
322
|
+
def page(starting_after: str | None) -> dict[str, Any]:
|
|
323
|
+
q = dict(query or {})
|
|
324
|
+
if starting_after:
|
|
325
|
+
q["starting_after"] = starting_after
|
|
326
|
+
return self.request( # type: ignore[no-any-return]
|
|
327
|
+
"GET", path, query=q, timeout=timeout, max_retries=max_retries
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
return ListObject(page)
|