nodda-client 1.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,18 @@
1
+ """SDK publico de Nodda -- capa 3. Cliente HTTP delgado, sin conocimiento
2
+ de AEAT, certificados, ni logica de negocio.
3
+ """
4
+
5
+ from .client import NoddaClient
6
+ from .exceptions import NoddaApiException, NoddaException
7
+ from .http_transport import HttpTransport, RespuestaHttp, UrllibHttpTransport
8
+
9
+ __version__ = "1.0.0"
10
+
11
+ __all__ = [
12
+ "NoddaClient",
13
+ "NoddaException",
14
+ "NoddaApiException",
15
+ "HttpTransport",
16
+ "RespuestaHttp",
17
+ "UrllibHttpTransport",
18
+ ]
nodda_client/client.py ADDED
@@ -0,0 +1,229 @@
1
+ """Cliente del SDK publico de Nodda (capa 3)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import hmac
7
+ import json
8
+ import secrets
9
+ from urllib.parse import quote, urlencode
10
+
11
+ from .exceptions import NoddaApiException, NoddaException
12
+ from .http_transport import HttpTransport, RespuestaHttp, UrllibHttpTransport
13
+
14
+
15
+ class NoddaClient:
16
+ """SDK publico de Nodda -- capa 3 de la arquitectura::
17
+
18
+ Este SDK -> API propia -> Core Engine -> AEAT
19
+
20
+ A proposito NO sabe nada de AEAT, certificados, XML, ni hash -- solo
21
+ habla HTTP/JSON con la API propia. Cualquier logica de negocio
22
+ (validaciones, reglas de VERI*FACTU) vive del lado de la API/Core
23
+ Engine, nunca aqui.
24
+
25
+ Multi-NIF: un token puede autorizar varios NIF ("1 cuenta principal,
26
+ varias empresas"). Por eso cada metodo que habla con una factura
27
+ especifica acepta un ``nif`` OPCIONAL -- si tu token solo tiene 1 NIF
28
+ autorizado, puedes seguir sin pasarlo.
29
+ """
30
+
31
+ def __init__(self, base_url: str, api_key: str, http: HttpTransport | None = None) -> None:
32
+ self._base_url = base_url
33
+ self._api_key = api_key
34
+ self._http: HttpTransport = http or UrllibHttpTransport()
35
+ self._ultimo_correlation_id: str | None = None
36
+ self._ultimo_cupo_restante: int | None = None
37
+
38
+ def crear_factura(self, datos: dict, nif: str | None = None) -> dict:
39
+ """POST /v1/invoices -- registra una factura. Siempre devuelve de
40
+ inmediato con status "queued"; usa consultar_estado() despues para
41
+ saber el resultado real.
42
+ """
43
+ if nif is not None:
44
+ datos = {**datos, "nif": nif}
45
+ return self._peticion("POST", "/v1/invoices", datos)
46
+
47
+ def consultar_estado(self, id: str, nif: str | None = None) -> dict:
48
+ """GET /v1/invoices/{id} -- estado real de una factura ya emitida."""
49
+ return self._peticion("GET", f"/v1/invoices/{id}{self._query_nif(nif)}")
50
+
51
+ def cancelar_factura(self, id: str, datos: dict | None = None, nif: str | None = None) -> dict:
52
+ """POST /v1/invoices/{id}/cancel -- anula una factura. VERI*FACTU no
53
+ permite borrar, solo anular con un registro nuevo.
54
+ """
55
+ datos = dict(datos or {})
56
+ if nif is not None:
57
+ datos["nif"] = nif
58
+ return self._peticion("POST", f"/v1/invoices/{id}/cancel", datos)
59
+
60
+ def obtener_url_qr(self, id: str, nif: str | None = None) -> str:
61
+ """GET /v1/invoices/{id}/qr?format=url -- la URL del codigo QR."""
62
+ query = f"?format=url&nif={quote(nif)}" if nif is not None else "?format=url"
63
+ respuesta = self._peticion("GET", f"/v1/invoices/{id}/qr{query}")
64
+ return str(respuesta["url"])
65
+
66
+ def obtener_imagen_qr(self, id: str, nif: str | None = None) -> bytes:
67
+ """GET /v1/invoices/{id}/qr -- la imagen PNG del codigo QR, en
68
+ binario crudo (bytes, listo para guardar a archivo o incrustar en
69
+ un PDF).
70
+ """
71
+ respuesta = self._http.request(
72
+ "GET",
73
+ self._url_completa(f"/v1/invoices/{id}/qr{self._query_nif(nif)}"),
74
+ self._headers(),
75
+ )
76
+ if respuesta.status >= 400:
77
+ self._lanzar_error_desde_json(respuesta)
78
+ return respuesta.body
79
+
80
+ def estado_sistema(self, nif: str | None = None) -> dict:
81
+ """GET /v1/status -- si se puede enviar ahora o hay que esperar
82
+ (control de flujo de AEAT). El tiempo de espera es por certificado,
83
+ no por token.
84
+ """
85
+ return self._peticion("GET", f"/v1/status{self._query_nif(nif)}")
86
+
87
+ def listar_facturas(self, filtros: dict | None = None, nif: str | None = None) -> dict:
88
+ """GET /v1/invoices -- lista las facturas propias, con filtros
89
+ opcionales (status, type, from, to, limit, offset).
90
+ """
91
+ filtros = dict(filtros or {})
92
+ if nif is not None:
93
+ filtros["nif"] = nif
94
+ query = f"?{urlencode(filtros)}" if filtros else ""
95
+ return self._peticion("GET", f"/v1/invoices{query}")
96
+
97
+ def reintentar_factura(self, id: str, tipo: str | None = None, nif: str | None = None) -> dict:
98
+ """POST /v1/invoices/{id}/retry -- fuerza un reintento manual, sin
99
+ esperar el ciclo automatico. ``tipo`` es 'alta' o 'anulacion'.
100
+ """
101
+ parametros: dict[str, str] = {}
102
+ if tipo is not None:
103
+ parametros["type"] = tipo
104
+ if nif is not None:
105
+ parametros["nif"] = nif
106
+ query = f"?{urlencode(parametros)}" if parametros else ""
107
+ return self._peticion("POST", f"/v1/invoices/{id}/retry{query}")
108
+
109
+ def obtener_perfil(self) -> dict:
110
+ """GET /v1/client -- los NIF autorizados para este token."""
111
+ return self._peticion("GET", "/v1/client")
112
+
113
+ def configurar_webhook(self, url: str, nif: str | None = None) -> dict:
114
+ """PUT /v1/webhooks -- configura la URL (https) a la que Nodda avisa cuando la AEAT responde.
115
+ La respuesta trae el ``secreto`` de firma UNA sola vez: guardalo para verificar_firma_webhook()."""
116
+ return self._peticion("PUT", f"/v1/webhooks{self._query_nif(nif)}", {"url": url})
117
+
118
+ def quitar_webhook(self, nif: str | None = None) -> dict:
119
+ """DELETE /v1/webhooks -- deja de enviar avisos."""
120
+ return self._peticion("DELETE", f"/v1/webhooks{self._query_nif(nif)}")
121
+
122
+ @staticmethod
123
+ def verificar_firma_webhook(cuerpo: bytes | str, firma: str | None, secreto: str) -> bool:
124
+ """Comprueba que un aviso viene de Nodda: X-Nodda-Signature es el HMAC-SHA256 (hex) del cuerpo EXACTO
125
+ recibido, con tu secreto. Usa el cuerpo sin decodificar."""
126
+ if not firma or not secreto:
127
+ return False
128
+ datos = cuerpo.encode("utf-8") if isinstance(cuerpo, str) else cuerpo
129
+ esperada = hmac.new(secreto.encode("utf-8"), datos, hashlib.sha256).hexdigest()
130
+ return hmac.compare_digest(esperada, firma.strip().lower())
131
+
132
+ def ultimo_correlation_id(self) -> str | None:
133
+ """El X-Correlation-Id enviado en la ultima peticion, o None."""
134
+ return self._ultimo_correlation_id
135
+
136
+ def ultimo_cupo_restante(self) -> int | None:
137
+ """Cuantas facturas gratuitas le quedan al cliente segun el ultimo
138
+ crear_factura() -- None si el NIF no esta sujeto a cupo (plan de
139
+ pago) o si el servidor no mando el encabezado. No hace su propia
140
+ peticion -- solo lee el encabezado de la ULTIMA respuesta.
141
+ """
142
+ return self._ultimo_cupo_restante
143
+
144
+ @staticmethod
145
+ def registrar_cuenta(
146
+ base_url: str,
147
+ email: str,
148
+ nif: str,
149
+ nombre_emisor: str,
150
+ telefono: str | None = None,
151
+ http: HttpTransport | None = None,
152
+ ) -> dict:
153
+ """POST /v1/cuentas -- registro publico, SIN necesitar una API Key
154
+ previa (por eso es estatico y no manda Authorization). Devuelve una
155
+ API Key nueva -- guardala de inmediato, el servidor no la vuelve a
156
+ mostrar.
157
+ """
158
+ http = http or UrllibHttpTransport()
159
+ datos = {"email": email, "nif": nif, "nombreEmisor": nombre_emisor}
160
+ if telefono is not None:
161
+ datos["telefono"] = telefono
162
+
163
+ respuesta = http.request(
164
+ "POST",
165
+ f"{base_url.rstrip('/')}/v1/cuentas",
166
+ {"Content-Type": "application/json"},
167
+ json.dumps(datos),
168
+ )
169
+ if respuesta.status >= 400:
170
+ NoddaClient._lanzar_error_estatico(respuesta)
171
+ decodificado = NoddaClient._intentar_json(respuesta.body)
172
+ if decodificado is None:
173
+ raise NoddaException(
174
+ f"La API devolvio una respuesta que no es JSON valido: {respuesta.body.decode('utf-8', 'replace')}"
175
+ )
176
+ return decodificado
177
+
178
+ # -- internos -----------------------------------------------------------
179
+
180
+ @staticmethod
181
+ def _query_nif(nif: str | None) -> str:
182
+ return f"?nif={quote(nif)}" if nif is not None else ""
183
+
184
+ def _peticion(self, metodo: str, ruta: str, datos: dict | None = None) -> dict:
185
+ cuerpo = json.dumps(datos) if datos is not None else None
186
+ respuesta = self._http.request(metodo, self._url_completa(ruta), self._headers(), cuerpo)
187
+
188
+ if respuesta.status >= 400:
189
+ self._lanzar_error_desde_json(respuesta)
190
+
191
+ valor = respuesta.headers.get("X-Nodda-Quota-Remaining")
192
+ self._ultimo_cupo_restante = int(valor) if valor is not None else None
193
+
194
+ decodificado = self._intentar_json(respuesta.body)
195
+ if decodificado is None:
196
+ raise NoddaException(
197
+ f"La API devolvio una respuesta que no es JSON valido: {respuesta.body.decode('utf-8', 'replace')}"
198
+ )
199
+ return decodificado
200
+
201
+ def _lanzar_error_desde_json(self, respuesta: RespuestaHttp) -> None:
202
+ self._lanzar_error_estatico(respuesta)
203
+
204
+ @staticmethod
205
+ def _lanzar_error_estatico(respuesta: RespuestaHttp) -> None:
206
+ decodificado = NoddaClient._intentar_json(respuesta.body) or {}
207
+ error = decodificado.get("error", {}) if isinstance(decodificado, dict) else {}
208
+ mensaje = error.get("message") or f"La API respondio con el codigo HTTP {respuesta.status}."
209
+ codigo = error.get("code")
210
+ raise NoddaApiException(mensaje, respuesta.status, codigo)
211
+
212
+ def _url_completa(self, ruta: str) -> str:
213
+ return self._base_url.rstrip("/") + ruta
214
+
215
+ def _headers(self) -> dict[str, str]:
216
+ self._ultimo_correlation_id = secrets.token_hex(16)
217
+ return {
218
+ "Authorization": f"Bearer {self._api_key}",
219
+ "Content-Type": "application/json",
220
+ "X-Correlation-Id": self._ultimo_correlation_id,
221
+ }
222
+
223
+ @staticmethod
224
+ def _intentar_json(body: bytes):
225
+ try:
226
+ valor = json.loads(body.decode("utf-8"))
227
+ except (ValueError, UnicodeDecodeError):
228
+ return None
229
+ return valor if isinstance(valor, (dict, list)) else None
@@ -0,0 +1,26 @@
1
+ """Excepciones del SDK publico de Nodda."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class NoddaException(Exception):
7
+ """Excepcion generica de comunicacion (red, timeout).
8
+
9
+ Para errores que la API devuelve con codigo HTTP de error (422, 404,
10
+ etc.), ver NoddaApiException -- son cosas distintas: esta es "no se
11
+ pudo ni hablar con la API", la otra es "la API respondio, pero con un
12
+ error".
13
+ """
14
+
15
+
16
+ class NoddaApiException(NoddaException):
17
+ """Se lanza cuando la API de Nodda SI respondio, pero con un codigo de
18
+ error (422 = dato invalido o regla de negocio, 404 = no encontrado,
19
+ 500 = error interno). Distinta de NoddaException (esa es cuando ni
20
+ siquiera hubo respuesta -- problema de red).
21
+ """
22
+
23
+ def __init__(self, mensaje: str, status_code: int, codigo_error: str | None) -> None:
24
+ super().__init__(mensaje)
25
+ self.status_code = status_code
26
+ self.codigo_error = codigo_error
@@ -0,0 +1,91 @@
1
+ """Transporte HTTP del SDK: interfaz + implementacion real con urllib."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import urllib.error
6
+ import urllib.request
7
+ from dataclasses import dataclass, field
8
+ from typing import Protocol
9
+
10
+ from .exceptions import NoddaException
11
+
12
+
13
+ @dataclass
14
+ class RespuestaHttp:
15
+ """Respuesta cruda del transporte.
16
+
17
+ body son bytes (texto JSON o imagen PNG); headers tiene el nombre del
18
+ encabezado en Title-Case.
19
+ """
20
+
21
+ status: int
22
+ body: bytes
23
+ headers: dict[str, str] = field(default_factory=dict)
24
+
25
+
26
+ class HttpTransport(Protocol):
27
+ """Abstrae el transporte HTTP -- misma razon que HttpClientInterface en
28
+ el SDK de PHP: sin esto no se puede probar NoddaClient con un doble de
29
+ prueba en vez de la conexion real. La implementacion real es
30
+ UrllibHttpTransport.
31
+ """
32
+
33
+ def request(
34
+ self,
35
+ metodo: str,
36
+ url: str,
37
+ headers: dict[str, str] | None = None,
38
+ cuerpo: str | None = None,
39
+ ) -> RespuestaHttp:
40
+ ...
41
+
42
+
43
+ class UrllibHttpTransport:
44
+ """Implementacion real usando urllib de la biblioteca estandar -- sin
45
+ requests ni ninguna otra libreria HTTP, mismo criterio que el SDK de
46
+ PHP (curl crudo, sin Guzzle): mantener el SDK publico con el minimo de
47
+ dependencias posible, ya que lo va a instalar gente externa.
48
+ """
49
+
50
+ _TIEMPO_LIMITE = 30
51
+
52
+ def request(
53
+ self,
54
+ metodo: str,
55
+ url: str,
56
+ headers: dict[str, str] | None = None,
57
+ cuerpo: str | None = None,
58
+ ) -> RespuestaHttp:
59
+ headers = headers or {}
60
+ datos = cuerpo.encode("utf-8") if cuerpo is not None else None
61
+ peticion = urllib.request.Request(url=url, data=datos, headers=headers, method=metodo.upper())
62
+
63
+ try:
64
+ with urllib.request.urlopen(peticion, timeout=self._TIEMPO_LIMITE) as respuesta:
65
+ return RespuestaHttp(
66
+ status=respuesta.status,
67
+ body=respuesta.read(),
68
+ headers=self._normalizar(respuesta.headers.items()),
69
+ )
70
+ except urllib.error.HTTPError as e:
71
+ # La API respondio con un codigo de error (>= 400); no es un
72
+ # fallo de red -- se devuelve como respuesta normal para que
73
+ # NoddaClient lo traduzca a NoddaApiException.
74
+ return RespuestaHttp(
75
+ status=e.code,
76
+ body=e.read(),
77
+ headers=self._normalizar(e.headers.items() if e.headers else []),
78
+ )
79
+ except urllib.error.URLError as e:
80
+ raise NoddaException(f"Fallo de comunicacion con la API de Nodda: {e.reason}") from e
81
+
82
+ @staticmethod
83
+ def _normalizar(items) -> dict[str, str]:
84
+ # HTTP/2 manda los nombres en minusculas; se normalizan a Title-Case
85
+ # para que el resto del codigo pueda buscar por un nombre fijo
86
+ # (p. ej. "X-Nodda-Quota-Remaining").
87
+ encabezados: dict[str, str] = {}
88
+ for nombre, valor in items:
89
+ titulo = "-".join(p[:1].upper() + p[1:] for p in nombre.split("-"))
90
+ encabezados[titulo] = valor
91
+ return encabezados
@@ -0,0 +1,106 @@
1
+ Metadata-Version: 2.4
2
+ Name: nodda-client
3
+ Version: 1.2.0
4
+ Summary: Cliente oficial de la API de Nodda para emitir y consultar facturas VERI*FACTU (AEAT).
5
+ Author-email: Nodda <soporte@nodda.es>
6
+ License: MIT
7
+ Project-URL: Documentacion, https://app.nodda.es/developers/introduccion
8
+ Project-URL: Repositorio, https://gitlab.com/Bitfx/nodda-facturacion/nodda-cliente
9
+ Keywords: verifactu,aeat,facturacion,factura,nodda
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Operating System :: OS Independent
13
+ Requires-Python: >=3.8
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Dynamic: license-file
17
+
18
+ # nodda-client (Python)
19
+
20
+ Cliente oficial de la API de [Nodda](https://nodda.es) para emitir y consultar facturas **VERI\*FACTU** de la Agencia Tributaria (AEAT) desde tu propio programa (ERP, tienda online, TPV…).
21
+
22
+ Nodda se encarga de la firma con tu certificado, del XML, de la cadena de huellas y del envío a la AEAT: tú solo mandas los datos de la factura en JSON.
23
+
24
+ ## Instalación
25
+
26
+ ```bash
27
+ pip install nodda-client
28
+ ```
29
+
30
+ Requisitos: Python 3.8 o superior. Sin dependencias (solo la librería estándar).
31
+
32
+ ## Antes de empezar
33
+
34
+ 1. Crea tu cuenta en el portal de Nodda y da de alta tu empresa (NIF y certificado digital).
35
+ 2. Genera tu **clave API** en el portal → *API keys*.
36
+ 3. Usa la dirección del entorno con su clave:
37
+
38
+ | Entorno | Dirección | Uso |
39
+ |---|---|---|
40
+ | Pruebas | `https://sandbox.nodda.es` | Envía al entorno de pruebas de la AEAT, sin validez fiscal |
41
+ | Producción | `https://api.nodda.es` | Facturas reales |
42
+
43
+ La clave de pruebas no sirve en producción, ni al revés.
44
+
45
+ ## Uso
46
+
47
+ ```python
48
+ import os
49
+ from nodda_client import NoddaClient
50
+
51
+ nodda = NoddaClient("https://sandbox.nodda.es", os.environ["NODDA_API_KEY"])
52
+
53
+ r = nodda.crear_factura({
54
+ "series": "2026", "issueDate": "2026-11-03", "invoiceType": "F1",
55
+ "description": "Servicios de consultoría", "taxRegimeKey": "01", "operationQualification": "S1",
56
+ "taxBase": "100.00", "taxRate": "21", "taxAmount": "21.00", "totalAmount": "121.00",
57
+ "recipients": [{"nif": "B12345674", "name": "Cliente Demo SL"}],
58
+ })
59
+ # r["status"] == "queued": la API responde al momento; la AEAT, unos segundos después.
60
+
61
+ estado = nodda.consultar_estado(r["id"]) # queued, sent, accepted_with_errors o permanently_failed
62
+ ```
63
+
64
+ Si tu clave da acceso a varias empresas (NIF), indica cuál en el parámetro `nif` de cada método. Puedes asociar la factura a un centro de coste enviando `costCenterId` en los datos.
65
+
66
+ ## Métodos
67
+
68
+ | Método | Endpoint |
69
+ |---|---|
70
+ | `crear_factura(datos, nif=None)` | `POST /v1/invoices` |
71
+ | `consultar_estado(id, nif=None)` | `GET /v1/invoices/{id}` |
72
+ | `listar_facturas(filtros=None, nif=None)` | `GET /v1/invoices` |
73
+ | `cancelar_factura(id, datos=None, nif=None)` | `POST /v1/invoices/{id}/cancel` |
74
+ | `reintentar_factura(id, tipo=None, nif=None)` | `POST /v1/invoices/{id}/retry` |
75
+ | `obtener_url_qr(id)` / `obtener_imagen_qr(id)` | `GET /v1/invoices/{id}/qr` |
76
+ | `estado_sistema(nif=None)` | `GET /v1/status` |
77
+ | `obtener_perfil()` | `GET /v1/client` |
78
+ | `configurar_webhook(url, nif=None)` / `quitar_webhook(nif=None)` | `PUT` / `DELETE /v1/webhooks` |
79
+ | `NoddaClient.verificar_firma_webhook(cuerpo, firma, secreto)` | Comprueba la firma de un aviso |
80
+
81
+ ## Avisos (webhooks)
82
+
83
+ En lugar de consultar el estado de cada factura, Nodda puede avisar a tu servidor cuando la AEAT responde. Configura la URL con `configurar_webhook(url)` (la respuesta trae el `secreto` **una sola vez**: guárdalo) y, en tu endpoint, comprueba la firma antes de confiar en el aviso:
84
+
85
+ ```python
86
+ # cuerpo: bytes tal cual llegan; firma: cabecera X-Nodda-Signature
87
+ if not NoddaClient.verificar_firma_webhook(cuerpo, firma, os.environ["NODDA_WEBHOOK_SECRET"]):
88
+ return 401
89
+ ```
90
+
91
+ El cuerpo trae `id` (número de factura), `status` (`sent`, `accepted_with_errors` o `permanently_failed`), `razon`, `eventId` y los datos del acuse de la AEAT. Responde con un código 2xx; si no, Nodda lo reintenta.
92
+
93
+ ## Errores
94
+
95
+ Si la API responde con un error, se lanza `NoddaApiException` con el código HTTP y un código estable (por ejemplo `invalid_request`, `business_rule_violation`, `certificate_not_configured`). Si no hubo respuesta (fallo de red), se lanza `NoddaException`: puedes reintentar la misma petición.
96
+
97
+ ## Documentación
98
+
99
+ Guía completa, campos de cada petición y códigos de error: <https://app.nodda.es/developers/introduccion>
100
+ Referencia OpenAPI: <https://api.nodda.es/openapi.json>
101
+
102
+ Soporte: soporte@nodda.es
103
+
104
+ ## Licencia
105
+
106
+ MIT
@@ -0,0 +1,9 @@
1
+ nodda_client/__init__.py,sha256=3O7vFVbl0WnaDhGCRihnyrQHu4WmMXrMS7x_aiCdYvE,465
2
+ nodda_client/client.py,sha256=oIKOlnrvGz6J1_KPHIBpNm2AGSqpUDP_mRZ08rGAiE4,9921
3
+ nodda_client/exceptions.py,sha256=AYPHee0S7J4GxewttY4w_rafJpIQ1SuuEPR_ix7zF1o,936
4
+ nodda_client/http_transport.py,sha256=rqBNd-HfTi9A0n15xDrwc97JMJLHAJrZWcrs6h7GDAM,3131
5
+ nodda_client-1.2.0.dist-info/licenses/LICENSE,sha256=GCyPSNhLNR3iRMKRRjXPqnwbYYjkPOCapSBJYmc2-ec,1062
6
+ nodda_client-1.2.0.dist-info/METADATA,sha256=hbe-_5a6GgznOAtP05bYKlmXvPZkyQl3FRndfcNkXTE,4573
7
+ nodda_client-1.2.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
8
+ nodda_client-1.2.0.dist-info/top_level.txt,sha256=TvZgJaGt4UkVGHMBjXLEx7CfH33b870Ww8QPQkZWfow,13
9
+ nodda_client-1.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nodda
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ nodda_client