flashpi 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
flashpi/__init__.py ADDED
@@ -0,0 +1,74 @@
1
+ """The official Python SDK for Flashpi, the electronic invoicing API for Ecuador's SRI."""
2
+
3
+ from ._client import DEFAULT_BASE_URL, DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT, AsyncFlashpi, Flashpi
4
+ from ._errors import (
5
+ APIConnectionError,
6
+ APIError,
7
+ APITimeoutError,
8
+ AuthenticationError,
9
+ BadRequestError,
10
+ ConflictError,
11
+ FlashpiError,
12
+ InternalServerError,
13
+ NotFoundError,
14
+ PermissionDeniedError,
15
+ RateLimitError,
16
+ UnprocessableEntityError,
17
+ WebhookVerificationError,
18
+ WebhookVerificationReason,
19
+ )
20
+ from ._http import RequestOptions
21
+ from ._pagination import AsyncPage, AsyncPaginator, Page
22
+ from ._response import APIResponse
23
+ from ._version import VERSION
24
+ from ._webhooks import (
25
+ WEBHOOK_TOLERANCE_SECONDS,
26
+ WebhookHeaders,
27
+ WebhookPayload,
28
+ Webhooks,
29
+ unwrap_webhook,
30
+ verify_webhook_signature,
31
+ )
32
+ from .types import ApiErrorCode, ApiFieldErrorCode, FieldError, Language, Problem
33
+
34
+ __version__ = VERSION
35
+
36
+ __all__ = [
37
+ "DEFAULT_BASE_URL",
38
+ "DEFAULT_MAX_RETRIES",
39
+ "DEFAULT_TIMEOUT",
40
+ "VERSION",
41
+ "WEBHOOK_TOLERANCE_SECONDS",
42
+ "APIConnectionError",
43
+ "APIError",
44
+ "APIResponse",
45
+ "APITimeoutError",
46
+ "ApiErrorCode",
47
+ "ApiFieldErrorCode",
48
+ "AsyncFlashpi",
49
+ "AsyncPage",
50
+ "AsyncPaginator",
51
+ "AuthenticationError",
52
+ "BadRequestError",
53
+ "ConflictError",
54
+ "FieldError",
55
+ "Flashpi",
56
+ "FlashpiError",
57
+ "InternalServerError",
58
+ "Language",
59
+ "NotFoundError",
60
+ "Page",
61
+ "PermissionDeniedError",
62
+ "Problem",
63
+ "RateLimitError",
64
+ "RequestOptions",
65
+ "UnprocessableEntityError",
66
+ "WebhookHeaders",
67
+ "WebhookPayload",
68
+ "WebhookVerificationError",
69
+ "WebhookVerificationReason",
70
+ "Webhooks",
71
+ "__version__",
72
+ "unwrap_webhook",
73
+ "verify_webhook_signature",
74
+ ]
flashpi/_client.py ADDED
@@ -0,0 +1,405 @@
1
+ """The Flashpi clients: `Flashpi` (sync) and `AsyncFlashpi` (asyncio), with the same API."""
2
+
3
+ import os
4
+ import re
5
+ from collections.abc import Mapping
6
+ from types import TracebackType
7
+ from typing import Self, cast
8
+
9
+ import httpx
10
+
11
+ from ._errors import FlashpiError
12
+ from ._http import AsyncEngine, ClientConfig, SyncEngine
13
+ from ._network import limit_to_attempt_deadlines
14
+ from ._validate import checked_headers, header_value, valid_retries, valid_timeout
15
+ from ._webhooks import Webhooks
16
+ from .resources.documents import (
17
+ AsyncCreditNotes,
18
+ AsyncDebitNotes,
19
+ AsyncDocuments,
20
+ AsyncInvoices,
21
+ AsyncPurchaseSettlements,
22
+ AsyncWaybills,
23
+ AsyncWithholdings,
24
+ CreditNotes,
25
+ DebitNotes,
26
+ Documents,
27
+ Invoices,
28
+ PurchaseSettlements,
29
+ Waybills,
30
+ Withholdings,
31
+ )
32
+ from .resources.events import AsyncEvents, Events
33
+ from .resources.issuer_onboardings import AsyncIssuerOnboardings, IssuerOnboardings
34
+ from .resources.issuers import AsyncIssuers, Issuers
35
+ from .resources.status import AsyncStatusResource, StatusResource
36
+ from .resources.webhook_endpoints import AsyncWebhookEndpoints, WebhookEndpoints
37
+ from .types import Environment, Language
38
+
39
+ DEFAULT_BASE_URL = "https://api.flashpi.com.ec"
40
+ DEFAULT_TIMEOUT = 60.0
41
+ """Seconds each attempt of a request may take, from sending it to reading the whole answer (the
42
+ Node SDK's 60 000 ms)."""
43
+ DEFAULT_MAX_RETRIES = 2
44
+
45
+ _API_KEY_CHARACTERS = re.compile(r"[\x21-\x7e]+")
46
+ """What an API key is made of: visible ASCII, without spaces."""
47
+ _JS_SPACE = r"[\t\n\v\f\r \xa0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]"
48
+ _AROUND = re.compile(rf"\A{_JS_SPACE}+|{_JS_SPACE}+\Z")
49
+ """What JavaScript's `trim()` removes around a string, the Node SDK's key included: its white
50
+ space and line breaks, and the byte order mark (U+FEFF) that `str.strip()` keeps."""
51
+
52
+
53
+ def _configure(
54
+ api_key: str | None,
55
+ *,
56
+ base_url: str | None,
57
+ timeout: float | None,
58
+ max_retries: int | None,
59
+ language: Language | None,
60
+ default_headers: Mapping[str, str] | None,
61
+ ) -> tuple[ClientConfig, Environment]:
62
+ # The key is checked first, so a missing or bad key is the error reported, then read again
63
+ # once every other setting is checked: no local of this frame keeps it, since error trackers
64
+ # record the local variables of the frames an error passes through (and scrub `api_key` by
65
+ # name at best).
66
+ _api_key(api_key)
67
+ headers = checked_headers("default_headers", default_headers)
68
+ if language is not None:
69
+ language = cast("Language", header_value("language", language))
70
+ if any(name.lower() == "idempotency-key" for name in headers):
71
+ raise FlashpiError(
72
+ "default_headers cannot carry an Idempotency-Key: it must be unique per operation. "
73
+ "Pass idempotency_key= to each create() instead"
74
+ )
75
+ url = DEFAULT_BASE_URL if base_url is None else base_url
76
+ if not _is_http_url(url):
77
+ raise FlashpiError(f"base_url must be an http:// or https:// URL, got {url!r}")
78
+ seconds = valid_timeout("timeout", DEFAULT_TIMEOUT if timeout is None else timeout)
79
+ retries = valid_retries(
80
+ "max_retries", DEFAULT_MAX_RETRIES if max_retries is None else max_retries
81
+ )
82
+ config = ClientConfig(
83
+ api_key=_api_key(api_key),
84
+ base_url=url,
85
+ timeout=seconds,
86
+ max_retries=retries,
87
+ language=language,
88
+ default_headers=headers,
89
+ )
90
+ return config, "test" if config.api_key.startswith("fp_test_") else "live"
91
+
92
+
93
+ def _is_http_url(url: object) -> bool:
94
+ if not isinstance(url, str):
95
+ return False
96
+ try:
97
+ parsed = httpx.URL(url)
98
+ except httpx.InvalidURL:
99
+ return False
100
+ return parsed.scheme in ("http", "https") and bool(parsed.host)
101
+
102
+
103
+ def _api_key(api_key: str | None) -> str:
104
+ """The key: the argument or FLASHPI_API_KEY, trimmed as JavaScript's `trim()`, the byte order
105
+ mark included. A key that cannot be used raises FlashpiError, with no local keeping it."""
106
+ value: object = os.environ.get("FLASHPI_API_KEY") if api_key is None else api_key
107
+ key = _AROUND.sub("", value) if isinstance(value, str) else ""
108
+ problem = _key_problem(key)
109
+ if problem is None:
110
+ return key
111
+ del value, key
112
+ raise FlashpiError(problem)
113
+
114
+
115
+ def _key_problem(key: str) -> str | None:
116
+ """Why a trimmed key cannot be used, or None. It returns instead of raising, so its frame is
117
+ never part of an error."""
118
+ if not key:
119
+ return (
120
+ "Missing API key: pass it to Flashpi(api_key=...) or set the FLASHPI_API_KEY "
121
+ "environment variable"
122
+ )
123
+ if key.startswith("whsec_"):
124
+ return (
125
+ "That is a webhook signing secret (whsec_...), not an API key: use it with "
126
+ "flashpi.webhooks"
127
+ )
128
+ if not key.startswith(("fp_test_", "fp_live_")):
129
+ return "The API key must start with fp_test_ or fp_live_"
130
+ if not _API_KEY_CHARACTERS.fullmatch(key):
131
+ # Never the key: httpx would refuse it in the Authorization header and quote it.
132
+ return (
133
+ "The API key has spaces, line breaks or other characters an API key cannot have: "
134
+ "copy it again from the Flashpi console"
135
+ )
136
+ return None
137
+
138
+
139
+ def _describe(client: object, environment: Environment, config: ClientConfig) -> str:
140
+ # Never the API key.
141
+ return f"{type(client).__name__}(environment={environment!r}, base_url={config.base_url!r})"
142
+
143
+
144
+ class Flashpi:
145
+ """The Flashpi API client.
146
+
147
+ ```python
148
+ from flashpi import Flashpi
149
+
150
+ with Flashpi() as flashpi: # reads FLASHPI_API_KEY
151
+ invoice = flashpi.invoices.create({...})
152
+ ```
153
+
154
+ An `fp_test_` key issues in the SRI test environment (pruebas); an `fp_live_` key issues real
155
+ documents in production. Use it on servers only: the key must stay secret.
156
+ """
157
+
158
+ environment: Environment
159
+ """`test` for fp_test_ keys (SRI pruebas), `live` for fp_live_ keys (SRI production)."""
160
+
161
+ invoices: Invoices
162
+ """Invoices (facturas)."""
163
+
164
+ credit_notes: CreditNotes
165
+ """Credit notes (notas de crédito)."""
166
+
167
+ debit_notes: DebitNotes
168
+ """Debit notes (notas de débito)."""
169
+
170
+ purchase_settlements: PurchaseSettlements
171
+ """Purchase settlements (liquidaciones de compra)."""
172
+
173
+ waybills: Waybills
174
+ """Waybills (guías de remisión)."""
175
+
176
+ withholdings: Withholdings
177
+ """Withholding receipts (comprobantes de retención)."""
178
+
179
+ documents: Documents
180
+ """Every issued document: search, XML, RIDE (PDF) and email."""
181
+
182
+ issuers: Issuers
183
+ """Issuers (RUCs) with their establishments, emission points and certificates."""
184
+
185
+ issuer_onboardings: IssuerOnboardings
186
+ """Create an issuer from its .p12 certificate."""
187
+
188
+ webhook_endpoints: WebhookEndpoints
189
+ """The URLs Flashpi sends events to."""
190
+
191
+ events: Events
192
+ """Events of documents, certificates and email sending."""
193
+
194
+ status: StatusResource
195
+ """The API's status."""
196
+
197
+ webhooks: Webhooks
198
+ """Verify the signature of the webhooks you receive (no API key needed)."""
199
+
200
+ def __init__(
201
+ self,
202
+ api_key: str | None = None,
203
+ *,
204
+ base_url: str | None = None,
205
+ timeout: float | None = None,
206
+ max_retries: int | None = None,
207
+ language: Language | None = None,
208
+ default_headers: Mapping[str, str] | None = None,
209
+ http_client: httpx.Client | None = None,
210
+ ) -> None:
211
+ """Creates a client.
212
+
213
+ Args:
214
+ api_key: The API key (`fp_test_...` or `fp_live_...`). Defaults to the
215
+ FLASHPI_API_KEY environment variable.
216
+ base_url: The API's address (default https://api.flashpi.com.ec).
217
+ timeout: Seconds each attempt of a request may take, from sending it to reading the
218
+ whole answer (default 60). Issuing with `options.waitForAuthorization` raises it,
219
+ on both clients, to the wait plus 15 seconds. On `Flashpi`, only a DNS lookup,
220
+ which Python cannot interrupt, and connecting to a host that has several
221
+ addresses can run past it (but see `http_client`).
222
+ max_retries: Retries after network errors, timeouts, 429 and 5xx (default 2; 0 turns
223
+ them off).
224
+ language: Language of the API's messages: `en` (default) or `es`. Error codes do not
225
+ change.
226
+ default_headers: Headers sent with every request (not `Idempotency-Key`: that one is
227
+ per request).
228
+ http_client: An `httpx.Client` to send the requests with (proxies, tests). The SDK
229
+ never follows redirects, and leaves closing this client to you. With it, the
230
+ deadline is checked as each chunk of the answer's body arrives: before that,
231
+ httpx limits connecting, each write, waiting for a pooled connection and each
232
+ read of the status line and headers to the timeout one by one, so headers that
233
+ trickle in can keep an attempt going far past its timeout, and a body that this
234
+ client's own response hooks read is checked only once it has been read.
235
+ """
236
+ config, self.environment = _configure(
237
+ api_key,
238
+ base_url=base_url,
239
+ timeout=timeout,
240
+ max_retries=max_retries,
241
+ language=language,
242
+ default_headers=default_headers,
243
+ )
244
+ if http_client is not None and not isinstance(cast("object", http_client), httpx.Client):
245
+ raise TypeError(
246
+ "http_client must be an httpx.Client (AsyncFlashpi takes an httpx.AsyncClient)"
247
+ )
248
+ self._owns_http_client = http_client is None
249
+ if http_client is None:
250
+ http_client = httpx.Client(follow_redirects=False)
251
+ limit_to_attempt_deadlines(http_client)
252
+ self._http_client = http_client
253
+ self._engine = SyncEngine(config, self._http_client)
254
+ self.invoices = Invoices(self._engine)
255
+ self.credit_notes = CreditNotes(self._engine)
256
+ self.debit_notes = DebitNotes(self._engine)
257
+ self.purchase_settlements = PurchaseSettlements(self._engine)
258
+ self.waybills = Waybills(self._engine)
259
+ self.withholdings = Withholdings(self._engine)
260
+ self.documents = Documents(self._engine)
261
+ self.issuers = Issuers(self._engine)
262
+ self.issuer_onboardings = IssuerOnboardings(self._engine)
263
+ self.webhook_endpoints = WebhookEndpoints(self._engine)
264
+ self.events = Events(self._engine)
265
+ self.status = StatusResource(self._engine)
266
+ self.webhooks = Webhooks()
267
+
268
+ def close(self) -> None:
269
+ """Closes the connections, unless the http_client came from you."""
270
+ if self._owns_http_client:
271
+ self._http_client.close()
272
+
273
+ def __enter__(self) -> Self:
274
+ return self
275
+
276
+ def __exit__(
277
+ self,
278
+ exc_type: type[BaseException] | None,
279
+ exc: BaseException | None,
280
+ traceback: TracebackType | None,
281
+ ) -> None:
282
+ self.close()
283
+
284
+ def __repr__(self) -> str:
285
+ return _describe(self, self.environment, self._engine.config)
286
+
287
+
288
+ class AsyncFlashpi:
289
+ """The Flashpi API client for asyncio, with the same API as `Flashpi`.
290
+
291
+ ```python
292
+ from flashpi import AsyncFlashpi
293
+
294
+ async with AsyncFlashpi() as flashpi: # reads FLASHPI_API_KEY
295
+ invoice = await flashpi.invoices.create({...})
296
+ ```
297
+
298
+ An `fp_test_` key issues in the SRI test environment (pruebas); an `fp_live_` key issues real
299
+ documents in production. Use it on servers only: the key must stay secret.
300
+ """
301
+
302
+ environment: Environment
303
+ """`test` for fp_test_ keys (SRI pruebas), `live` for fp_live_ keys (SRI production)."""
304
+
305
+ invoices: AsyncInvoices
306
+ """Invoices (facturas)."""
307
+
308
+ credit_notes: AsyncCreditNotes
309
+ """Credit notes (notas de crédito)."""
310
+
311
+ debit_notes: AsyncDebitNotes
312
+ """Debit notes (notas de débito)."""
313
+
314
+ purchase_settlements: AsyncPurchaseSettlements
315
+ """Purchase settlements (liquidaciones de compra)."""
316
+
317
+ waybills: AsyncWaybills
318
+ """Waybills (guías de remisión)."""
319
+
320
+ withholdings: AsyncWithholdings
321
+ """Withholding receipts (comprobantes de retención)."""
322
+
323
+ documents: AsyncDocuments
324
+ """Every issued document: search, XML, RIDE (PDF) and email."""
325
+
326
+ issuers: AsyncIssuers
327
+ """Issuers (RUCs) with their establishments, emission points and certificates."""
328
+
329
+ issuer_onboardings: AsyncIssuerOnboardings
330
+ """Create an issuer from its .p12 certificate."""
331
+
332
+ webhook_endpoints: AsyncWebhookEndpoints
333
+ """The URLs Flashpi sends events to."""
334
+
335
+ events: AsyncEvents
336
+ """Events of documents, certificates and email sending."""
337
+
338
+ status: AsyncStatusResource
339
+ """The API's status."""
340
+
341
+ webhooks: Webhooks
342
+ """Verify the signature of the webhooks you receive (no API key needed)."""
343
+
344
+ def __init__(
345
+ self,
346
+ api_key: str | None = None,
347
+ *,
348
+ base_url: str | None = None,
349
+ timeout: float | None = None,
350
+ max_retries: int | None = None,
351
+ language: Language | None = None,
352
+ default_headers: Mapping[str, str] | None = None,
353
+ http_client: httpx.AsyncClient | None = None,
354
+ ) -> None:
355
+ """Creates a client. The arguments are the ones of `Flashpi`, with an
356
+ `httpx.AsyncClient` as `http_client`."""
357
+ config, self.environment = _configure(
358
+ api_key,
359
+ base_url=base_url,
360
+ timeout=timeout,
361
+ max_retries=max_retries,
362
+ language=language,
363
+ default_headers=default_headers,
364
+ )
365
+ if http_client is not None and not isinstance(
366
+ cast("object", http_client), httpx.AsyncClient
367
+ ):
368
+ raise TypeError(
369
+ "http_client must be an httpx.AsyncClient (Flashpi takes an httpx.Client)"
370
+ )
371
+ self._owns_http_client = http_client is None
372
+ self._http_client = http_client or httpx.AsyncClient(follow_redirects=False)
373
+ self._engine = AsyncEngine(config, self._http_client)
374
+ self.invoices = AsyncInvoices(self._engine)
375
+ self.credit_notes = AsyncCreditNotes(self._engine)
376
+ self.debit_notes = AsyncDebitNotes(self._engine)
377
+ self.purchase_settlements = AsyncPurchaseSettlements(self._engine)
378
+ self.waybills = AsyncWaybills(self._engine)
379
+ self.withholdings = AsyncWithholdings(self._engine)
380
+ self.documents = AsyncDocuments(self._engine)
381
+ self.issuers = AsyncIssuers(self._engine)
382
+ self.issuer_onboardings = AsyncIssuerOnboardings(self._engine)
383
+ self.webhook_endpoints = AsyncWebhookEndpoints(self._engine)
384
+ self.events = AsyncEvents(self._engine)
385
+ self.status = AsyncStatusResource(self._engine)
386
+ self.webhooks = Webhooks()
387
+
388
+ async def aclose(self) -> None:
389
+ """Closes the connections, unless the http_client came from you."""
390
+ if self._owns_http_client:
391
+ await self._http_client.aclose()
392
+
393
+ async def __aenter__(self) -> Self:
394
+ return self
395
+
396
+ async def __aexit__(
397
+ self,
398
+ exc_type: type[BaseException] | None,
399
+ exc: BaseException | None,
400
+ traceback: TracebackType | None,
401
+ ) -> None:
402
+ await self.aclose()
403
+
404
+ def __repr__(self) -> str:
405
+ return _describe(self, self.environment, self._engine.config)