From cfbc4a4bf8ba2bcfdc13e221b3e6cb7b99aeb70c Mon Sep 17 00:00:00 2001 From: "raul@facturapi.io" Date: Mon, 12 Oct 2026 10:44:22 -0600 Subject: [PATCH] feat(lib): add new version 2.0.0 of python --- .github/workflows/auto_merge.yml | 2 +- .github/workflows/docs.yml | 8 +- .github/workflows/openapi.yml | 40 + .github/workflows/release.yml | 44 +- .github/workflows/test.yml | 22 +- MANIFEST.in | 8 + Makefile | 25 +- README.es-MX.md | 62 +- README.md | 197 ++-- docs/images/facturapi-logo.svg | 160 +-- docs/index.md | 108 +- docs/openapi.md | 105 ++ facturapi/__init__.py | 3 + facturapi/_generated.py | 1737 +++++++++++++++++++++++++++++ facturapi/client.py | 85 ++ facturapi/http/client.py | 192 +++- facturapi/resources/base.py | 24 +- facturapi/resources/invoices.py | 2 +- facturapi/types/enums.py | 2 +- facturapi/types/exc.py | 12 +- facturapi/types/queries.py | 5 +- facturapi/version.py | 2 +- mkdocs.yml | 9 +- pyproject.toml | 52 + requirements-docs.txt | 2 + requirements-test.txt | 4 + requirements.txt | 2 +- scripts/generate_openapi.py | 447 ++++++++ setup.cfg | 8 +- setup.py | 36 +- tests/http/test_client.py | 146 +++ tests/resources/test_resources.py | 39 + tests/test_generated_client.py | 127 +++ tests/types/test_queries.py | 2 +- 34 files changed, 3293 insertions(+), 426 deletions(-) create mode 100644 .github/workflows/openapi.yml create mode 100644 MANIFEST.in create mode 100644 docs/openapi.md create mode 100644 facturapi/_generated.py create mode 100644 facturapi/client.py create mode 100644 pyproject.toml create mode 100644 requirements-docs.txt create mode 100644 scripts/generate_openapi.py create mode 100644 tests/test_generated_client.py diff --git a/.github/workflows/auto_merge.yml b/.github/workflows/auto_merge.yml index 25db3d0..001e3ce 100644 --- a/.github/workflows/auto_merge.yml +++ b/.github/workflows/auto_merge.yml @@ -10,7 +10,7 @@ jobs: auto-merge: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: ahmadnassri/action-dependabot-auto-merge@v2 with: github-token: ${{ secrets.DEPENDABOT_AUTOMERGE }} diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 0db0bd6..360c92b 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -1,7 +1,7 @@ name: docs permissions: - contents: read + contents: write on: release: @@ -11,14 +11,14 @@ jobs: deploy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Set up Python 3.13 - uses: actions/setup-python@v5 + uses: actions/setup-python@v7 with: python-version: 3.13 - name: Install dependencies run: make install-test - name: Install mkdocs - run: pip install -qU mkdocs-material mkdocstrings + run: python -m pip install -r requirements-docs.txt - name: Deploy Github Page run: mkdocs gh-deploy --force diff --git a/.github/workflows/openapi.yml b/.github/workflows/openapi.yml new file mode 100644 index 0000000..1a6edb5 --- /dev/null +++ b/.github/workflows/openapi.yml @@ -0,0 +1,40 @@ +name: update-openapi-client + +on: + workflow_dispatch: + schedule: + - cron: '17 12 * * 1' + +permissions: + contents: write + pull-requests: write + +jobs: + generate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + with: + python-version: '3.13' + - name: Install generator dependencies + run: python -m pip install -r requirements.txt -r requirements-test.txt + - name: Generate the SDK from the current OpenAPI document + run: python scripts/generate_openapi.py + - name: Verify generated client + run: | + make lint + pytest -x + - name: Create update pull request + uses: peter-evans/create-pull-request@v8 + with: + branch: codex/update-openapi-client + delete-branch: true + commit-message: 'chore: update generated OpenAPI client' + title: 'chore: update generated OpenAPI client' + body: | + Automated SDK update generated from Facturapi's public OpenAPI + document. CI verifies formatting, typing, and tests before merge. + labels: | + automated + openapi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d0b2a6e..8a74174 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,27 +1,41 @@ name: release -permissions: - contents: read - on: release: types: [published] jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - name: Set up Python + uses: actions/setup-python@v7 + with: + python-version: '3.13' + - name: Build distributions + run: | + python -m pip install --upgrade build twine + python -c "import os, runpy; version = runpy.run_path('facturapi/version.py')['__version__']; assert os.environ['GITHUB_REF_NAME'] == f'v{version}', 'release tag must match package version'" + python -m build + python -m twine check dist/* + - uses: actions/upload-artifact@v7 + with: + name: python-package-distributions + path: dist/ + publish-pypi: + needs: build runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/facturapi + permissions: + id-token: write steps: - - uses: actions/checkout@master - - name: Set up Python 3.8 - uses: actions/setup-python@v2.2.2 + - uses: actions/download-artifact@v8 with: - python-version: 3.8 - - name: Install dependencies - run: pip install -qU setuptools wheel twine - - name: Generating distribution archives - run: python setup.py sdist bdist_wheel - - name: Publish distribution 📦 to PyPI + name: python-package-distributions + path: dist/ + - name: Publish distributions to PyPI uses: pypa/gh-action-pypi-publish@release/v1 - with: - user: __token__ - password: ${{ secrets.pypi_password }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cba0572..a6b2084 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,9 +9,9 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v7 with: python-version: 3.13 - name: Install dependencies @@ -23,11 +23,11 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.10", "3.11", "3.12", "3.13"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v7 with: python-version: ${{ matrix.python-version }} - name: Install dependencies @@ -37,10 +37,13 @@ jobs: coverage: runs-on: ubuntu-latest + permissions: + contents: read + id-token: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Setup Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v7 with: python-version: 3.13 - name: Install dependencies @@ -50,8 +53,9 @@ jobs: - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 with: - token: ${{ secrets.CODECOV_TOKEN }} - file: ./coverage.xml + use_oidc: true + slug: ${{ github.repository }} + files: ./coverage.xml flags: unittests name: codecov-umbrella fail_ci_if_error: true diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..effef3b --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,8 @@ +include LICENSE +include README.md +include README.es-MX.md +include requirements-docs.txt +recursive-include docs *.md *.png *.svg +recursive-include examples *.py +recursive-include scripts *.py +recursive-include tests *.py *.yaml diff --git a/Makefile b/Makefile index 46e6780..b6e6a30 100644 --- a/Makefile +++ b/Makefile @@ -2,8 +2,8 @@ SHELL := bash PATH := ./venv/bin:${PATH} PYTHON = python3.13 PROJECT = facturapi -isort = isort $(PROJECT) tests setup.py examples -black = black -S -l 79 --target-version py313 $(PROJECT) tests setup.py examples +isort = isort $(PROJECT) tests scripts setup.py examples +black = black -S -l 79 --target-version py313 $(PROJECT) tests scripts setup.py examples all: test @@ -21,15 +21,21 @@ install-test: install test: clean install-test lint pytest +generate: + python scripts/generate_openapi.py + +generate-check: + python scripts/generate_openapi.py --check + format: $(isort) $(black) lint: - flake8 $(PROJECT) tests setup.py + flake8 $(PROJECT) tests scripts setup.py $(isort) --check-only $(black) --check - mypy $(PROJECT) tests + mypy $(PROJECT) tests scripts clean: rm -rf `find . -name __pycache__` @@ -46,9 +52,12 @@ clean: rm -rf build rm -rf dist -release: test clean - python setup.py sdist bdist_wheel - twine upload dist/* +build: clean + python -m build + python -m twine check dist/* + +release: test build + @echo "Create a GitHub release to publish with PyPI Trusted Publishing." -.PHONY: all install-test test format lint clean release +.PHONY: all install-test test generate generate-check format lint clean build release diff --git a/README.es-MX.md b/README.es-MX.md index 4c4dc44..e7bedad 100644 --- a/README.es-MX.md +++ b/README.es-MX.md @@ -7,14 +7,11 @@ FacturAPI – Librería de Python -![test](https://github.com/cuenca-mx/facturapi-python/workflows/test/badge.svg) -[![codecov](https://codecov.io/gh/cuenca-mx/facturapi-python/branch/main/graph/badge.svg?token=U89Q4PR339)](https://codecov.io/gh/cuenca-mx/facturapi-python) +![test](https://github.com/FacturAPI/facturapi-python/actions/workflows/test.yml/badge.svg) [![PyPI version](https://badge.fury.io/py/facturapi.svg)](https://badge.fury.io/py/facturapi) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) -Esta es una librería de Python para https://www.facturapi.io - -**⚠️Advertencia: Esta librería NO es oficial y aún sigue en desarrollo. No es un producto final.⚠️** +SDK oficial de Python para https://www.facturapi.io FacturAPI facilita la creación de facturas válidas para desarrolladores en México (Facturas Electrónicas o CFDI). @@ -30,39 +27,28 @@ pip install facturapi ## 🚀 Primeros pasos -### Autenticación con API Key +### Cliente recomendado Asegurate de crear una cuenta gratis en [FacturAPI](https://www.facturapi.io) y tener acceso a tus llaves de test y live. -Hay dos formas de configurar tus llaves para usar la librería: - -1. **Variable de entorno:** Configura una variable de entorno y la librería usará la llave automáticamente: - - ```bash - export FACTURAPI_KEY=TU_API_KEY - ``` - -2. **Usando el método `configure`:** Puedes configurar tu llave en el código usando el método `configure`: - - ```python - from facturapi import configure - - configure(api_key='TU_API_KEY') - ``` - -### Crea un Cliente -Después de configurar tu llave, puedes usar la librería para realizar varias acciones en los recursos de FacturAPI, por ejempo crear un Cliente: +El cliente por instancia incluye todos los métodos de la referencia de API: ```python -import facturapi -from facturapi.resources.customers import CustomerRequest, CustomerUpdateRequest - -customer = facturapi.Customer.create(data=CustomerRequest( - legal_name='Frida Kahlo', - tax_id='ABCD111111CBA', - email='frida_kahlo@test.com', -)) +from facturapi import Facturapi + +facturapi = Facturapi("sk_test_...") +customer = facturapi.customers.create({ + "legal_name": "Frida Kahlo", + "tax_id": "ABCD111111CBA", + "tax_system": "601", + "email": "frida@example.com", + "address": {"zip": "01234", "country": "MEX"}, +}) ``` +También puedes omitir la llave y usar la variable de entorno +`FACTURAPI_KEY`. La API anterior basada en `facturapi.Customer` y +`facturapi.Invoice` sigue disponible por compatibilidad. + Para más detalles en los datos necesarios para crear un Cliente y otros recursos, revisa la [documentación](http://docs.facturapi.io). Puedes encontrar más ejemplos de código de cómo crear y usar recursos en el directorio de [examples](./examples/). @@ -71,7 +57,8 @@ Puedes encontrar más ejemplos de código de cómo crear y usar recursos en el d Encuentra más información de Facturapi y sus recursos en sus [docs](http://docs.facturapi.io). -O para más detalles técnicos y de la librería, entra a la [documentación](https://cuenca-mx.github.io/facturapi-python/) de `facturapi-python`. +La capa de recursos se genera automáticamente desde el documento OpenAPI de +Facturapi. El workflow semanal propone un pull request cuando la API cambia. ## 💡 Contribuye ### ¿Encontraste un bug? @@ -84,12 +71,5 @@ No olvides mantener la cobertura del código arriba de 98%, nos encanta tener c ### Contactos: - **Facturapi:** contacto@facturapi.io -- **Cuenca:** dev@cuenca.com - --- -Desarrollado y mantenido con 💙 por [Cuenca](https://cuenca.com/) -

- - Cuenca Logo - -

+Desarrollado y mantenido por [Facturapi](https://facturapi.io/). diff --git a/README.md b/README.md index 888dc65..cb11e10 100644 --- a/README.md +++ b/README.md @@ -1,97 +1,166 @@

- - Facturapi Logo - + + Facturapi Logo +

-

- FacturAPI – Python Client Library -

-![test](https://github.com/cuenca-mx/facturapi-python/workflows/test/badge.svg) -[![codecov](https://codecov.io/gh/cuenca-mx/facturapi-python/branch/main/graph/badge.svg?token=U89Q4PR339)](https://codecov.io/gh/cuenca-mx/facturapi-python) -[![PyPI version](https://badge.fury.io/py/facturapi.svg)](https://badge.fury.io/py/facturapi) -[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) +# Facturapi Python SDK + +[![tests](https://github.com/FacturAPI/facturapi-python/actions/workflows/test.yml/badge.svg)](https://github.com/FacturAPI/facturapi-python/actions/workflows/test.yml) +[![PyPI](https://img.shields.io/pypi/v/facturapi.svg)](https://pypi.org/project/facturapi/) +[![Python](https://img.shields.io/pypi/pyversions/facturapi.svg)](https://pypi.org/project/facturapi/) + +The official synchronous Python client for the +[Facturapi API](https://docs.facturapi.io/en/api/). It supports every operation +in the public OpenAPI document while keeping the original `Customer` and +`Invoice` class API backwards compatible. [Español](./README.es-MX.md) -This is a Python library for [FacturAPI](https://www.facturapi.io) +## Install -**⚠️Warning: This is still an unofficial library and it is still under development. This is not a final product.⚠️** +```bash +pip install facturapi +``` -FacturAPI makes it easy for developers to generate valid Invoices in Mexico (known as Factura Electrónica or CFDI). +Python 3.10 or newer is required. -This library is based on [`cuenca-python`](https://github.com/cuenca-mx/cuenca-python) 💙. +## Quick start -## 💻 Installation +```python +import os + +from facturapi import Facturapi + +facturapi = Facturapi(os.environ["FACTURAPI_KEY"]) + +customer = facturapi.customers.create( + { + "legal_name": "Dunder Mifflin", + "tax_id": "ABC101010111", + "tax_system": "601", + "email": "accounting@example.com", + "address": {"zip": "01234", "country": "MEX"}, + } +) + +invoice = facturapi.invoices.create( + { + "customer": customer["id"], + "payment_form": "03", + "items": [ + { + "quantity": 1, + "product": { + "description": "Consulting", + "product_key": "80101500", + "price": 1000, + }, + } + ], + } +) + +pdf = facturapi.invoices.download_pdf(invoice["id"]) +``` -Just use `pip` to install de library: +If the key is omitted, `Facturapi()` reads `FACTURAPI_KEY`. The timeout can be +set per client or through `FACTURAPI_TIMEOUT`. -```bash -pip install facturapi +```python +with Facturapi("sk_test_...", timeout=30) as facturapi: + result = facturapi.invoices.list( + { + "status": ["valid", "canceled"], + "date": {"gte": "2026-01-01T00:00:00Z"}, + } + ) ``` -## 🚀 Getting started +## API resources -### Authenticate with your API Key -Be sure to create a free account on [FacturAPI](https://www.facturapi.io) and have access to your test or live API keys. +The instance client follows the same resource layout as `facturapi-node`: -There are two ways to configure your API Key to use the library: +- `customers` +- `products` +- `invoices` +- `receipts` +- `retentions` +- `organizations`, including users, roles, API keys, certificates, and series +- `webhooks` +- `tools` +- `catalogs` +- `nomina_catalogs` +- `carta_porte_catalogs` +- `comercio_exterior_catalogs` -1. **Environment variable:** Just set an environment variable and the library will automatically use it: - - ```bash - export FACTURAPI_KEY=YOUR_API_KEY - ``` +Methods use `snake_case`, accept normal Python dictionaries, and return decoded +JSON. Download methods return `bytes`. Multipart organization methods accept an +`httpx`-compatible `files` mapping. -2. **Using the `configure` method:** If you want to set it in the code, you can import and use the `configure` method: +```python +files = { + "cer": ("certificate.cer", open("certificate.cer", "rb")), + "key": ("certificate.key", open("certificate.key", "rb")), +} +facturapi.organizations.upload_certificate( + "org_123", + files, + {"password": "certificate-password"}, +) +``` + +For parameter and payload details, use the +[Facturapi API reference](https://docs.facturapi.io/en/api/). Each generated +method includes its OpenAPI operation and endpoint in its docstring. - ```python - from facturapi import configure - - configure(api_key='YOUR_API_KEY') - ``` +## Backwards compatibility -### Create a customer -After configuring the API Key, you can use the client to perform many actions on the resources, for example to create -a Customer: +The original class API still works: ```python import facturapi -from facturapi.resources.customers import CustomerRequest, CustomerUpdateRequest - -customer = facturapi.Customer.create(data=CustomerRequest( - legal_name='Frida Kahlo', - tax_id='ABCD111111CBA', - email='frida_kahlo@test.com', -)) +from facturapi.resources.customers import CustomerRequest + +facturapi.configure(api_key="sk_test_...") +customer = facturapi.Customer.create( + CustomerRequest( + legal_name="Dunder Mifflin", + tax_id="ABC101010111", + tax_system="601", + email="accounting@example.com", + address={"zip": "01234", "country": "MEX"}, + ) +) ``` -For more details on the data used to create a Customer and other resources, be sure to check out the [docs](http://docs.facturapi.io). +## OpenAPI updates -More examples can be found on the [examples](./examples/) directory. +The resource layer is generated from Facturapi's public OpenAPI document: -## 📚 Documentation +```bash +make generate +make generate-check +``` -You can checkout Facturapi's [docs](http://docs.facturapi.io) for more info on the API and its resources. +The `update-openapi-client` GitHub Actions workflow checks the current document +weekly and opens a pull request when generated methods change. Generated code +lives in `facturapi/_generated.py`; edit the generator instead of that file. -Or checkout the library's [docs](https://cuenca-mx.github.io/facturapi-python/) for usage and more technical details. +## Development and releases -## 💡 Contribute -### Found a bug? -Please create an issue addressing the bug and how to replicate it. -### Want to contribute? -Be sure to fork the repo and send your PR! Any and all help is appreciated. -Also don't forget to keep the coverage above 98%, we love tested code! +```bash +make venv +make test +make build +``` -### Contacts: -- **Facturapi:** contacto@facturapi.io +Publishing uses PyPI Trusted Publishing. Maintainers publish a GitHub release; +the release workflow builds, verifies, and uploads the wheel and source archive +without a long-lived PyPI token. The PyPI project must trust the +`FacturAPI/facturapi-python` repository, the `release.yml` workflow, and the +`pypi` environment. -- **Cuenca:** dev@cuenca.com +## License ---- -Developed and maintained with 💙 by [Cuenca](https://github.com/cuenca-mx) -

- - Cuenca Logo - -

+[MIT](./LICENSE) diff --git a/docs/images/facturapi-logo.svg b/docs/images/facturapi-logo.svg index 0bb3233..9892a50 100644 --- a/docs/images/facturapi-logo.svg +++ b/docs/images/facturapi-logo.svg @@ -1,117 +1,47 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + diff --git a/docs/index.md b/docs/index.md index 410efe1..560dc04 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,97 +1,39 @@

- - Facturapi Logo - + + Facturapi Logo +

-

- FacturAPI – Python Client Library -

-![test](https://github.com/cuenca-mx/facturapi-python/workflows/test/badge.svg) -[![codecov](https://codecov.io/gh/cuenca-mx/facturapi-python/branch/main/graph/badge.svg?token=U89Q4PR339)](https://codecov.io/gh/cuenca-mx/facturapi-python) -[![PyPI version](https://badge.fury.io/py/facturapi.svg)](https://badge.fury.io/py/facturapi) -[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) +# Facturapi Python SDK -[Español](./README.es-MX.md) +The official synchronous Python client for Facturapi. The generated instance +client covers every operation in the public OpenAPI document, and the original +`Customer` and `Invoice` classes remain available for compatibility. -This is a Python library for [FacturAPI](https://www.facturapi.io) - -**⚠️Warning: This is still an unofficial library and it is still under development. This is not a final product.⚠️** - -FacturAPI makes it easy for developers to generate valid Invoices in Mexico (known as Factura Electrónica or CFDI). - -This library is based on [`cuenca-python`](https://github.com/cuenca-mx/cuenca-python) 💙. - -## 💻 Installation - -Just use `pip` to install de library: +## Installation ```bash pip install facturapi ``` -## 🚀 Getting started - -### Authenticate with your API Key -Be sure to create a free account on [FacturAPI](https://www.facturapi.io) and have access to your test or live API keys. - -There are two ways to configure your API Key to use the library: - -1. **Environment variable:** Just set an environment variable and the library will automatically use it: - - ```bash - export FACTURAPI_KEY=YOUR_API_KEY - ``` - -2. **Using the `configure` method:** If you want to set it in the code, you can import and use the `configure` method: - - ```python - from facturapi import configure - - configure(api_key='YOUR_API_KEY') - ``` - -### Create a customer -After configuring the API Key, you can use the client to perform many actions on the resources, for example to create -a Customer: +## Getting started ```python -import facturapi -from facturapi.resources.customers import CustomerRequest, CustomerUpdateRequest - -customer = facturapi.Customer.create(data=CustomerRequest( - legal_name='Frida Kahlo', - tax_id='ABCD111111CBA', - email='frida_kahlo@test.com', -)) +from facturapi import Facturapi + +api = Facturapi("sk_test_...") +customer = api.customers.create( + { + "legal_name": "Dunder Mifflin", + "tax_id": "ABC101010111", + "tax_system": "601", + "email": "accounting@example.com", + "address": {"zip": "01234", "country": "MEX"}, + } +) ``` -For more details on the data used to create a Customer and other resources, be sure to check out the [docs](http://docs.facturapi.io). - -More examples can be found on the [examples](./examples/) directory. - -## 📚 Documentation - -You can checkout Facturapi's [docs](http://docs.facturapi.io) for more info on the API and its resources. - -Or checkout the library's [docs](https://cuenca-mx.github.io/facturapi-python/) for usage and more technical details. - -## 💡 Contribute -### Found a bug? -Please create an issue addressing the bug and how to replicate it. -### Want to contribute? -Be sure to fork the repo and send your PR! Any and all help is appreciated. -Also don't forget to keep the coverage above 98%, we love tested code! - -### Contacts: -- **Facturapi:** contacto@facturapi.io - -- **Cuenca:** dev@cuenca.com - ---- -Developed and maintained with 💙 by [Cuenca](https://github.com/cuenca-mx) -

- - Cuenca Logo - -

+If the API key is omitted, the client reads `FACTURAPI_KEY`. See the +[generated API client](./openapi.md) for resource coverage and the +[Facturapi API reference](https://docs.facturapi.io/en/api/) for request and +response fields. diff --git a/docs/openapi.md b/docs/openapi.md new file mode 100644 index 0000000..0b1f36d --- /dev/null +++ b/docs/openapi.md @@ -0,0 +1,105 @@ +# Generated API client + +`Facturapi` exposes every operation in the public Facturapi OpenAPI document. +Create an instance with an API key and use one of its resource clients: + +```python +from facturapi import Facturapi + +api = Facturapi("sk_test_...") +products = api.products.list({"page": 1, "limit": 50}) +``` + +| Attribute | Coverage | +| --- | --- | +| `customers` | Customer CRUD, edit links, tax validation | +| `products` | Product CRUD | +| `invoices` | CRUD, drafts, cancellation, email, previews, downloads, ZIP requests | +| `receipts` | CRUD, invoice conversion, previews, email, downloads | +| `retentions` | CRUD, drafts, cancellation, email, downloads | +| `organizations` | Legal data, files, keys, domains, series, team access, roles | +| `webhooks` | CRUD and signature validation | +| `tools` | Health and RFC validation | +| `catalogs` | SAT product and unit catalogs | +| `nomina_catalogs` | Payroll deduction and perception catalogs | +| `carta_porte_catalogs` | Carta Porte 3.1 catalogs | +| `comercio_exterior_catalogs` | Foreign trade tariff fractions | + +Python method names use `snake_case`. Request bodies and query parameters are +passed as dictionaries. Download operations return `bytes`. + +::: facturapi.client.Facturapi + options: + show_source: false + +## Customers + +::: facturapi._generated.Customers + options: + show_source: false + +## Products + +::: facturapi._generated.Products + options: + show_source: false + +## Invoices + +::: facturapi._generated.Invoices + options: + show_source: false + +## E-Receipts + +::: facturapi._generated.Receipts + options: + show_source: false + +## Retentions + +::: facturapi._generated.Retentions + options: + show_source: false + +## Organizations + +::: facturapi._generated.Organizations + options: + show_source: false + +## Webhooks + +::: facturapi._generated.Webhooks + options: + show_source: false + +## Tools + +::: facturapi._generated.Tools + options: + show_source: false + +## SAT catalogs + +::: facturapi._generated.Catalogs + options: + show_source: false + +## Payroll catalogs + +::: facturapi._generated.NominaCatalogs + options: + show_source: false + +## Carta Porte catalogs + +::: facturapi._generated.CartaPorteCatalogs + options: + show_source: false + +## Foreign-trade catalogs + +::: facturapi._generated.ComercioExteriorCatalogs + options: + show_source: false diff --git a/facturapi/__init__.py b/facturapi/__init__.py index 8d7f753..c04666c 100644 --- a/facturapi/__init__.py +++ b/facturapi/__init__.py @@ -1,10 +1,13 @@ __all__ = [ '__version__', 'Customer', + 'FacturAPI', + 'Facturapi', 'Invoice', 'configure', ] +from .client import FacturAPI, Facturapi from .http import client from .resources import Customer, Invoice from .version import __version__ diff --git a/facturapi/_generated.py b/facturapi/_generated.py new file mode 100644 index 0000000..4c4727d --- /dev/null +++ b/facturapi/_generated.py @@ -0,0 +1,1737 @@ +# This file is generated by scripts/generate_openapi.py. +# Do not edit it by hand. + +from collections.abc import Mapping +from typing import Any +from urllib.parse import quote + +from .http.client import Client, QueryParams + +OPENAPI_VERSION = '2.0' +OPENAPI_OPERATION_COUNT = 120 + + +class _Service: + def __init__(self, client: Client) -> None: + self._client = client + + def _request( + self, + method: str, + path: str, + *, + path_params: Mapping[str, Any] | None = None, + params: QueryParams | None = None, + data: Mapping[str, Any] | None = None, + files: Mapping[str, Any] | None = None, + response_type: str = 'auto', + ) -> Any: + for key, value in (path_params or {}).items(): + encoded = quote(str(value), safe='') + path = path.replace('{' + key + '}', encoded) + return self._client.request( + method, + path, + params=params, + data=data, + files=files, + response_type=response_type, + ) + + +class Customers(_Service): + def create( + self, data: Mapping[str, Any], params: QueryParams | None = None + ) -> Any: + """Create Customer. + + OpenAPI operation: ``createCustomer``. + Endpoint: ``POST /customers``. + """ + return self._request('POST', '/customers', params=params, data=data) + + def list(self, params: QueryParams | None = None) -> Any: + """List customers. + + OpenAPI operation: ``listCustomers``. + Endpoint: ``GET /customers``. + """ + return self._request('GET', '/customers', params=params) + + def retrieve(self, customer_id: str) -> Any: + """Retrieve customer by ID. + + OpenAPI operation: ``getCustomer``. + Endpoint: ``GET /customers/{customer_id}``. + """ + return self._request( + 'GET', + '/customers/{customer_id}', + path_params={'customer_id': customer_id}, + ) + + def update( + self, + customer_id: str, + data: Mapping[str, Any], + params: QueryParams | None = None, + ) -> Any: + """Edit Customer. + + OpenAPI operation: ``editCustomer``. + Endpoint: ``PUT /customers/{customer_id}``. + """ + return self._request( + 'PUT', + '/customers/{customer_id}', + path_params={'customer_id': customer_id}, + params=params, + data=data, + ) + + def delete(self, customer_id: str) -> Any: + """Delete Customer. + + OpenAPI operation: ``deleteCustomer``. + Endpoint: ``DELETE /customers/{customer_id}``. + """ + return self._request( + 'DELETE', + '/customers/{customer_id}', + path_params={'customer_id': customer_id}, + ) + + def send_edit_link_by_email( + self, customer_id: str, data: Mapping[str, Any] | None = None + ) -> Any: + """Send edit link by email. + + OpenAPI operation: ``sendEditLinkByEmail``. + Endpoint: ``POST /customers/{customer_id}/email-edit-link``. + """ + return self._request( + 'POST', + '/customers/{customer_id}/email-edit-link', + path_params={'customer_id': customer_id}, + data=data, + ) + + def validate_tax_info(self, customer_id: str) -> Any: + """Validate Customer Tax Info. + + OpenAPI operation: ``validateCustomerTaxInfo``. + Endpoint: ``GET /customers/{customer_id}/tax-info-validation``. + """ + return self._request( + 'GET', + '/customers/{customer_id}/tax-info-validation', + path_params={'customer_id': customer_id}, + ) + + +class Products(_Service): + def create(self, data: Mapping[str, Any]) -> Any: + """Create Product. + + OpenAPI operation: ``createProduct``. + Endpoint: ``POST /products``. + """ + return self._request('POST', '/products', data=data) + + def list(self, params: QueryParams | None = None) -> Any: + """List products. + + OpenAPI operation: ``listProducts``. + Endpoint: ``GET /products``. + """ + return self._request('GET', '/products', params=params) + + def retrieve(self, product_id: str) -> Any: + """Retrieve product by ID. + + OpenAPI operation: ``getProduct``. + Endpoint: ``GET /products/{product_id}``. + """ + return self._request( + 'GET', + '/products/{product_id}', + path_params={'product_id': product_id}, + ) + + def update(self, product_id: str, data: Mapping[str, Any]) -> Any: + """Edit product. + + OpenAPI operation: ``editProduct``. + Endpoint: ``PUT /products/{product_id}``. + """ + return self._request( + 'PUT', + '/products/{product_id}', + path_params={'product_id': product_id}, + data=data, + ) + + def delete(self, product_id: str) -> Any: + """Delete Product. + + OpenAPI operation: ``deleteProduct``. + Endpoint: ``DELETE /products/{product_id}``. + """ + return self._request( + 'DELETE', + '/products/{product_id}', + path_params={'product_id': product_id}, + ) + + +class Invoices(_Service): + def create( + self, data: Mapping[str, Any], params: QueryParams | None = None + ) -> Any: + """Create invoice (CFDI 4.0). + + OpenAPI operation: ``createInvoice``. + Endpoint: ``POST /invoices``. + """ + return self._request('POST', '/invoices', params=params, data=data) + + def list(self, params: QueryParams | None = None) -> Any: + """List invoices. + + OpenAPI operation: ``listInvoices``. + Endpoint: ``GET /invoices``. + """ + return self._request('GET', '/invoices', params=params) + + def retrieve(self, invoice_id: str) -> Any: + """Retrieve invoice by ID. + + OpenAPI operation: ``getInvoice``. + Endpoint: ``GET /invoices/{invoice_id}``. + """ + return self._request( + 'GET', + '/invoices/{invoice_id}', + path_params={'invoice_id': invoice_id}, + ) + + def update_draft(self, invoice_id: str, data: Mapping[str, Any]) -> Any: + """Edit draft invoice. + + OpenAPI operation: ``updateDraftInvoice``. + Endpoint: ``PUT /invoices/{invoice_id}``. + """ + return self._request( + 'PUT', + '/invoices/{invoice_id}', + path_params={'invoice_id': invoice_id}, + data=data, + ) + + def cancel( + self, invoice_id: str, params: QueryParams | None = None + ) -> Any: + """Cancel invoice. + + OpenAPI operation: ``cancelInvoice``. + Endpoint: ``DELETE /invoices/{invoice_id}``. + """ + return self._request( + 'DELETE', + '/invoices/{invoice_id}', + path_params={'invoice_id': invoice_id}, + params=params, + ) + + def copy_to_draft(self, invoice_id: str) -> Any: + """Copy to draft. + + OpenAPI operation: ``copyToDraftInvoice``. + Endpoint: ``POST /invoices/{invoice_id}/copy``. + """ + return self._request( + 'POST', + '/invoices/{invoice_id}/copy', + path_params={'invoice_id': invoice_id}, + ) + + def stamp_draft( + self, invoice_id: str, params: QueryParams | None = None + ) -> Any: + """Stamp draft invoice. + + OpenAPI operation: ``stampDraftInvoice``. + Endpoint: ``POST /invoices/{invoice_id}/stamp``. + """ + return self._request( + 'POST', + '/invoices/{invoice_id}/stamp', + path_params={'invoice_id': invoice_id}, + params=params, + ) + + def update_status(self, invoice_id: str) -> Any: + """Update invoice status + . + + OpenAPI operation: ``updateInvoiceStatus``. + Endpoint: ``PUT /invoices/{invoice_id}/status``. + """ + return self._request( + 'PUT', + '/invoices/{invoice_id}/status', + path_params={'invoice_id': invoice_id}, + ) + + def payment_summary( + self, invoice_id: str, params: QueryParams | None = None + ) -> Any: + """Payment summary. + + OpenAPI operation: ``getInvoicePaymentSummary``. + Endpoint: ``GET /invoices/{invoice_id}/payment-summary``. + """ + return self._request( + 'GET', + '/invoices/{invoice_id}/payment-summary', + path_params={'invoice_id': invoice_id}, + params=params, + ) + + def preview_pdf(self, data: Mapping[str, Any]) -> Any: + """Preview invoice PDF. + + OpenAPI operation: ``previewInvoicePdf``. + Endpoint: ``POST /invoices/preview/pdf``. + """ + return self._request( + 'POST', '/invoices/preview/pdf', data=data, response_type='bytes' + ) + + def preview_pdf_url(self, data: Mapping[str, Any]) -> Any: + """Get invoice PDF preview URL. + + OpenAPI operation: ``previewInvoicePdfUrl``. + Endpoint: ``POST /invoices/preview/pdf/download-url``. + """ + return self._request( + 'POST', '/invoices/preview/pdf/download-url', data=data + ) + + def download(self, invoice_id: str, format: str) -> Any: + """Download invoice. + + OpenAPI operation: ``downloadInvoice``. + Endpoint: ``GET /invoices/{invoice_id}/{format}``. + """ + return self._request( + 'GET', + '/invoices/{invoice_id}/{format}', + path_params={'invoice_id': invoice_id, 'format': format}, + response_type='bytes', + ) + + def download_pdf(self, invoice_id: str) -> Any: + """Download invoice. + + OpenAPI operation: ``downloadInvoice``. + Endpoint: ``GET /invoices/{invoice_id}/{format}``. + """ + return self._request( + 'GET', + '/invoices/{invoice_id}/{format}', + path_params={'invoice_id': invoice_id, 'format': 'pdf'}, + response_type='bytes', + ) + + def download_xml(self, invoice_id: str) -> Any: + """Download invoice. + + OpenAPI operation: ``downloadInvoice``. + Endpoint: ``GET /invoices/{invoice_id}/{format}``. + """ + return self._request( + 'GET', + '/invoices/{invoice_id}/{format}', + path_params={'invoice_id': invoice_id, 'format': 'xml'}, + response_type='bytes', + ) + + def download_zip(self, invoice_id: str) -> Any: + """Download invoice. + + OpenAPI operation: ``downloadInvoice``. + Endpoint: ``GET /invoices/{invoice_id}/{format}``. + """ + return self._request( + 'GET', + '/invoices/{invoice_id}/{format}', + path_params={'invoice_id': invoice_id, 'format': 'zip'}, + response_type='bytes', + ) + + def download_url(self, invoice_id: str, format: str) -> Any: + """Get download URL. + + OpenAPI operation: ``getInvoiceDownloadUrl``. + Endpoint: ``GET /invoices/{invoice_id}/download-url/{format}``. + """ + return self._request( + 'GET', + '/invoices/{invoice_id}/download-url/{format}', + path_params={'invoice_id': invoice_id, 'format': format}, + ) + + def download_pdf_url(self, invoice_id: str) -> Any: + """Get download URL. + + OpenAPI operation: ``getInvoiceDownloadUrl``. + Endpoint: ``GET /invoices/{invoice_id}/download-url/{format}``. + """ + return self._request( + 'GET', + '/invoices/{invoice_id}/download-url/{format}', + path_params={'invoice_id': invoice_id, 'format': 'pdf'}, + ) + + def download_xml_url(self, invoice_id: str) -> Any: + """Get download URL. + + OpenAPI operation: ``getInvoiceDownloadUrl``. + Endpoint: ``GET /invoices/{invoice_id}/download-url/{format}``. + """ + return self._request( + 'GET', + '/invoices/{invoice_id}/download-url/{format}', + path_params={'invoice_id': invoice_id, 'format': 'xml'}, + ) + + def download_zip_url(self, invoice_id: str) -> Any: + """Get download URL. + + OpenAPI operation: ``getInvoiceDownloadUrl``. + Endpoint: ``GET /invoices/{invoice_id}/download-url/{format}``. + """ + return self._request( + 'GET', + '/invoices/{invoice_id}/download-url/{format}', + path_params={'invoice_id': invoice_id, 'format': 'zip'}, + ) + + def download_cancellation_receipt( + self, invoice_id: str, format: str + ) -> Any: + """Cancellation receipt. + + OpenAPI operation: ``downloadCancellationReceiptXml``. + Endpoint: ``GET /invoices/{invoice_id}/cancellation_receipt/{format}``. + """ + return self._request( + 'GET', + '/invoices/{invoice_id}/cancellation_receipt/{format}', + path_params={'invoice_id': invoice_id, 'format': format}, + response_type='bytes', + ) + + def download_cancellation_receipt_pdf(self, invoice_id: str) -> Any: + """Cancellation receipt. + + OpenAPI operation: ``downloadCancellationReceiptXml``. + Endpoint: ``GET /invoices/{invoice_id}/cancellation_receipt/{format}``. + """ + return self._request( + 'GET', + '/invoices/{invoice_id}/cancellation_receipt/{format}', + path_params={'invoice_id': invoice_id, 'format': 'pdf'}, + response_type='bytes', + ) + + def download_cancellation_receipt_xml(self, invoice_id: str) -> Any: + """Cancellation receipt. + + OpenAPI operation: ``downloadCancellationReceiptXml``. + Endpoint: ``GET /invoices/{invoice_id}/cancellation_receipt/{format}``. + """ + return self._request( + 'GET', + '/invoices/{invoice_id}/cancellation_receipt/{format}', + path_params={'invoice_id': invoice_id, 'format': 'xml'}, + response_type='bytes', + ) + + def download_cancellation_receipt_url( + self, invoice_id: str, format: str + ) -> Any: + """Get cancellation receipt download URL. + + OpenAPI operation: ``getCancellationReceiptDownloadUrl``. + Endpoint: ``GET /invoices/{invoice_id}/cancellation_receipt/download-url/{format}``. + """ + return self._request( + 'GET', + '/invoices/{invoice_id}/cancellation_receipt/download-url/{format}', + path_params={'invoice_id': invoice_id, 'format': format}, + ) + + def download_cancellation_receipt_pdf_url(self, invoice_id: str) -> Any: + """Get cancellation receipt download URL. + + OpenAPI operation: ``getCancellationReceiptDownloadUrl``. + Endpoint: ``GET /invoices/{invoice_id}/cancellation_receipt/download-url/{format}``. + """ + return self._request( + 'GET', + '/invoices/{invoice_id}/cancellation_receipt/download-url/{format}', + path_params={'invoice_id': invoice_id, 'format': 'pdf'}, + ) + + def download_cancellation_receipt_xml_url(self, invoice_id: str) -> Any: + """Get cancellation receipt download URL. + + OpenAPI operation: ``getCancellationReceiptDownloadUrl``. + Endpoint: ``GET /invoices/{invoice_id}/cancellation_receipt/download-url/{format}``. + """ + return self._request( + 'GET', + '/invoices/{invoice_id}/cancellation_receipt/download-url/{format}', + path_params={'invoice_id': invoice_id, 'format': 'xml'}, + ) + + def send_by_email( + self, invoice_id: str, data: Mapping[str, Any] | None = None + ) -> Any: + """Send invoice by email. + + OpenAPI operation: ``sendInvoiceByEmail``. + Endpoint: ``POST /invoices/{invoice_id}/email``. + """ + return self._request( + 'POST', + '/invoices/{invoice_id}/email', + path_params={'invoice_id': invoice_id}, + data=data, + ) + + def create_zip_request(self, data: Mapping[str, Any]) -> Any: + """Create or retrieve a monthly ZIP request. + + OpenAPI operation: ``createInvoiceZipRequest``. + Endpoint: ``POST /invoices/zip-requests``. + """ + return self._request('POST', '/invoices/zip-requests', data=data) + + def list_zip_requests(self, params: QueryParams | None = None) -> Any: + """List monthly ZIP requests. + + OpenAPI operation: ``listInvoiceZipRequests``. + Endpoint: ``GET /invoices/zip-requests``. + """ + return self._request('GET', '/invoices/zip-requests', params=params) + + def retrieve_zip_request(self, id: str) -> Any: + """Retrieve a monthly ZIP request. + + OpenAPI operation: ``retrieveInvoiceZipRequest``. + Endpoint: ``GET /invoices/zip-requests/{id}``. + """ + return self._request( + 'GET', '/invoices/zip-requests/{id}', path_params={'id': id} + ) + + def download_zip_request(self, id: str) -> Any: + """Download a monthly ZIP. + + OpenAPI operation: ``downloadInvoiceZipRequest``. + Endpoint: ``GET /invoices/zip-requests/{id}/zip``. + """ + return self._request( + 'GET', + '/invoices/zip-requests/{id}/zip', + path_params={'id': id}, + response_type='bytes', + ) + + def download_zip_request_url(self, id: str) -> Any: + """Get monthly ZIP download URL. + + OpenAPI operation: ``getInvoiceZipRequestDownloadUrl``. + Endpoint: ``GET /invoices/zip-requests/{id}/download-url``. + """ + return self._request( + 'GET', + '/invoices/zip-requests/{id}/download-url', + path_params={'id': id}, + ) + + +class Receipts(_Service): + def create(self, data: Mapping[str, Any]) -> Any: + """Create e-receipt. + + OpenAPI operation: ``createReceipt``. + Endpoint: ``POST /receipts``. + """ + return self._request('POST', '/receipts', data=data) + + def list(self, params: QueryParams | None = None) -> Any: + """List e-recipts. + + OpenAPI operation: ``listReceipts``. + Endpoint: ``GET /receipts``. + """ + return self._request('GET', '/receipts', params=params) + + def retrieve(self, receipt_id: str) -> Any: + """Retrieve e-receipt by ID. + + OpenAPI operation: ``getReceipt``. + Endpoint: ``GET /receipts/{receipt_id}``. + """ + return self._request( + 'GET', + '/receipts/{receipt_id}', + path_params={'receipt_id': receipt_id}, + ) + + def update_customer(self, receipt_id: str, data: Mapping[str, Any]) -> Any: + """Assign or reassign customer to receipt. + + OpenAPI operation: ``assignReceiptCustomer``. + Endpoint: ``PUT /receipts/{receipt_id}``. + """ + return self._request( + 'PUT', + '/receipts/{receipt_id}', + path_params={'receipt_id': receipt_id}, + data=data, + ) + + def cancel(self, receipt_id: str) -> Any: + """Cancel e-receipt. + + OpenAPI operation: ``cancelReceipt``. + Endpoint: ``DELETE /receipts/{receipt_id}``. + """ + return self._request( + 'DELETE', + '/receipts/{receipt_id}', + path_params={'receipt_id': receipt_id}, + ) + + def invoice(self, receipt_id: str, data: Mapping[str, Any]) -> Any: + """Convert e-receipt to invoice. + + OpenAPI operation: ``invoiceReceipt``. + Endpoint: ``POST /receipts/{receipt_id}/invoice``. + """ + return self._request( + 'POST', + '/receipts/{receipt_id}/invoice', + path_params={'receipt_id': receipt_id}, + data=data, + ) + + def to_invoice(self, data: Mapping[str, Any]) -> Any: + """Create invoice from multiple receipts. + + OpenAPI operation: ``createToInvoiceFromReceipts``. + Endpoint: ``POST /receipts/to-invoice``. + """ + return self._request('POST', '/receipts/to-invoice', data=data) + + def preview_to_invoice_pdf(self, data: Mapping[str, Any]) -> Any: + """Preview PDF for multiple receipts invoice. + + OpenAPI operation: ``previewToInvoiceFromReceipts``. + Endpoint: ``POST /receipts/to-invoice/preview``. + """ + return self._request( + 'POST', + '/receipts/to-invoice/preview', + data=data, + response_type='bytes', + ) + + def preview_to_invoice_pdf_url(self, data: Mapping[str, Any]) -> Any: + """Get receipts invoice preview URL. + + OpenAPI operation: ``previewToInvoiceFromReceiptsUrl``. + Endpoint: ``POST /receipts/to-invoice/preview/download-url``. + """ + return self._request( + 'POST', '/receipts/to-invoice/preview/download-url', data=data + ) + + def create_global_invoice(self, data: Mapping[str, Any]) -> Any: + """Create global invoice. + + OpenAPI operation: ``createGlobalInvoice``. + Endpoint: ``POST /receipts/global-invoice``. + """ + return self._request('POST', '/receipts/global-invoice', data=data) + + def download_pdf(self, receipt_id: str) -> Any: + """Download PDF. + + OpenAPI operation: ``downloadReceiptPdf``. + Endpoint: ``GET /receipts/{receipt_id}/pdf``. + """ + return self._request( + 'GET', + '/receipts/{receipt_id}/pdf', + path_params={'receipt_id': receipt_id}, + response_type='bytes', + ) + + def download_pdf_url(self, receipt_id: str) -> Any: + """Get download URL. + + OpenAPI operation: ``getReceiptDownloadUrl``. + Endpoint: ``GET /receipts/{receipt_id}/download-url/pdf``. + """ + return self._request( + 'GET', + '/receipts/{receipt_id}/download-url/pdf', + path_params={'receipt_id': receipt_id}, + ) + + def send_by_email( + self, receipt_id: str, data: Mapping[str, Any] | None = None + ) -> Any: + """Send e-receipt by email. + + OpenAPI operation: ``sendReceiptByEmail``. + Endpoint: ``POST /receipts/{receipt_id}/email``. + """ + return self._request( + 'POST', + '/receipts/{receipt_id}/email', + path_params={'receipt_id': receipt_id}, + data=data, + ) + + +class Retentions(_Service): + def create(self, data: Mapping[str, Any]) -> Any: + """Create retention. + + OpenAPI operation: ``createRetention``. + Endpoint: ``POST /retentions``. + """ + return self._request('POST', '/retentions', data=data) + + def list(self, params: QueryParams | None = None) -> Any: + """List retentions. + + OpenAPI operation: ``listRetentions``. + Endpoint: ``GET /retentions``. + """ + return self._request('GET', '/retentions', params=params) + + def retrieve(self, retention_id: str) -> Any: + """Retrieve retention by ID. + + OpenAPI operation: ``getRetention``. + Endpoint: ``GET /retentions/{retention_id}``. + """ + return self._request( + 'GET', + '/retentions/{retention_id}', + path_params={'retention_id': retention_id}, + ) + + def update_draft(self, retention_id: str, data: Mapping[str, Any]) -> Any: + """Edit draft retention. + + OpenAPI operation: ``updateDraftRetention``. + Endpoint: ``PUT /retentions/{retention_id}``. + """ + return self._request( + 'PUT', + '/retentions/{retention_id}', + path_params={'retention_id': retention_id}, + data=data, + ) + + def cancel( + self, retention_id: str, params: QueryParams | None = None + ) -> Any: + """Cancel retention. + + OpenAPI operation: ``cancelRetention``. + Endpoint: ``DELETE /retentions/{retention_id}``. + """ + return self._request( + 'DELETE', + '/retentions/{retention_id}', + path_params={'retention_id': retention_id}, + params=params, + ) + + def copy_to_draft(self, retention_id: str) -> Any: + """Copy to draft. + + OpenAPI operation: ``copyToDraftRetention``. + Endpoint: ``POST /retentions/{retention_id}/copy``. + """ + return self._request( + 'POST', + '/retentions/{retention_id}/copy', + path_params={'retention_id': retention_id}, + ) + + def stamp_draft(self, retention_id: str) -> Any: + """Stamp draft retention. + + OpenAPI operation: ``stampDraftRetention``. + Endpoint: ``POST /retentions/{retention_id}/stamp``. + """ + return self._request( + 'POST', + '/retentions/{retention_id}/stamp', + path_params={'retention_id': retention_id}, + ) + + def download(self, retention_id: str, format: str) -> Any: + """Download retention. + + OpenAPI operation: ``downloadRetention``. + Endpoint: ``GET /retentions/{retention_id}/{format}``. + """ + return self._request( + 'GET', + '/retentions/{retention_id}/{format}', + path_params={'retention_id': retention_id, 'format': format}, + response_type='bytes', + ) + + def download_pdf(self, retention_id: str) -> Any: + """Download retention. + + OpenAPI operation: ``downloadRetention``. + Endpoint: ``GET /retentions/{retention_id}/{format}``. + """ + return self._request( + 'GET', + '/retentions/{retention_id}/{format}', + path_params={'retention_id': retention_id, 'format': 'pdf'}, + response_type='bytes', + ) + + def download_xml(self, retention_id: str) -> Any: + """Download retention. + + OpenAPI operation: ``downloadRetention``. + Endpoint: ``GET /retentions/{retention_id}/{format}``. + """ + return self._request( + 'GET', + '/retentions/{retention_id}/{format}', + path_params={'retention_id': retention_id, 'format': 'xml'}, + response_type='bytes', + ) + + def download_zip(self, retention_id: str) -> Any: + """Download retention. + + OpenAPI operation: ``downloadRetention``. + Endpoint: ``GET /retentions/{retention_id}/{format}``. + """ + return self._request( + 'GET', + '/retentions/{retention_id}/{format}', + path_params={'retention_id': retention_id, 'format': 'zip'}, + response_type='bytes', + ) + + def download_url(self, retention_id: str, format: str) -> Any: + """Get download URL. + + OpenAPI operation: ``getRetentionDownloadUrl``. + Endpoint: ``GET /retentions/{retention_id}/download-url/{format}``. + """ + return self._request( + 'GET', + '/retentions/{retention_id}/download-url/{format}', + path_params={'retention_id': retention_id, 'format': format}, + ) + + def download_pdf_url(self, retention_id: str) -> Any: + """Get download URL. + + OpenAPI operation: ``getRetentionDownloadUrl``. + Endpoint: ``GET /retentions/{retention_id}/download-url/{format}``. + """ + return self._request( + 'GET', + '/retentions/{retention_id}/download-url/{format}', + path_params={'retention_id': retention_id, 'format': 'pdf'}, + ) + + def download_xml_url(self, retention_id: str) -> Any: + """Get download URL. + + OpenAPI operation: ``getRetentionDownloadUrl``. + Endpoint: ``GET /retentions/{retention_id}/download-url/{format}``. + """ + return self._request( + 'GET', + '/retentions/{retention_id}/download-url/{format}', + path_params={'retention_id': retention_id, 'format': 'xml'}, + ) + + def download_zip_url(self, retention_id: str) -> Any: + """Get download URL. + + OpenAPI operation: ``getRetentionDownloadUrl``. + Endpoint: ``GET /retentions/{retention_id}/download-url/{format}``. + """ + return self._request( + 'GET', + '/retentions/{retention_id}/download-url/{format}', + path_params={'retention_id': retention_id, 'format': 'zip'}, + ) + + def send_by_email( + self, retention_id: str, data: Mapping[str, Any] | None = None + ) -> Any: + """Send retention by email. + + OpenAPI operation: ``sendRetentionByEmail``. + Endpoint: ``POST /retentions/{retention_id}/email``. + """ + return self._request( + 'POST', + '/retentions/{retention_id}/email', + path_params={'retention_id': retention_id}, + data=data, + ) + + +class Organizations(_Service): + def create(self, data: Mapping[str, Any]) -> Any: + """Create organization. + + OpenAPI operation: ``createOrganization``. + Endpoint: ``POST /organizations``. + """ + return self._request('POST', '/organizations', data=data) + + def list(self, params: QueryParams | None = None) -> Any: + """List organizations. + + OpenAPI operation: ``listOrganizations``. + Endpoint: ``GET /organizations``. + """ + return self._request('GET', '/organizations', params=params) + + def me(self) -> Any: + """Organization detail. + + OpenAPI operation: ``meOrganization``. + Endpoint: ``GET /organizations/me``. + """ + return self._request('GET', '/organizations/me') + + def retrieve(self, organization_id: str) -> Any: + """Retrieve organization by ID. + + OpenAPI operation: ``getOrganization``. + Endpoint: ``GET /organizations/{organization_id}``. + """ + return self._request( + 'GET', + '/organizations/{organization_id}', + path_params={'organization_id': organization_id}, + ) + + def delete(self, organization_id: str) -> Any: + """Delete organization. + + OpenAPI operation: ``deleteOrganization``. + Endpoint: ``DELETE /organizations/{organization_id}``. + """ + return self._request( + 'DELETE', + '/organizations/{organization_id}', + path_params={'organization_id': organization_id}, + ) + + def update_legal( + self, organization_id: str, data: Mapping[str, Any] + ) -> Any: + """Edit legal data. + + OpenAPI operation: ``editOrganizationLegal``. + Endpoint: ``PUT /organizations/{organization_id}/legal``. + """ + return self._request( + 'PUT', + '/organizations/{organization_id}/legal', + path_params={'organization_id': organization_id}, + data=data, + ) + + def upload_certificate( + self, + organization_id: str, + files: Mapping[str, Any], + data: Mapping[str, Any] | None = None, + ) -> Any: + """Upload certificates (CSD). + + OpenAPI operation: ``uploadOrganizationCertificate``. + Endpoint: ``PUT /organizations/{organization_id}/certificate``. + """ + return self._request( + 'PUT', + '/organizations/{organization_id}/certificate', + path_params={'organization_id': organization_id}, + data=data, + files=files, + ) + + def delete_certificate(self, organization_id: str) -> Any: + """Delete certificates (CSD). + + OpenAPI operation: ``deleteOrganizationCertificate``. + Endpoint: ``DELETE /organizations/{organization_id}/certificate``. + """ + return self._request( + 'DELETE', + '/organizations/{organization_id}/certificate', + path_params={'organization_id': organization_id}, + ) + + def upload_fiel( + self, + organization_id: str, + files: Mapping[str, Any], + data: Mapping[str, Any] | None = None, + ) -> Any: + """Upload FIEL certificate. + + OpenAPI operation: ``uploadOrganizationFiel``. + Endpoint: ``PUT /organizations/{organization_id}/fiel``. + """ + return self._request( + 'PUT', + '/organizations/{organization_id}/fiel', + path_params={'organization_id': organization_id}, + data=data, + files=files, + ) + + def upload_logo( + self, + organization_id: str, + files: Mapping[str, Any], + data: Mapping[str, Any] | None = None, + ) -> Any: + """Upload logo. + + OpenAPI operation: ``uploadOrganizationLogo``. + Endpoint: ``PUT /organizations/{organization_id}/logo``. + """ + return self._request( + 'PUT', + '/organizations/{organization_id}/logo', + path_params={'organization_id': organization_id}, + data=data, + files=files, + ) + + def update_customization( + self, organization_id: str, data: Mapping[str, Any] + ) -> Any: + """Edit customization. + + OpenAPI operation: ``editOrganizationCustomization``. + Endpoint: ``PUT /organizations/{organization_id}/customization``. + """ + return self._request( + 'PUT', + '/organizations/{organization_id}/customization', + path_params={'organization_id': organization_id}, + data=data, + ) + + def update_receipt_settings( + self, organization_id: str, data: Mapping[str, Any] + ) -> Any: + """Edit receipts settings. + + OpenAPI operation: ``editOrganizationReceiptsSettings``. + Endpoint: ``PUT /organizations/{organization_id}/receipts``. + """ + return self._request( + 'PUT', + '/organizations/{organization_id}/receipts', + path_params={'organization_id': organization_id}, + data=data, + ) + + def update_self_invoice_settings( + self, organization_id: str, data: Mapping[str, Any] + ) -> Any: + """Edit self-invoice settings. + + OpenAPI operation: ``editOrganizationSelfInvoiceSettings``. + Endpoint: ``PUT /organizations/{organization_id}/self-invoice``. + """ + return self._request( + 'PUT', + '/organizations/{organization_id}/self-invoice', + path_params={'organization_id': organization_id}, + data=data, + ) + + def check_domain_is_available( + self, params: QueryParams | None = None + ) -> Any: + """Check domain availability. + + OpenAPI operation: ``checkDomainAvailability``. + Endpoint: ``GET /organizations/domain-check``. + """ + return self._request( + 'GET', '/organizations/domain-check', params=params + ) + + def update_domain( + self, organization_id: str, data: Mapping[str, Any] + ) -> Any: + """Choose self-invoice domain. + + OpenAPI operation: ``editOrganizationDomain``. + Endpoint: ``PUT /organizations/{organization_id}/domain``. + """ + return self._request( + 'PUT', + '/organizations/{organization_id}/domain', + path_params={'organization_id': organization_id}, + data=data, + ) + + def get_test_api_key(self, organization_id: str) -> Any: + """Retrieve Test API Key. + + OpenAPI operation: ``getTestApiKey``. + Endpoint: ``GET /organizations/{organization_id}/apikeys/test``. + """ + return self._request( + 'GET', + '/organizations/{organization_id}/apikeys/test', + path_params={'organization_id': organization_id}, + ) + + def renew_test_api_key(self, organization_id: str) -> Any: + """Renew Test API Key. + + OpenAPI operation: ``renewTestApiKey``. + Endpoint: ``PUT /organizations/{organization_id}/apikeys/test``. + """ + return self._request( + 'PUT', + '/organizations/{organization_id}/apikeys/test', + path_params={'organization_id': organization_id}, + ) + + def list_live_api_keys(self, organization_id: str) -> Any: + """List Live API Keys. + + OpenAPI operation: ``listLiveApiKeys``. + Endpoint: ``GET /organizations/{organization_id}/apikeys/live``. + """ + return self._request( + 'GET', + '/organizations/{organization_id}/apikeys/live', + path_params={'organization_id': organization_id}, + ) + + def renew_live_api_key(self, organization_id: str) -> Any: + """Create Live API Key. + + OpenAPI operation: ``renewLiveApiKey``. + Endpoint: ``PUT /organizations/{organization_id}/apikeys/live``. + """ + return self._request( + 'PUT', + '/organizations/{organization_id}/apikeys/live', + path_params={'organization_id': organization_id}, + ) + + def delete_live_api_key(self, organization_id: str, id: str) -> Any: + """Revoke Live API Key. + + OpenAPI operation: ``deleteLiveApiKey``. + Endpoint: ``DELETE /organizations/{organization_id}/apikeys/live/{id}``. + """ + return self._request( + 'DELETE', + '/organizations/{organization_id}/apikeys/live/{id}', + path_params={'organization_id': organization_id, 'id': id}, + ) + + def list_series_group(self, organization_id: str) -> Any: + """Listado de series. + + OpenAPI operation: ``getSeriesGroup``. + Endpoint: ``GET /organizations/{organization_id}/series-group``. + """ + return self._request( + 'GET', + '/organizations/{organization_id}/series-group', + path_params={'organization_id': organization_id}, + ) + + def create_series_group( + self, organization_id: str, data: Mapping[str, Any] | None = None + ) -> Any: + """Create Series. + + OpenAPI operation: ``createSeriesGroup``. + Endpoint: ``POST /organizations/{organization_id}/series-group``. + """ + return self._request( + 'POST', + '/organizations/{organization_id}/series-group', + path_params={'organization_id': organization_id}, + data=data, + ) + + def update_default_series( + self, organization_id: str, data: Mapping[str, Any] | None = None + ) -> Any: + """Set default series. + + OpenAPI operation: ``updateDefaultSeries``. + Endpoint: ``PUT /organizations/{organization_id}/series-group/default-series``. + """ + return self._request( + 'PUT', + '/organizations/{organization_id}/series-group/default-series', + path_params={'organization_id': organization_id}, + data=data, + ) + + def update_series_group( + self, + organization_id: str, + series_name: str, + data: Mapping[str, Any] | None = None, + ) -> Any: + """Update Series. + + OpenAPI operation: ``updateSeriesGroup``. + Endpoint: ``PUT /organizations/{organization_id}/series-group/{series_name}``. + """ + return self._request( + 'PUT', + '/organizations/{organization_id}/series-group/{series_name}', + path_params={ + 'organization_id': organization_id, + 'series_name': series_name, + }, + data=data, + ) + + def delete_series_group( + self, organization_id: str, series_name: str + ) -> Any: + """Delete series. + + OpenAPI operation: ``deleteSeriesGroup``. + Endpoint: ``DELETE /organizations/{organization_id}/series-group/{series_name}``. + """ + return self._request( + 'DELETE', + '/organizations/{organization_id}/series-group/{series_name}', + path_params={ + 'organization_id': organization_id, + 'series_name': series_name, + }, + ) + + def list_team_access(self, organization_id: str) -> Any: + """List users with organization access. + + OpenAPI operation: ``getOrganizationTeam``. + Endpoint: ``GET /organizations/{organization_id}/team``. + """ + return self._request( + 'GET', + '/organizations/{organization_id}/team', + path_params={'organization_id': organization_id}, + ) + + def invite_user_to_team( + self, organization_id: str, data: Mapping[str, Any] + ) -> Any: + """Invite user to organization. + + OpenAPI operation: ``createOrganizationTeamInvite``. + Endpoint: ``POST /organizations/{organization_id}/team/invites``. + """ + return self._request( + 'POST', + '/organizations/{organization_id}/team/invites', + path_params={'organization_id': organization_id}, + data=data, + ) + + def list_sent_team_invites(self, organization_id: str) -> Any: + """List sent invites. + + OpenAPI operation: ``listOrganizationTeamInvites``. + Endpoint: ``GET /organizations/{organization_id}/team/invites``. + """ + return self._request( + 'GET', + '/organizations/{organization_id}/team/invites', + path_params={'organization_id': organization_id}, + ) + + def retrieve_team_access( + self, organization_id: str, access_id: str + ) -> Any: + """Retrieve user access. + + OpenAPI operation: ``getOrganizationTeamUser``. + Endpoint: ``GET /organizations/{organization_id}/team/{access_id}``. + """ + return self._request( + 'GET', + '/organizations/{organization_id}/team/{access_id}', + path_params={ + 'organization_id': organization_id, + 'access_id': access_id, + }, + ) + + def remove_team_access(self, organization_id: str, access_id: str) -> Any: + """Remove user access. + + OpenAPI operation: ``removeOrganizationUserAccess``. + Endpoint: ``DELETE /organizations/{organization_id}/team/{access_id}``. + """ + return self._request( + 'DELETE', + '/organizations/{organization_id}/team/{access_id}', + path_params={ + 'organization_id': organization_id, + 'access_id': access_id, + }, + ) + + def cancel_team_invite(self, organization_id: str, invite_key: str) -> Any: + """Cancel sent invite. + + OpenAPI operation: ``deleteOrganizationTeamInvite``. + Endpoint: ``DELETE /organizations/{organization_id}/team/invites/{invite_key}``. + """ + return self._request( + 'DELETE', + '/organizations/{organization_id}/team/invites/{invite_key}', + path_params={ + 'organization_id': organization_id, + 'invite_key': invite_key, + }, + ) + + def list_received_team_invites(self) -> Any: + """List received invites. + + OpenAPI operation: ``listPendingOrganizationInvites``. + Endpoint: ``GET /organizations/invites/pending``. + """ + return self._request('GET', '/organizations/invites/pending') + + def respond_team_invite( + self, invite_key: str, data: Mapping[str, Any] + ) -> Any: + """Respond to invite. + + OpenAPI operation: ``respondOrganizationInvite``. + Endpoint: ``POST /organizations/invites/{invite_key}/response``. + """ + return self._request( + 'POST', + '/organizations/invites/{invite_key}/response', + path_params={'invite_key': invite_key}, + data=data, + ) + + def list_team_roles(self, organization_id: str) -> Any: + """List organization roles. + + OpenAPI operation: ``listOrganizationPermissionRoles``. + Endpoint: ``GET /organizations/{organization_id}/team/roles``. + """ + return self._request( + 'GET', + '/organizations/{organization_id}/team/roles', + path_params={'organization_id': organization_id}, + ) + + def create_team_role( + self, organization_id: str, data: Mapping[str, Any] + ) -> Any: + """Create organization role. + + OpenAPI operation: ``createOrganizationPermissionRole``. + Endpoint: ``POST /organizations/{organization_id}/team/roles``. + """ + return self._request( + 'POST', + '/organizations/{organization_id}/team/roles', + path_params={'organization_id': organization_id}, + data=data, + ) + + def list_team_role_templates(self, organization_id: str) -> Any: + """List role templates. + + OpenAPI operation: ``listOrganizationPermissionRoleTemplates``. + Endpoint: ``GET /organizations/{organization_id}/team/roles/templates``. + """ + return self._request( + 'GET', + '/organizations/{organization_id}/team/roles/templates', + path_params={'organization_id': organization_id}, + ) + + def list_team_role_operations(self, organization_id: str) -> Any: + """List permission operations. + + OpenAPI operation: ``listOrganizationPermissionOperations``. + Endpoint: ``GET /organizations/{organization_id}/team/roles/operations``. + """ + return self._request( + 'GET', + '/organizations/{organization_id}/team/roles/operations', + path_params={'organization_id': organization_id}, + ) + + def retrieve_team_role(self, organization_id: str, role_id: str) -> Any: + """Retrieve organization role. + + OpenAPI operation: ``getOrganizationPermissionRole``. + Endpoint: ``GET /organizations/{organization_id}/team/roles/{role_id}``. + """ + return self._request( + 'GET', + '/organizations/{organization_id}/team/roles/{role_id}', + path_params={ + 'organization_id': organization_id, + 'role_id': role_id, + }, + ) + + def update_team_role( + self, organization_id: str, role_id: str, data: Mapping[str, Any] + ) -> Any: + """Update organization role. + + OpenAPI operation: ``updateOrganizationPermissionRole``. + Endpoint: ``PUT /organizations/{organization_id}/team/roles/{role_id}``. + """ + return self._request( + 'PUT', + '/organizations/{organization_id}/team/roles/{role_id}', + path_params={ + 'organization_id': organization_id, + 'role_id': role_id, + }, + data=data, + ) + + def delete_team_role(self, organization_id: str, role_id: str) -> Any: + """Delete organization role. + + OpenAPI operation: ``deleteOrganizationPermissionRole``. + Endpoint: ``DELETE /organizations/{organization_id}/team/roles/{role_id}``. + """ + return self._request( + 'DELETE', + '/organizations/{organization_id}/team/roles/{role_id}', + path_params={ + 'organization_id': organization_id, + 'role_id': role_id, + }, + ) + + def update_team_access_role( + self, organization_id: str, access_id: str, data: Mapping[str, Any] + ) -> Any: + """Reassign role to user. + + OpenAPI operation: ``updateOrganizationTeamUserRole``. + Endpoint: ``PUT /organizations/{organization_id}/team/{access_id}/role``. + """ + return self._request( + 'PUT', + '/organizations/{organization_id}/team/{access_id}/role', + path_params={ + 'organization_id': organization_id, + 'access_id': access_id, + }, + data=data, + ) + + +class Webhooks(_Service): + def create(self, data: Mapping[str, Any]) -> Any: + """Create webhook. + + OpenAPI operation: ``createWebhook``. + Endpoint: ``POST /webhooks``. + """ + return self._request('POST', '/webhooks', data=data) + + def list(self, params: QueryParams | None = None) -> Any: + """List webhooks. + + OpenAPI operation: ``listWebhooks``. + Endpoint: ``GET /webhooks``. + """ + return self._request('GET', '/webhooks', params=params) + + def retrieve(self, webhook_id: str) -> Any: + """Retrieve webhook by ID. + + OpenAPI operation: ``getWebhook``. + Endpoint: ``GET /webhooks/{webhook_id}``. + """ + return self._request( + 'GET', + '/webhooks/{webhook_id}', + path_params={'webhook_id': webhook_id}, + ) + + def update(self, webhook_id: str, data: Mapping[str, Any]) -> Any: + """Edit webhook. + + OpenAPI operation: ``editWebhook``. + Endpoint: ``PUT /webhooks/{webhook_id}``. + """ + return self._request( + 'PUT', + '/webhooks/{webhook_id}', + path_params={'webhook_id': webhook_id}, + data=data, + ) + + def delete(self, webhook_id: str) -> Any: + """Delete Webhook. + + OpenAPI operation: ``deleteWebhook``. + Endpoint: ``DELETE /webhooks/{webhook_id}``. + """ + return self._request( + 'DELETE', + '/webhooks/{webhook_id}', + path_params={'webhook_id': webhook_id}, + ) + + def validate_signature(self, data: Mapping[str, Any]) -> Any: + """Validate Webhook Signature. + + OpenAPI operation: ``validateWebhookSignature``. + Endpoint: ``POST /webhooks/validate-signature``. + """ + return self._request('POST', '/webhooks/validate-signature', data=data) + + +class Tools(_Service): + def health_check(self) -> Any: + """Health check. + + OpenAPI operation: ``checkApiHealth``. + Endpoint: ``GET /check``. + """ + return self._request('GET', '/check') + + def validate_tax_id(self, tax_id: str) -> Any: + """Validate RFC (tax_id). + + OpenAPI operation: ``validateTaxId``. + Endpoint: ``GET /tools/tax_id_validation``. + """ + return self._request( + 'GET', '/tools/tax_id_validation', params={'tax_id': tax_id} + ) + + +class Catalogs(_Service): + def search_products(self, params: QueryParams | None = None) -> Any: + """Product/Service Key. + + OpenAPI operation: ``searchProducts``. + Endpoint: ``GET /catalogs/products``. + """ + return self._request('GET', '/catalogs/products', params=params) + + def search_units(self, params: QueryParams | None = None) -> Any: + """Units of Measure. + + OpenAPI operation: ``searchUnits``. + Endpoint: ``GET /catalogs/units``. + """ + return self._request('GET', '/catalogs/units', params=params) + + +class NominaCatalogs(_Service): + def search_deductions(self, params: QueryParams | None = None) -> Any: + """Search payroll deduction types. + + OpenAPI operation: ``searchNominaDeductions``. + Endpoint: ``GET /catalogs/deductions``. + """ + return self._request('GET', '/catalogs/deductions', params=params) + + def search_perceptions(self, params: QueryParams | None = None) -> Any: + """Search payroll perception types. + + OpenAPI operation: ``searchNominaPerceptions``. + Endpoint: ``GET /catalogs/perceptions``. + """ + return self._request('GET', '/catalogs/perceptions', params=params) + + +class CartaPorteCatalogs(_Service): + def search_air_transport_codes( + self, params: QueryParams | None = None + ) -> Any: + """Search air transport codes. + + OpenAPI operation: ``searchCartaPorteAirTransportCodes``. + Endpoint: ``GET /catalogs/cartaporte/3.1/air-transport-codes``. + """ + return self._request( + 'GET', + '/catalogs/cartaporte/3.1/air-transport-codes', + params=params, + ) + + def search_transport_configs( + self, params: QueryParams | None = None + ) -> Any: + """Search auto transport configurations. + + OpenAPI operation: ``searchCartaPorteTransportConfigs``. + Endpoint: ``GET /catalogs/cartaporte/3.1/transport-configs``. + """ + return self._request( + 'GET', '/catalogs/cartaporte/3.1/transport-configs', params=params + ) + + def search_rights_of_passage( + self, params: QueryParams | None = None + ) -> Any: + """Search rights of passage. + + OpenAPI operation: ``searchCartaPorteRightsOfPassage``. + Endpoint: ``GET /catalogs/cartaporte/3.1/rights-of-passage``. + """ + return self._request( + 'GET', '/catalogs/cartaporte/3.1/rights-of-passage', params=params + ) + + def search_customs_documents( + self, params: QueryParams | None = None + ) -> Any: + """Search customs documents. + + OpenAPI operation: ``searchCartaPorteCustomsDocuments``. + Endpoint: ``GET /catalogs/cartaporte/3.1/customs-documents``. + """ + return self._request( + 'GET', '/catalogs/cartaporte/3.1/customs-documents', params=params + ) + + def search_packaging_types(self, params: QueryParams | None = None) -> Any: + """Search packaging types. + + OpenAPI operation: ``searchCartaPortePackagingTypes``. + Endpoint: ``GET /catalogs/cartaporte/3.1/packaging-types``. + """ + return self._request( + 'GET', '/catalogs/cartaporte/3.1/packaging-types', params=params + ) + + def search_trailer_types(self, params: QueryParams | None = None) -> Any: + """Search trailer types. + + OpenAPI operation: ``searchCartaPorteTrailerTypes``. + Endpoint: ``GET /catalogs/cartaporte/3.1/trailer-types``. + """ + return self._request( + 'GET', '/catalogs/cartaporte/3.1/trailer-types', params=params + ) + + def search_hazardous_materials( + self, params: QueryParams | None = None + ) -> Any: + """Search hazardous materials. + + OpenAPI operation: ``searchCartaPorteHazardousMaterials``. + Endpoint: ``GET /catalogs/cartaporte/3.1/hazardous-materials``. + """ + return self._request( + 'GET', + '/catalogs/cartaporte/3.1/hazardous-materials', + params=params, + ) + + def search_naval_authorizations( + self, params: QueryParams | None = None + ) -> Any: + """Search naval authorizations. + + OpenAPI operation: ``searchCartaPorteNavalAuthorizations``. + Endpoint: ``GET /catalogs/cartaporte/3.1/naval-authorizations``. + """ + return self._request( + 'GET', + '/catalogs/cartaporte/3.1/naval-authorizations', + params=params, + ) + + def search_port_stations(self, params: QueryParams | None = None) -> Any: + """Search port stations. + + OpenAPI operation: ``searchCartaPortePortStations``. + Endpoint: ``GET /catalogs/cartaporte/3.1/port-stations``. + """ + return self._request( + 'GET', '/catalogs/cartaporte/3.1/port-stations', params=params + ) + + def search_marine_containers( + self, params: QueryParams | None = None + ) -> Any: + """Search marine containers. + + OpenAPI operation: ``searchCartaPorteMarineContainers``. + Endpoint: ``GET /catalogs/cartaporte/3.1/marine-containers``. + """ + return self._request( + 'GET', '/catalogs/cartaporte/3.1/marine-containers', params=params + ) + + +class ComercioExteriorCatalogs(_Service): + def search_tariff_fractions( + self, params: QueryParams | None = None + ) -> Any: + """Search tariff fractions. + + OpenAPI operation: ``searchComercioExteriorTariffFractions``. + Endpoint: ``GET /catalogs/comercioexterior/2.0/tariff-fractions``. + """ + return self._request( + 'GET', + '/catalogs/comercioexterior/2.0/tariff-fractions', + params=params, + ) diff --git a/facturapi/client.py b/facturapi/client.py new file mode 100644 index 0000000..cd40d6a --- /dev/null +++ b/facturapi/client.py @@ -0,0 +1,85 @@ +"""Modern, instance-based Facturapi client. + +The resource classes imported here are generated from Facturapi's OpenAPI +document. The legacy ``facturapi.Customer`` and ``facturapi.Invoice`` APIs +remain available for backwards compatibility. +""" + +from collections.abc import Mapping +from typing import Any + +from ._generated import ( + CartaPorteCatalogs, + Catalogs, + ComercioExteriorCatalogs, + Customers, + Invoices, + NominaCatalogs, + Organizations, + Products, + Receipts, + Retentions, + Tools, + Webhooks, +) +from .http.client import API_HOST, FACTURAPI_TIMEOUT, Client + + +class Facturapi: + """Synchronous client for every operation in the Facturapi API. + + Args: + api_key: Test, Live, or User API key. If omitted, ``FACTURAPI_KEY`` is + read from the environment. + base_url: API base URL. Override this for tests or compatible proxies. + timeout: Request timeout in seconds. + headers: Extra default request headers. + """ + + def __init__( + self, + api_key: str | None = None, + *, + base_url: str = API_HOST, + timeout: float = FACTURAPI_TIMEOUT, + headers: Mapping[str, str] | None = None, + http_client: Client | None = None, + ) -> None: + self.http = http_client or Client( + api_key, + base_url=base_url, + timeout=timeout, + headers=headers, + ) + self.customers = Customers(self.http) + self.products = Products(self.http) + self.invoices = Invoices(self.http) + self.receipts = Receipts(self.http) + self.retentions = Retentions(self.http) + self.organizations = Organizations(self.http) + self.webhooks = Webhooks(self.http) + self.tools = Tools(self.http) + self.catalogs = Catalogs(self.http) + self.nomina_catalogs = NominaCatalogs(self.http) + self.carta_porte_catalogs = CartaPorteCatalogs(self.http) + self.comercio_exterior_catalogs = ComercioExteriorCatalogs(self.http) + + @property + def base_url(self) -> str: + return self.http.host + + @base_url.setter + def base_url(self, value: str) -> None: + self.http.host = value.rstrip('/') + '/' + + def close(self) -> None: + self.http.close() + + def __enter__(self) -> 'Facturapi': + return self + + def __exit__(self, *_: Any) -> None: + self.close() + + +FacturAPI = Facturapi diff --git a/facturapi/http/client.py b/facturapi/http/client.py index 4afb927..ebabba8 100644 --- a/facturapi/http/client.py +++ b/facturapi/http/client.py @@ -1,5 +1,8 @@ import os -from typing import Any, MutableMapping +from collections.abc import Mapping, Sequence +from datetime import date, datetime +from enum import Enum +from typing import Any from urllib.parse import urljoin import httpx @@ -8,9 +11,11 @@ from ..types.exc import FacturapiResponseException from ..version import CLIENT_VERSION -API_HOST = 'www.facturapi.io/v2' +API_HOST = 'https://www.facturapi.io/v2/' FACTURAPI_TIMEOUT = float(os.getenv('FACTURAPI_TIMEOUT', 10.0)) +QueryParams = Mapping[str, Any] | Sequence[tuple[str, Any]] + class Client: """Client to perform http requests to Facturapi. @@ -31,18 +36,28 @@ class Client: host: str = API_HOST client: httpx.Client - def __init__(self) -> None: - self.client = httpx.Client(timeout=FACTURAPI_TIMEOUT) + def __init__( + self, + api_key: str | None = None, + *, + base_url: str = API_HOST, + timeout: float = FACTURAPI_TIMEOUT, + headers: Mapping[str, str] | None = None, + ) -> None: + self.host = base_url.rstrip('/') + '/' + self.client = httpx.Client(timeout=timeout) self.client.headers.update( { 'User-Agent': f'facturapi-python/{CLIENT_VERSION}', - 'Content-Type': 'application/json', + 'Accept': 'application/json', } ) + if headers: + self.client.headers.update(headers) # Auth - self.api_key = os.getenv('FACTURAPI_KEY', '') - self.client.auth = httpx.BasicAuth(self.api_key, '') + self.api_key = api_key or os.getenv('FACTURAPI_KEY', '') + self._set_auth() def configure(self, api_key: str) -> None: """Configure the http client. @@ -55,36 +70,61 @@ def configure(self, api_key: str) -> None: """ self.api_key = api_key - self.client.auth = httpx.BasicAuth(self.api_key, '') + self._set_auth() + + def _set_auth(self) -> None: + """Set the current Bearer token without leaking it to other clients.""" + if self.api_key: + self.client.headers['Authorization'] = f'Bearer {self.api_key}' + else: + self.client.headers.pop('Authorization', None) def get( self, endpoint: str, - params: bytes | MutableMapping[str, str] | None = None, - ) -> dict[str, Any]: + params: QueryParams | None = None, + **kwargs: Any, + ) -> Any: """Performs GET request to Facturapi.""" - return self.request('get', endpoint, params=params) + return self.request('get', endpoint, params=params, **kwargs) - def post(self, endpoint: str, data: dict[str, Any]) -> dict[str, Any]: + def post( + self, + endpoint: str, + data: Mapping[str, Any] | None = None, + **kwargs: Any, + ) -> Any: """Performs POST request to Facturapi.""" - return self.request('post', endpoint, data=data) + return self.request('post', endpoint, data=data, **kwargs) - def put(self, endpoint: str, data: dict[str, Any]) -> dict[str, Any]: + def put( + self, + endpoint: str, + data: Mapping[str, Any] | None = None, + **kwargs: Any, + ) -> Any: """Performs PUT request to Facturapi.""" - return self.request('put', endpoint, data=data) + return self.request('put', endpoint, data=data, **kwargs) - def delete(self, endpoint: str) -> dict[str, Any]: + def delete( + self, + endpoint: str, + params: QueryParams | None = None, + **kwargs: Any, + ) -> Any: """Performs DELETE request to Facturapi.""" - return self.request('delete', endpoint) + return self.request('delete', endpoint, params=params, **kwargs) def request( self, method: str, endpoint: str, - params: bytes | MutableMapping[str, str] | None = None, - data: dict[str, int | str] | None = None, - **kwargs, - ) -> dict[str, Any]: + params: QueryParams | None = None, + data: Mapping[str, Any] | None = None, + files: Mapping[str, Any] | None = None, + response_type: str = 'auto', + **kwargs: Any, + ) -> Any: """Performs a request to Facturapi. Given a `method` and `endpoint`, perform a request to @@ -105,15 +145,37 @@ def request( successful. """ + request_kwargs: dict[str, Any] = dict(kwargs) + if files is not None: + request_kwargs['files'] = files + request_kwargs['data'] = dict(data or {}) + elif data is not None: + request_kwargs['json'] = self._serialize(data) + response = self.client.request( method=method, - url=('https://' + self.host + urljoin('/', endpoint)), - json=data, - params=params, - **kwargs, + url=urljoin(self.host, endpoint.lstrip('/')), + params=( + httpx.QueryParams(self._query_items(params)) + if params is not None + else None + ), + **request_kwargs, ) self._check_response(response) - return response.json() + if response_type == 'bytes': + return response.content + if response_type == 'text': + return response.text + + content_type = response.headers.get('content-type', '').lower() + if 'json' in content_type: + return response.json() + if response_type == 'json': + return response.json() + if content_type.startswith('text/'): + return response.text + return response.content def download_request( self, @@ -134,18 +196,80 @@ def download_request( successful. """ - response = self.client.request( - method='GET', - url=('https://' + self.host + urljoin('/', endpoint)), - **kwargs, - ) - self._check_response(response) - return response.content + return self.request('GET', endpoint, response_type='bytes', **kwargs) + + def close(self) -> None: + """Close the underlying HTTP connection pool.""" + self.client.close() + + def __enter__(self) -> 'Client': + return self + + def __exit__(self, *_: Any) -> None: + self.close() + + @classmethod + def _serialize(cls, value: Any) -> Any: + if hasattr(value, 'model_dump'): + return cls._serialize( + value.model_dump(exclude_none=True, exclude_unset=True) + ) + if isinstance(value, Mapping): + return {key: cls._serialize(item) for key, item in value.items()} + if isinstance(value, (list, tuple)): + return [cls._serialize(item) for item in value] + if isinstance(value, (datetime, date)): + return value.isoformat() + if isinstance(value, Enum): + return value.value + return value + + @classmethod + def _query_items( + cls, params: QueryParams | None + ) -> list[tuple[str, str | int | float | bool | None]] | None: + """Encode deep objects and arrays according to the OpenAPI contract.""" + if params is None: + return None + source = params.items() if isinstance(params, Mapping) else params + items: list[tuple[str, str | int | float | bool | None]] = [] + + def append(key: str, value: Any) -> None: + if value is None: + return + if isinstance(value, Mapping): + for child_key, child_value in value.items(): + append(f'{key}[{child_key}]', child_value) + return + if isinstance(value, (list, tuple, set)): + for child_value in value: + append(key, child_value) + return + value = cls._serialize(value) + if isinstance(value, bool): + rendered = str(value).lower() + else: + rendered = str(value) + items.append((key, rendered)) + + for key, value in source: + append(str(key), value) + return items @staticmethod def _check_response(response: Response) -> None: if not response.is_success: + try: + parsed = response.json() + body = ( + parsed + if isinstance(parsed, dict) + else {'message': str(parsed)} + ) + except ValueError: + body = {'message': response.text or response.reason_phrase} raise FacturapiResponseException( - json=response.json(), + json=body, status_code=response.status_code, + headers=dict(response.headers), ) diff --git a/facturapi/resources/base.py b/facturapi/resources/base.py index c9ee833..4cafd2a 100644 --- a/facturapi/resources/base.py +++ b/facturapi/resources/base.py @@ -290,8 +290,7 @@ def count(cls, **query_params) -> int: """ q = cls._query_params(**query_params) response = client.get(cls._resource, q.dict()) - items = response['data'] - return len(items) + return response.get('total_results', len(response['data'])) @classmethod def all(cls, **query_params) -> Generator[Resource, None, None]: @@ -309,13 +308,26 @@ def all(cls, **query_params) -> Generator[Resource, None, None]: """ q = cls._query_params(**query_params) - next_page_uri = f'{cls._resource}?{urlencode(q.dict())}' + params = q.dict() + encoded_params = urlencode(params) + next_page_uri = ( + f'{cls._resource}?{encoded_params}' + if encoded_params + else cls._resource + ) current_page = 1 while next_page_uri: page = client.get(next_page_uri) yield from (cls._from_dict(item) for item in page['data']) next_page_uri = '' - if current_page < page['total_pages']: + + if q.pagination == 'cursor': + next_cursor = page.get('next_cursor') + if next_cursor: + params.pop('before', None) + params['after'] = next_cursor + next_page_uri = f'{cls._resource}?{urlencode(params)}' + elif current_page < page['total_pages']: current_page += 1 - q.page = current_page - next_page_uri = f'{cls._resource}?{urlencode(q.dict())}' + params['page'] = current_page + next_page_uri = f'{cls._resource}?{urlencode(params)}' diff --git a/facturapi/resources/invoices.py b/facturapi/resources/invoices.py index 1d328d3..aee737d 100644 --- a/facturapi/resources/invoices.py +++ b/facturapi/resources/invoices.py @@ -106,7 +106,7 @@ class InvoiceRequest(BaseModel): relation: InvoiceRelation | None = None pdf_custom_section: str | None = None addenda: str | None = None - namespaces: Namespace | None = None + namespaces: list[Namespace] | None = None @dataclass diff --git a/facturapi/types/enums.py b/facturapi/types/enums.py index 6ffb74c..e148fdb 100644 --- a/facturapi/types/enums.py +++ b/facturapi/types/enums.py @@ -71,7 +71,7 @@ class PaymentForm(str, Enum): tarjeta_de_credito = '04' monedero_electronico = '05' dinero_electronico = '06' - vales_de_despensa = '06' + vales_de_despensa = '08' dacion_en_pago = '12' pago_por_subrogacion = '13' pago_por_consignacion = '14' diff --git a/facturapi/types/exc.py b/facturapi/types/exc.py index a80b92c..a10cf61 100644 --- a/facturapi/types/exc.py +++ b/facturapi/types/exc.py @@ -18,6 +18,16 @@ class MultipleResultsFound(FacturapiException): class FacturapiResponseException(FacturapiException): json: dict[str, Any] status_code: int + headers: dict[str, str] | None = None + + @property + def message(self) -> str: + return str(self.json.get('message', 'Facturapi request failed')) + + @property + def code(self) -> str | None: + value = self.json.get('code') + return str(value) if value is not None else None def __str__(self) -> str: - return repr(self) + return self.message diff --git a/facturapi/types/queries.py b/facturapi/types/queries.py index da058ee..3ae9cb3 100644 --- a/facturapi/types/queries.py +++ b/facturapi/types/queries.py @@ -3,7 +3,7 @@ from pydantic import BaseModel, Field -MAX_PAGE_SIZE = 50 +MAX_PAGE_SIZE = 100 MIN_PAGE = 1 PageSize = Annotated[int, Field(gt=0, le=MAX_PAGE_SIZE)] @@ -52,6 +52,9 @@ class BaseQuery(BaseModel): """ q: str | None = None + pagination: str | None = None + after: str | None = None + before: str | None = None limit: PageSize | None = MAX_PAGE_SIZE page: Page | None = MIN_PAGE date: DateFilter | None = None diff --git a/facturapi/version.py b/facturapi/version.py index bf5ac87..0c141bf 100644 --- a/facturapi/version.py +++ b/facturapi/version.py @@ -1,2 +1,2 @@ -__version__ = '1.0.1' +__version__ = '2.0.0' CLIENT_VERSION = __version__ diff --git a/mkdocs.yml b/mkdocs.yml index 7631f6d..b852c40 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,7 +1,7 @@ site_name: "facturapi-python" site_description: "Facturapi Python Library" -repo_url: "https://github.com/cuenca-mx/facturapi-python" -repo_name: "cuenca-mx/facturapi-python" +repo_url: "https://github.com/FacturAPI/facturapi-python" +repo_name: "FacturAPI/facturapi-python" theme: name: material @@ -21,12 +21,11 @@ plugins: rendering: show_root_full_path: no show_source: no - watch: - - src/facturapi nav: - Overview: index.md - Code Reference: - Base code: resources/resources.md - Customers: resources/customers.md - - Invoices: resources/invoices.md \ No newline at end of file + - Invoices: resources/invoices.md + - Generated API: openapi.md diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..8fbb1d7 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,52 @@ +[build-system] +requires = ["setuptools>=77"] +build-backend = "setuptools.build_meta" + +[project] +name = "facturapi" +dynamic = ["version"] +description = "Official Python SDK for the Facturapi API" +readme = "README.md" +requires-python = ">=3.10" +license = "MIT" +authors = [ + { name = "Facturapi", email = "contacto@facturapi.io" }, +] +keywords = [ + "cfdi", + "factura", + "facturacion-electronica", + "mexico", + "sat", +] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Operating System :: OS Independent", + "Typing :: Typed", +] +dependencies = [ + "httpx>=0.28.0,<1.0.0", + "pydantic>=2.13.0,<3.0.0", +] + +[project.urls] +Homepage = "https://www.facturapi.io" +Documentation = "https://docs.facturapi.io" +Repository = "https://github.com/FacturAPI/facturapi-python" +Issues = "https://github.com/FacturAPI/facturapi-python/issues" + +[tool.setuptools.dynamic] +version = { attr = "facturapi.version.__version__" } + +[tool.setuptools.package-data] +facturapi = ["py.typed"] + +[tool.setuptools.packages.find] +include = ["facturapi*"] diff --git a/requirements-docs.txt b/requirements-docs.txt new file mode 100644 index 0000000..404e523 --- /dev/null +++ b/requirements-docs.txt @@ -0,0 +1,2 @@ +mkdocs-material==9.7.* +mkdocstrings[python]==1.0.* diff --git a/requirements-test.txt b/requirements-test.txt index 80fa901..05f1ea0 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -5,3 +5,7 @@ pytest-vcr==1.* isort==6.* flake8==7.* mypy==1.* +build==1.* +twine==6.* +PyYAML==6.* +types-PyYAML==6.* diff --git a/requirements.txt b/requirements.txt index 8c3b52c..ab25b6e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ httpx==0.28.1 -pydantic==2.10.6 +pydantic==2.13.3 diff --git a/scripts/generate_openapi.py b/scripts/generate_openapi.py new file mode 100644 index 0000000..bc9ecc9 --- /dev/null +++ b/scripts/generate_openapi.py @@ -0,0 +1,447 @@ +#!/usr/bin/env python3 +"""Generate Facturapi resource clients from the public OpenAPI document. + +The generator intentionally creates a small, stable SDK layer rather than a +large model tree. Request and response payloads remain ordinary mappings while +every documented operation gets an explicit, discoverable Python method. +""" + +from __future__ import annotations + +import argparse +import json +import keyword +import re +import urllib.request +from collections import defaultdict +from pathlib import Path +from typing import Any + +OPENAPI_URL = 'https://docs.facturapi.io/redocusaurus/api-en.yaml' +HTTP_METHODS = {'get', 'post', 'put', 'patch', 'delete'} + +TAG_SERVICES = { + 'carta_porte_keys': ('carta_porte_catalogs', 'CartaPorteCatalogs'), + 'comercio_exterior_keys': ( + 'comercio_exterior_catalogs', + 'ComercioExteriorCatalogs', + ), + 'customer': ('customers', 'Customers'), + 'product': ('products', 'Products'), + 'invoice': ('invoices', 'Invoices'), + 'receipt': ('receipts', 'Receipts'), + 'retention': ('retentions', 'Retentions'), + 'organization': ('organizations', 'Organizations'), + 'organization_access': ('organizations', 'Organizations'), + 'webhooks': ('webhooks', 'Webhooks'), + 'tools': ('tools', 'Tools'), + 'sat_keys': ('catalogs', 'Catalogs'), + 'nomina_keys': ('nomina_catalogs', 'NominaCatalogs'), +} + +FALLBACK_OPERATIONS = { + ('GET', '/check'): ('tools', 'health_check'), + ('GET', '/tools/tax_id_validation'): ('tools', 'validate_tax_id'), + ('GET', '/catalogs/products'): ('catalogs', 'search_products'), + ('GET', '/catalogs/units'): ('catalogs', 'search_units'), +} + +QUERY_ARGUMENTS = { + ('GET', '/tools/tax_id_validation'): ('tax_id',), +} + +METHOD_OVERRIDES = { + 'uploadOrganizationFiel': 'upload_fiel', + # Keep parity with the current Node SDK where the OpenAPI samples lag. + 'getSeriesGroup': 'list_series_group', + 'createSeriesGroup': 'create_series_group', + 'updateSeriesGroup': 'update_series_group', + 'deleteSeriesGroup': 'delete_series_group', +} + +# A single OpenAPI operation accepts ``format``. These aliases provide the +# same convenient methods as facturapi-node while retaining a generic method. +FORMAT_ALIASES = { + ('GET', '/invoices/{invoice_id}/{format}'): ( + 'download', + ('pdf', 'xml', 'zip'), + ), + ('GET', '/invoices/{invoice_id}/cancellation_receipt/{format}'): ( + 'download_cancellation_receipt', + ('pdf', 'xml'), + ), + ('GET', '/invoices/{invoice_id}/download-url/{format}'): ( + 'download_url', + ('pdf', 'xml', 'zip'), + ), + ( + 'GET', + '/invoices/{invoice_id}/cancellation_receipt/download-url/{format}', + ): ('download_cancellation_receipt_url', ('pdf', 'xml')), + ('GET', '/retentions/{retention_id}/{format}'): ( + 'download', + ('pdf', 'xml', 'zip'), + ), + ('GET', '/retentions/{retention_id}/download-url/{format}'): ( + 'download_url', + ('pdf', 'xml', 'zip'), + ), +} + + +def camel_to_snake(value: str) -> str: + value = re.sub(r'(.)([A-Z][a-z]+)', r'\1_\2', value) + value = re.sub(r'([a-z0-9])([A-Z])', r'\1_\2', value) + return re.sub(r'[^a-zA-Z0-9_]+', '_', value).strip('_').lower() + + +def python_name(value: str) -> str: + value = camel_to_snake(value) + if keyword.iskeyword(value): + value += '_' + return value + + +def load_document(source: str) -> dict[str, Any]: + if source.startswith(('http://', 'https://')): + with urllib.request.urlopen(source) as response: # noqa: S310 + raw = response.read().decode() + else: + raw = Path(source).read_text() + + try: + return json.loads(raw) + except json.JSONDecodeError: + try: + import yaml + except ImportError as exc: # pragma: no cover - developer guidance + raise SystemExit( + 'PyYAML is required for YAML specs: pip install PyYAML' + ) from exc + return yaml.safe_load(raw) + + +def resolve(document: dict[str, Any], value: Any) -> Any: + while isinstance(value, dict) and '$ref' in value: + ref = value['$ref'] + if not ref.startswith('#/'): + return value + target: Any = document + for part in ref[2:].split('/'): + target = target[part.replace('~1', '/').replace('~0', '~')] + value = target + return value + + +def node_method(operation: dict[str, Any]) -> tuple[str, str] | None: + pattern = re.compile( + r'facturapi\s*\.\s*([A-Za-z]\w*)\s*\.\s*' r'([A-Za-z]\w*)\s*\(' + ) + for sample in operation.get('x-codeSamples', []): + lang = str(sample.get('lang', '')).lower() + label = str(sample.get('label', '')).lower() + if lang not in {'javascript', 'node.js'} and label != 'node.js': + continue + match = pattern.search(sample.get('source', '')) + if match: + service, method = match.groups() + if method == 'del': + method = 'delete' + return camel_to_snake(service), camel_to_snake(method) + return None + + +def fallback_method(operation_id: str, service: str) -> str: + if operation_id in METHOD_OVERRIDES: + return METHOD_OVERRIDES[operation_id] + method = camel_to_snake(operation_id) + tokens = service.rstrip('s').split('_') + for token in tokens: + method = re.sub(rf'(^|_){re.escape(token)}(?=_|$)', r'\1', method) + return method.replace('__', '_').strip('_') + + +def path_parameters(path: str) -> list[str]: + return [python_name(item) for item in re.findall(r'{([^}]+)}', path)] + + +def request_body( + document: dict[str, Any], operation: dict[str, Any] +) -> tuple[bool, bool, bool]: + body = operation.get('requestBody') + if not body: + return False, False, False + body = resolve(document, body) + content = body.get('content', {}) + multipart = 'multipart/form-data' in content + return True, bool(body.get('required')), multipart + + +def has_query_parameters( + document: dict[str, Any], + path_item: dict[str, Any], + operation: dict[str, Any], +) -> bool: + parameters = [ + *path_item.get('parameters', []), + *operation.get('parameters', []), + ] + return any( + resolve(document, item).get('in') == 'query' for item in parameters + ) + + +def binary_response( + document: dict[str, Any], operation: dict[str, Any] +) -> bool: + for status, response in operation.get('responses', {}).items(): + if not str(status).startswith('2'): + continue + response = resolve(document, response) + content_types = set(response.get('content', {})) + if content_types and not any('json' in item for item in content_types): + return True + return False + + +def operation_name( + method: str, + path: str, + operation: dict[str, Any], + tag: str, +) -> tuple[str, str]: + fallback = FALLBACK_OPERATIONS.get((method, path)) + if fallback: + return fallback + service, _ = TAG_SERVICES[tag] + operation_id = operation.get('operationId') + if operation_id in METHOD_OVERRIDES: + return service, METHOD_OVERRIDES[operation_id] + sample_name = node_method(operation) + if sample_name: + sample_service, sample_method = sample_name + return sample_service, sample_method + if not operation_id: + raise ValueError(f'Missing operationId/name for {method} {path}') + return service, fallback_method(operation_id, service) + + +def render_method( + *, + document: dict[str, Any], + path_item: dict[str, Any], + method: str, + path: str, + operation: dict[str, Any], + name: str, + fixed_format: str | None = None, +) -> list[str]: + path_args = path_parameters(path) + if fixed_format: + path_args.remove('format') + has_body, body_required, multipart = request_body(document, operation) + has_query = has_query_parameters(document, path_item, operation) + query_arguments = QUERY_ARGUMENTS.get((method, path), ()) + parameters = [f'{item}: str' for item in path_args] + if multipart: + parameters.append('files: Mapping[str, Any]') + parameters.append('data: Mapping[str, Any] | None = None') + elif has_body: + default = '' if body_required else ' | None = None' + parameters.append(f'data: Mapping[str, Any]{default}') + parameters.extend(f'{item}: str' for item in query_arguments) + if has_query and not query_arguments: + parameters.append('params: QueryParams | None = None') + + signature = ', '.join(['self', *parameters]) + summary = operation.get('summary') or operation.get('operationId') or name + operation_id = operation.get('operationId') or f'{method} {path}' + values = ', '.join(f"'{item}': {item}" for item in path_args) + if fixed_format: + values = ', '.join( + filter(None, [values, f"'format': '{fixed_format}'"]) + ) + args = [repr(method), repr(path)] + if values: + args.append(f'path_params={{{values}}}') + if query_arguments: + query_values = ', '.join( + f"'{item}': {item}" for item in query_arguments + ) + args.append(f'params={{{query_values}}}') + elif has_query: + args.append('params=params') + if multipart: + args.extend(['data=data', 'files=files']) + elif has_body: + args.append('data=data') + if binary_response(document, operation): + args.append("response_type='bytes'") + + return [ + f' def {name}({signature}) -> Any:', + f' """{summary}.', + '', + f' OpenAPI operation: ``{operation_id}``.', + f' Endpoint: ``{method} {path}``.', + ' """', + f" return self._request({', '.join(args)})", + '', + ] + + +def generate(document: dict[str, Any]) -> str: + services: dict[str, dict[str, Any]] = defaultdict(dict) + operations: list[tuple[Any, ...]] = [] + + for path, path_item in document['paths'].items(): + for verb, operation in path_item.items(): + if verb.lower() not in HTTP_METHODS: + continue + method = verb.upper() + tag = operation.get('tags', ['tools'])[0] + service, class_name = TAG_SERVICES[tag] + resolved_service, name = operation_name( + method, path, operation, tag + ) + if resolved_service != service: + service = resolved_service + class_name = next( + item[1] + for item in TAG_SERVICES.values() + if item[0] == service + ) + record = (document, path_item, method, path, operation) + + format_alias = FORMAT_ALIASES.get((method, path)) + if format_alias: + generic_name, formats = format_alias + services[class_name][generic_name] = ( + *record, + generic_name, + None, + ) + for value in formats: + alias = f'{generic_name}_{value}' + if generic_name.endswith('_url'): + alias = f'{generic_name[:-4]}_{value}_url' + services[class_name][alias] = (*record, alias, value) + else: + if name in services[class_name]: + raise ValueError( + f'Duplicate generated method {class_name}.{name}' + ) + services[class_name][name] = (*record, name, None) + operations.append((method, path, operation.get('operationId'))) + + class_order = [ + 'Customers', + 'Products', + 'Invoices', + 'Receipts', + 'Retentions', + 'Organizations', + 'Webhooks', + 'Tools', + 'Catalogs', + 'NominaCatalogs', + 'CartaPorteCatalogs', + 'ComercioExteriorCatalogs', + ] + output = [ + '# This file is generated by scripts/generate_openapi.py.', + '# Do not edit it by hand.', + '', + 'from collections.abc import Mapping', + 'from typing import Any', + 'from urllib.parse import quote', + '', + 'from .http.client import Client, QueryParams', + '', + f"OPENAPI_VERSION = {document['info']['version']!r}", + f'OPENAPI_OPERATION_COUNT = {len(operations)}', + '', + '', + 'class _Service:', + ' def __init__(self, client: Client) -> None:', + ' self._client = client', + '', + ' def _request(', + ' self,', + ' method: str,', + ' path: str,', + ' *,', + ' path_params: Mapping[str, Any] | None = None,', + ' params: QueryParams | None = None,', + ' data: Mapping[str, Any] | None = None,', + ' files: Mapping[str, Any] | None = None,', + " response_type: str = 'auto',", + ' ) -> Any:', + ' for key, value in (path_params or {}).items():', + " encoded = quote(str(value), safe='')", + " path = path.replace('{' + key + '}', encoded)", + ' return self._client.request(', + ' method,', + ' path,', + ' params=params,', + ' data=data,', + ' files=files,', + ' response_type=response_type,', + ' )', + '', + '', + ] + for class_name in class_order: + output.extend([f'class {class_name}(_Service):']) + methods = services.get(class_name, {}) + if not methods: + output.extend([' pass', '', '']) + continue + for record in methods.values(): + doc, path_item, method, path, operation, name, fixed_format = ( + record + ) + output.extend( + render_method( + document=doc, + path_item=path_item, + method=method, + path=path, + operation=operation, + name=name, + fixed_format=fixed_format, + ) + ) + output.append('') + rendered = '\n'.join(output).rstrip() + '\n' + try: + import black + except ImportError: + return rendered + return black.format_str( + rendered, + mode=black.Mode(line_length=79, string_normalization=False), + ) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument('--input', default=OPENAPI_URL) + parser.add_argument('--output', default='facturapi/_generated.py') + parser.add_argument('--check', action='store_true') + args = parser.parse_args() + + rendered = generate(load_document(args.input)) + output = Path(args.output) + if args.check: + if not output.exists() or output.read_text() != rendered: + raise SystemExit( + f'{output} is stale; run scripts/generate_openapi.py' + ) + return + output.write_text(rendered) + print(f'Generated {output}') + + +if __name__ == '__main__': + main() diff --git a/setup.cfg b/setup.cfg index bc2565a..3f50781 100644 --- a/setup.cfg +++ b/setup.cfg @@ -4,9 +4,15 @@ test=pytest [tool:pytest] addopts = -p no:warnings -v --cov=facturapi --cov-report term-missing +[coverage:run] +omit = + facturapi/_generated.py + [flake8] inline-quotes = ' multiline-quotes = """ +per-file-ignores = + facturapi/_generated.py:E501 [isort] multi_line_output=3 @@ -24,4 +30,4 @@ ignore_missing_imports = True ignore_missing_imports = True [mypy-iso4217] -ignore_missing_imports = True \ No newline at end of file +ignore_missing_imports = True diff --git a/setup.py b/setup.py index 8cc9c39..3c2b884 100644 --- a/setup.py +++ b/setup.py @@ -1,35 +1,5 @@ -from importlib.machinery import SourceFileLoader +"""Compatibility shim for tools that still invoke ``setup.py`` directly.""" -from setuptools import find_packages, setup +from setuptools import setup -version = SourceFileLoader('version', 'facturapi/version.py').load_module() - - -with open('README.md', 'r') as f: - long_description = f.read() - - -setup( - name='facturapi', - version=version.__version__, - author='Cuenca', - author_email='dev@cuenca.com', - description='Facturapi API Client', - long_description=long_description, - long_description_content_type='text/markdown', - url='https://github.com/cuenca-mx/facturapi-python', - packages=find_packages(), - include_package_data=True, - package_data=dict(facturapi=['py.typed']), - python_requires='>=3.10', - install_requires=['httpx>=0.28.0,<1.0.0', 'pydantic>=2.10.0,<3.0.0'], - classifiers=[ - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.10', - 'Programming Language :: Python :: 3.11', - 'Programming Language :: Python :: 3.12', - 'Programming Language :: Python :: 3.13', - 'License :: OSI Approved :: MIT License', - 'Operating System :: OS Independent', - ], -) +setup() diff --git a/tests/http/test_client.py b/tests/http/test_client.py index cc2c8f5..e2d1e0c 100644 --- a/tests/http/test_client.py +++ b/tests/http/test_client.py @@ -1,6 +1,10 @@ +import datetime as dt + +import httpx import pytest from facturapi.http.client import Client +from facturapi.types import BaseQuery from facturapi.types.exc import FacturapiResponseException @@ -35,3 +39,145 @@ def test_override_with_configure(): client = Client() client.configure('some_api_key2') assert client.api_key == 'some_api_key2' + + +def test_query_params_use_openapi_deep_object_encoding(): + request = None + + def handler(value: httpx.Request) -> httpx.Response: + nonlocal request + request = value + return httpx.Response( + 200, + json={'ok': True}, + headers={'Content-Type': 'application/json'}, + ) + + client = Client('test_key') + headers = dict(client.client.headers) + client.client.close() + client.client = httpx.Client( + transport=httpx.MockTransport(handler), headers=headers + ) + client.get( + '/invoices', + params={ + 'date': {'gte': dt.datetime(2026, 1, 1, tzinfo=dt.timezone.utc)}, + 'status': ['valid', 'canceled'], + 'draft': False, + 'empty': None, + }, + ) + + assert request is not None + assert request.headers['authorization'] == 'Bearer test_key' + assert request.url.params.multi_items() == [ + ('date[gte]', '2026-01-01T00:00:00+00:00'), + ('status', 'valid'), + ('status', 'canceled'), + ('draft', 'false'), + ] + + +def test_binary_response_is_returned_as_bytes(): + def handler(_: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + content=b'%PDF', + headers={'Content-Type': 'application/pdf'}, + ) + + client = Client('test_key') + headers = dict(client.client.headers) + client.client.close() + client.client = httpx.Client( + transport=httpx.MockTransport(handler), headers=headers + ) + + assert client.get('/invoices/inv_123/pdf') == b'%PDF' + + +def test_non_json_error_response(): + def handler(_: httpx.Request) -> httpx.Response: + return httpx.Response(503, text='temporarily unavailable') + + client = Client('test_key') + headers = dict(client.client.headers) + client.client.close() + client.client = httpx.Client( + transport=httpx.MockTransport(handler), headers=headers + ) + + with pytest.raises(FacturapiResponseException) as exc_info: + client.get('/check') + + assert exc_info.value.status_code == 503 + assert str(exc_info.value) == 'temporarily unavailable' + + +@pytest.mark.parametrize('body', ['gateway unavailable', ['bad gateway']]) +def test_non_object_json_error_response(body): + def handler(_: httpx.Request) -> httpx.Response: + return httpx.Response(502, json=body) + + client = Client('test_key') + headers = dict(client.client.headers) + client.client.close() + client.client = httpx.Client( + transport=httpx.MockTransport(handler), headers=headers + ) + + with pytest.raises(FacturapiResponseException) as exc_info: + client.get('/check') + + assert exc_info.value.status_code == 502 + assert str(exc_info.value) == str(body) + + +def test_response_modes_and_multipart_request(): + requests = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + if request.url.path.endswith('/explicit-json'): + return httpx.Response(200, content=b'{"ok": true}', headers={}) + return httpx.Response( + 200, + text='plain response', + headers={'Content-Type': 'text/plain'}, + ) + + client = Client('test_key', headers={'X-Test': 'true'}) + headers = dict(client.client.headers) + client.client.close() + client.client = httpx.Client( + transport=httpx.MockTransport(handler), headers=headers + ) + + assert client.get('/text', response_type='text') == 'plain response' + assert client.get('/text') == 'plain response' + assert client.get('/explicit-json', response_type='json') == {'ok': True} + client.post( + '/upload', + {'password': 'secret'}, + files={'file': ('file.txt', b'content', 'text/plain')}, + ) + + content_type = requests[-1].headers['content-type'] + assert content_type.startswith('multipart/form-data') + assert requests[-1].headers['x-test'] == 'true' + + +def test_client_helpers_and_context_manager(): + assert Client._serialize(BaseQuery(q='test')) == {'q': 'test'} + assert Client._query_items(None) is None + + with Client('test_key') as client: + assert client.__enter__() is client + + +def test_response_exception_code(): + error = FacturapiResponseException( + json={'message': 'bad request', 'code': 123}, status_code=400 + ) + assert error.code == '123' diff --git a/tests/resources/test_resources.py b/tests/resources/test_resources.py index 25ad73a..810c37e 100644 --- a/tests/resources/test_resources.py +++ b/tests/resources/test_resources.py @@ -1,8 +1,47 @@ import pytest +from facturapi.resources import base +from facturapi.resources.base import Queryable from facturapi.resources.resources import retrieve_property def test_retrieve_wrong_uri(): with pytest.raises(ValueError): retrieve_property('wrong_uri') + + +class CursorResource(Queryable): + _resource = 'resources' + + +def test_all_follows_cursor_pagination(monkeypatch): + pages = iter( + [ + { + 'data': [{'id': 'resource_1'}], + 'next_cursor': 'cursor/2', + }, + { + 'data': [{'id': 'resource_2'}], + 'next_cursor': None, + }, + ] + ) + requested_uris = [] + + def get(uri): + requested_uris.append(uri) + return next(pages) + + monkeypatch.setattr(base.client, 'get', get) + + resources = list(CursorResource.all(pagination='cursor', limit=1)) + + assert [resource.id for resource in resources] == [ + 'resource_1', + 'resource_2', + ] + assert requested_uris == [ + 'resources?pagination=cursor&limit=1', + 'resources?pagination=cursor&limit=1&after=cursor%2F2', + ] diff --git a/tests/test_generated_client.py b/tests/test_generated_client.py new file mode 100644 index 0000000..09cba09 --- /dev/null +++ b/tests/test_generated_client.py @@ -0,0 +1,127 @@ +from typing import Any + +from facturapi import Facturapi +from facturapi._generated import OPENAPI_OPERATION_COUNT + + +class RecordingClient: + def __init__(self) -> None: + self.host = 'https://example.test/v2/' + self.calls: list[tuple[str, str, dict[str, Any]]] = [] + self.closed = False + + def request(self, method: str, path: str, **kwargs: Any) -> dict: + self.calls.append((method, path, kwargs)) + return {'ok': True} + + def close(self) -> None: + self.closed = True + + +def test_generated_client_has_openapi_operations(): + assert OPENAPI_OPERATION_COUNT > 0 + + +def test_resource_clients_are_exposed(): + http = RecordingClient() + api = Facturapi(http_client=http) # type: ignore[arg-type] + + assert api.customers + assert api.products + assert api.invoices + assert api.receipts + assert api.retentions + assert api.organizations + assert api.webhooks + assert api.tools + assert api.catalogs + assert api.nomina_catalogs + assert api.carta_porte_catalogs + assert api.comercio_exterior_catalogs + assert api.base_url == 'https://example.test/v2/' + api.base_url = 'https://api.example.test/custom' + assert http.host == 'https://api.example.test/custom/' + + +def test_generated_method_builds_path_body_and_query(): + http = RecordingClient() + api = Facturapi(http_client=http) # type: ignore[arg-type] + + api.customers.update( + 'cus/with spaces', + {'email': 'test@example.com'}, + {'createEditLink': True}, + ) + + assert http.calls == [ + ( + 'PUT', + '/customers/cus%2Fwith%20spaces', + { + 'params': {'createEditLink': True}, + 'data': {'email': 'test@example.com'}, + 'files': None, + 'response_type': 'auto', + }, + ) + ] + + +def test_format_convenience_methods_match_node_sdk(): + http = RecordingClient() + api = Facturapi(http_client=http) # type: ignore[arg-type] + + api.invoices.download_pdf('inv_123') + api.invoices.download_xml_url('inv_123') + api.retentions.download_zip('ret_123') + + assert [call[:2] for call in http.calls] == [ + ('GET', '/invoices/inv_123/pdf'), + ('GET', '/invoices/inv_123/download-url/xml'), + ('GET', '/retentions/ret_123/zip'), + ] + assert http.calls[0][2]['response_type'] == 'bytes' + assert http.calls[1][2]['response_type'] == 'auto' + + +def test_multipart_operation_is_generated(): + http = RecordingClient() + api = Facturapi(http_client=http) # type: ignore[arg-type] + files = {'file': ('logo.png', b'png', 'image/png')} + + api.organizations.upload_logo('org_123', files) + + _, path, kwargs = http.calls[0] + assert path == '/organizations/org_123/logo' + assert kwargs['files'] == files + + +def test_tool_method_matches_node_sdk_signature(): + http = RecordingClient() + api = Facturapi(http_client=http) # type: ignore[arg-type] + + api.tools.validate_tax_id('ABC101010111') + + assert http.calls[0][1] == '/tools/tax_id_validation' + assert http.calls[0][2]['params'] == {'tax_id': 'ABC101010111'} + + +def test_nomina_catalog_methods_are_exposed(): + http = RecordingClient() + api = Facturapi(http_client=http) # type: ignore[arg-type] + + api.nomina_catalogs.search_deductions({'q': '001', 'limit': 10}) + api.nomina_catalogs.search_perceptions({'q': '001', 'limit': 10}) + + assert [call[:2] for call in http.calls] == [ + ('GET', '/catalogs/deductions'), + ('GET', '/catalogs/perceptions'), + ] + assert http.calls[0][2]['params'] == {'q': '001', 'limit': 10} + + +def test_context_manager_closes_http_client(): + http = RecordingClient() + with Facturapi(http_client=http): # type: ignore[arg-type] + pass + assert http.closed diff --git a/tests/types/test_queries.py b/tests/types/test_queries.py index bb980cf..227dfb5 100644 --- a/tests/types/test_queries.py +++ b/tests/types/test_queries.py @@ -13,7 +13,7 @@ def test_base_query(): def test_base_query_page_size(): with pytest.raises(ValidationError): - _ = BaseQuery(q='Frida', limit=51) + _ = BaseQuery(q='Frida', limit=101) def test_base_query_page_min():