xAPI-client 1.0.11__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,12 @@
1
+ Copyright © 2026, Kohl Development Group.
2
+ All rights reserved.
3
+
4
+ Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
5
+
6
+ * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
7
+
8
+ * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
9
+
10
+ * Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
11
+
12
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,625 @@
1
+ Metadata-Version: 2.4
2
+ Name: xAPI-client
3
+ Version: 1.0.11
4
+ Summary: A lightweight, flexible asynchronous API client for Python built on httpx and pydantic
5
+ Author: rkohl
6
+ License-Expression: BSD-3-Clause
7
+ Project-URL: Homepage, https://github.com/rkohl/xAPI
8
+ Project-URL: Source, https://github.com/rkohl/xAPI
9
+ Keywords: api,client,async,httpx,pydantic,rest,api-client,python-api-client
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Framework :: Pydantic :: 2
16
+ Classifier: Typing :: Typed
17
+ Classifier: Topic :: Internet :: WWW/HTTP
18
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
19
+ Requires-Python: >=3.12
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE.md
22
+ Requires-Dist: pydantic>=2.0
23
+ Requires-Dist: httpx>=0.27
24
+ Requires-Dist: loguru>=0.7
25
+ Requires-Dist: shortuuid>=1.0
26
+ Dynamic: license-file
27
+
28
+ # xAPI
29
+
30
+ A lightweight, flexible asynchronous API client for Python built on [httpx](https://www.python-httpx.org/) and [pydantic](https://docs.pydantic.dev/).
31
+
32
+ xAPI organizes API endpoints into a tree of **Resources** and **Endpoints**, giving you a clean, dot-notation interface for calling any REST API with full type safety and automatic response validation.
33
+
34
+
35
+ ```python
36
+ coin = await client.coins.coin(parameters=CoinParams.Bitcoin)
37
+ print(coin.name) # "Bitcoin"
38
+ ```
39
+
40
+ ---
41
+
42
+ ## Features
43
+
44
+ - **Async-first** — Built on `httpx.AsyncClient` for high-performance, non-blocking requests.
45
+ - **Resource-oriented** — Organize endpoints into a hierarchical tree. Access them with dot notation (`client.coins.coin(...)`).
46
+ - **Type-safe responses** — Pydantic models validate and parse every API response automatically.
47
+ - **List responses** — Handle endpoints that return JSON arrays by passing a `list[Model]` response type.
48
+ - **Parameterized paths** — Define URL templates like `{id}` and inject values with type-safe `Parameters` enums.
49
+ - **Query parameters** — Build and manage query strings with the `Query` class.
50
+ - **Authentication** — Scoped API key auth — apply globally, per-endpoint, or disable entirely.
51
+ - **Rate limiting** — Built-in sliding window rate limiter to stay within API quotas.
52
+ - **Retry with backoff** — Automatic exponential backoff on 5xx errors, timeouts, and connection failures. 4xx errors are raised immediately.
53
+ - **Structured logging** — Color-coded, per-component logging via `loguru` (enabled with `debug=True`).
54
+ - **Context manager** — Proper connection cleanup with `async with` support.
55
+
56
+ **NOTE:** This is an experimental project and proof of concept. May not fully work as indended.
57
+
58
+ ---
59
+
60
+ ## Installation
61
+
62
+ Install **xAPI** using pip
63
+
64
+ ```shell
65
+ $ pip install xAPI
66
+ ```
67
+ **Requirements:** Python 3.12+
68
+
69
+ ---
70
+
71
+ ## Quick Start
72
+
73
+ ```python
74
+ import asyncio
75
+ import xAPI
76
+
77
+ from pydantic import BaseModel
78
+
79
+ # 1. Define a response model
80
+ class CoinModel(xAPI.ResponseModel):
81
+ id: str
82
+ symbol: str
83
+ name: str
84
+
85
+ # 2. Define path parameters
86
+ class CoinParams(xAPI.Parameters):
87
+ Bitcoin = "bitcoin"
88
+ Ethereum = "ethereum"
89
+
90
+ # 3. Define a path with parameter placeholders
91
+ class CoinPath(xAPI.Path[CoinParams]):
92
+ endpointPath: str = "{id}"
93
+
94
+ async def main():
95
+ # 4. Create the client
96
+ auth = xAPI.APIKey(
97
+ key="x_cg_demo_api_key",
98
+ secret="YOUR_API_KEY",
99
+ scope="All",
100
+ schem="Header"
101
+ )
102
+
103
+ async with xAPI.Client(
104
+ url="https://api.example.com/v1/",
105
+ apiKey=auth
106
+ ) as client:
107
+
108
+ # 5. Build resources and endpoints
109
+ coins = xAPI.Resource("coins")
110
+ coins.addEndpoints(
111
+ xAPI.Endpoint(name="coin", path=CoinPath(), response=CoinModel)
112
+ )
113
+ client.add(coins)
114
+
115
+ # 6. Make the request
116
+ coin = await client.coins.coin(parameters=CoinParams.Bitcoin)
117
+ print(coin.name) # "Bitcoin"
118
+ print(coin.symbol) # "btc"
119
+
120
+ asyncio.run(main())
121
+ ```
122
+
123
+ ---
124
+
125
+ ## Core Concepts
126
+
127
+ ### Client
128
+
129
+ The `Client` is the entry point. It manages the HTTP connection, authentication, rate limiting, retries, and the resource tree.
130
+
131
+ ```python
132
+ client = xAPI.Client(
133
+ url="https://api.example.com/v1/",
134
+ apiKey=auth, # optional
135
+ timeout=httpx.Timeout(30, connect=5), # optional (default: 30s overall, 5s connect)
136
+ rateLimit=xAPI.RateLimiter(maxCalls=30, perSecond=60), # optional
137
+ retry=xAPI.RetryConfig(attempts=3, baseDelay=0.3, maxDelay=5.0), # optional
138
+ debug=True, # optional, enables logging
139
+ )
140
+ ```
141
+
142
+ Always close the client when done, either with `async with` or by calling `await client.close()`.
143
+
144
+ ### Resource
145
+
146
+ A `Resource` is a named group of endpoints. Resources are registered on the client and accessed as attributes.
147
+
148
+ ```python
149
+ coins = xAPI.Resource("coins")
150
+ client.add(coins)
151
+
152
+ # Now accessible as:
153
+ client.coins
154
+ ```
155
+
156
+ **Path protection:** By default, the resource name is prepended to endpoint URLs. Set `pathProtection=False` to use the endpoint path as-is from the API root:
157
+
158
+ ```python
159
+ # With pathProtection=True (default):
160
+ # Endpoint path "list" -> request to /coins/list
161
+ coins = xAPI.Resource("coins")
162
+
163
+ # With pathProtection=False:
164
+ # Endpoint path "global" -> request to /global
165
+ markets = xAPI.Resource("markets", pathProtection=False)
166
+ ```
167
+
168
+ **Sub-resources** can be nested:
169
+
170
+ ```python
171
+ parent = xAPI.Resource("api")
172
+ child = xAPI.Resource("coins")
173
+ parent.addResources(child)
174
+ # Access: client.api.coins.some_endpoint(...)
175
+ ```
176
+
177
+ **Authentication scoping:** Set `requireAuth=True` on a resource to enable per-endpoint authentication (when using `Scope.Endpoint`).
178
+
179
+ ### Endpoint
180
+
181
+ An `Endpoint` represents a single API call. It defines the HTTP method, URL path, response model, and validation behavior.
182
+
183
+ ```python
184
+ endpoint = xAPI.Endpoint(
185
+ name="coin", # Python attribute name
186
+ path=CoinPath(), # Path object with URL template
187
+ response=CoinModel, # Pydantic model or list[Model] for response parsing
188
+ method="GET", # HTTP method (default: "GET")
189
+ nameOverride="", # Override the API-facing name
190
+ strict=False, # Enable strict Pydantic validation
191
+ )
192
+ ```
193
+
194
+ Add endpoints to a resource:
195
+
196
+ ```python
197
+ resource.addEndpoints(endpoint)
198
+ # or multiple:
199
+ resource.addEndpoints([endpoint1, endpoint2, endpoint3])
200
+ ```
201
+
202
+ Call an endpoint:
203
+
204
+ ```python
205
+ # Simple endpoint (no path parameters)
206
+ result = await client.coins.list()
207
+
208
+ # With path parameters
209
+ result = await client.coins.coin(parameters=CoinParams.Bitcoin)
210
+
211
+ # With query parameters
212
+ query = xAPI.Query({"localization": False, "tickers": False})
213
+ result = await client.coins.coin(parameters=CoinParams.Bitcoin, query=query)
214
+ ```
215
+
216
+ ### Path & Parameters
217
+
218
+ Paths define URL templates. Parameters are typed enums that fill in the template placeholders.
219
+
220
+ ```python
221
+ # Define parameters as a StrEnum
222
+ class CoinParams(xAPI.Parameters):
223
+ Bitcoin = "bitcoin"
224
+ Ethereum = "ethereum"
225
+ Solana = "solana"
226
+
227
+ # Define a path with a placeholder
228
+ class CoinByID(xAPI.Path[CoinParams]):
229
+ endpointPath: str = "{id}"
230
+
231
+ # Path without parameters
232
+ class CoinList(xAPI.Path):
233
+ endpointPath: str = "list"
234
+
235
+ # Multi-segment path
236
+ class CoinTickers(xAPI.Path[CoinParams]):
237
+ endpointPath: str = "{id}/tickers"
238
+ ```
239
+
240
+ ### Query
241
+
242
+ The `Query` class manages URL query parameters. Values set to `"NOT_GIVEN"` are automatically filtered out.
243
+
244
+ ```python
245
+ query = xAPI.Query({
246
+ "vs_currency": "usd",
247
+ "order": "market_cap_desc",
248
+ "per_page": 100,
249
+ "sparkline": False,
250
+ "optional_param": "NOT_GIVEN", # filtered out
251
+ })
252
+
253
+ # Add more params
254
+ query.add({"page": 2})
255
+
256
+ # Remove a param
257
+ query.remove("sparkline")
258
+
259
+ # Inspect
260
+ print(query.queries) # dict of active params
261
+ print(query.queryString) # "vs_currency=usd&order=market_cap_desc&..."
262
+ ```
263
+
264
+ ### ResponseModel
265
+
266
+ All response models should extend `xAPI.ResponseModel`, which extends Pydantic's `BaseModel` with convenience methods and optional API metadata.
267
+
268
+ ```python
269
+ class Coin(xAPI.ResponseModel):
270
+ id: str
271
+ symbol: str
272
+ name: str
273
+ market_cap: float | None = None
274
+ current_price: float | None = None
275
+ ```
276
+
277
+ **Convenience methods:**
278
+
279
+ ```python
280
+ coin = await client.coins.coin(parameters=CoinParams.Bitcoin)
281
+
282
+ # Convert to dict (excludes unset fields by default)
283
+ coin.toDict()
284
+
285
+ # Convert to formatted JSON string
286
+ coin.toJson(indent=2)
287
+
288
+ # Access API metadata (method, path, elapsed time)
289
+ print(coin.api.endpoint) # "GET coins/bitcoin in 0.35s"
290
+ ```
291
+
292
+ **List responses:** When an API returns a JSON array instead of an object, use a `list[Model]` response type on the endpoint:
293
+
294
+ ```python
295
+ class Category(xAPI.ResponseModel):
296
+ id: str
297
+ name: str
298
+
299
+ CategoryList = list[Category]
300
+
301
+ endpoint = xAPI.Endpoint(
302
+ name="categories",
303
+ path=CategoriesPath(),
304
+ response=CategoryList,
305
+ )
306
+
307
+ categories = await client.coins.categories() # returns list[Category]
308
+ ```
309
+
310
+ ---
311
+
312
+ ## Authentication
313
+
314
+ xAPI supports API key authentication with three scoping levels:
315
+
316
+ ```python
317
+ # Apply auth to ALL endpoints
318
+ auth = xAPI.APIKey(
319
+ keyName="x-api-key",
320
+ apiKey="your-secret-key",
321
+ scope=xAPI.Scope.All
322
+ )
323
+
324
+ # Apply auth only to endpoints on resources with requireAuth=True
325
+ auth = xAPI.APIKey(
326
+ keyName="x-api-key",
327
+ apiKey="your-secret-key",
328
+ scope=xAPI.Scope.Endpoint
329
+ )
330
+
331
+ # Disable auth
332
+ auth = xAPI.APIKey(
333
+ keyName="x-api-key",
334
+ apiKey="your-secret-key",
335
+ scope=xAPI.Scope.Disabled
336
+ )
337
+ ```
338
+
339
+ When `scope=Scope.All`, the auth key-value pair is added to both request headers and query parameters on every request.
340
+
341
+ When `scope=Scope.Endpoint`, auth is only applied to requests made through resources that have `requireAuth=True`.
342
+
343
+ ---
344
+
345
+ ## Rate Limiting
346
+
347
+ The built-in sliding window rate limiter prevents exceeding API quotas:
348
+
349
+ ```python
350
+ rate_limiter = xAPI.RateLimiter(
351
+ maxCalls=30, # maximum number of calls
352
+ perSecond=60, # within this time window (seconds)
353
+ )
354
+
355
+ client = xAPI.Client(
356
+ url="https://api.example.com/v1/",
357
+ rateLimit=rate_limiter,
358
+ )
359
+ ```
360
+
361
+ The rate limiter uses an async lock and automatically pauses requests when the limit is reached.
362
+
363
+ ---
364
+
365
+ ## Retry & Error Handling
366
+
367
+ ### Retry Configuration
368
+
369
+ Retries use exponential backoff and only trigger on retriable errors (5xx, timeouts, connection errors). 4xx errors are raised immediately.
370
+
371
+ ```python
372
+ retry = xAPI.RetryConfig(
373
+ attempts=3, # max retry attempts (default: 3)
374
+ baseDelay=0.3, # initial delay in seconds (default: 0.3)
375
+ maxDelay=5.0, # maximum delay in seconds (default: 5.0)
376
+ )
377
+ ```
378
+
379
+ ### Exception Hierarchy
380
+
381
+ xAPI provides specific exception types for different failure modes:
382
+
383
+ | Exception | When |
384
+ |---|---|
385
+ | `APIStatusError` | Any 4xx or 5xx response |
386
+ | `BadRequestError` | HTTP 400 |
387
+ | `AuthenticationError` | HTTP 401 |
388
+ | `PermissionDeniedError` | HTTP 403 |
389
+ | `NotFoundError` | HTTP 404 |
390
+ | `ConflictError` | HTTP 409 |
391
+ | `UnprocessableEntityError` | HTTP 422 |
392
+ | `RateLimitError` | HTTP 429 |
393
+ | `InternalServerError` | HTTP 5xx |
394
+ | `APITimeoutError` | Request timed out |
395
+ | `APIConnectionError` | Connection failed |
396
+ | `APIResponseValidationError` | Response doesn't match the Pydantic model |
397
+
398
+ ```python
399
+ import xAPI
400
+
401
+ try:
402
+ coin = await client.coins.coin(parameters=CoinParams.Bitcoin)
403
+ except xAPI.NotFoundError:
404
+ print("Coin not found")
405
+ except xAPI.RateLimitError:
406
+ print("Rate limited - slow down")
407
+ except xAPI.APIStatusError as e:
408
+ print(f"API error {e.status_code}: {e.message}")
409
+ except xAPI.APIConnectionError:
410
+ print("Could not connect to API")
411
+ except xAPI.APITimeoutError:
412
+ print("Request timed out")
413
+ ```
414
+
415
+ ---
416
+
417
+ ## Nested Data Unwrapping
418
+
419
+ Many APIs wrap their response in a `{"data": {...}}` envelope. xAPI automatically unwraps this by default, so your models only need to define the inner data structure.
420
+
421
+ ```python
422
+ # API returns: {"data": {"total_market_cap": 2.5e12, "total_volume": 1e11}}
423
+ # Your model only needs:
424
+ class MarketData(xAPI.ResponseModel):
425
+ total_market_cap: float
426
+ total_volume: float
427
+ ```
428
+
429
+ To disable this behavior, set `client.unsetNestedData = False`.
430
+
431
+ ---
432
+
433
+ ## Options (Enum Helpers)
434
+
435
+ xAPI provides base enum classes for defining typed option values:
436
+
437
+ ```python
438
+ from xAPI import Options, IntOptions
439
+
440
+ # String-based options
441
+ Status = Options("Status", ["active", "inactive"])
442
+ Interval = Options("Interval", ["5m", "hourly", "daily"])
443
+
444
+ # Integer-based options
445
+ Days = IntOptions("Days", [("one", 1), ("seven", 7), ("thirty", 30)])
446
+ ```
447
+
448
+ ---
449
+
450
+ ## Debug Logging
451
+
452
+ Enable debug logging to see detailed request/response information:
453
+
454
+ ```python
455
+ client = xAPI.Client(
456
+ url="https://api.example.com/v1/",
457
+ debug=True,
458
+ )
459
+ ```
460
+
461
+ This enables color-coded, structured logging for:
462
+ - Client operations (resource binding)
463
+ - HTTP requests (method, path, timing)
464
+ - Endpoint resolution
465
+ - Retry attempts
466
+
467
+ ---
468
+
469
+ ## Full Example
470
+
471
+ Here's a complete example using the CoinGecko API:
472
+
473
+ ```python
474
+ import asyncio
475
+ import xAPI
476
+
477
+ # --- Response Models ---
478
+
479
+ class Coin(xAPI.ResponseModel):
480
+ id: str
481
+ symbol: str
482
+ name: str
483
+ description: dict | None = None
484
+ market_data: dict | None = None
485
+
486
+ class CoinTickers(xAPI.ResponseModel):
487
+ name: str
488
+ tickers: list | None = None
489
+
490
+ class Category(xAPI.ResponseModel):
491
+ id: str
492
+ name: str
493
+
494
+ # --- Path Parameters ---
495
+
496
+ class CoinParams(xAPI.Parameters):
497
+ Bitcoin = "bitcoin"
498
+ Ethereum = "ethereum"
499
+ Solana = "solana"
500
+
501
+ # --- Paths ---
502
+
503
+ class CoinByID(xAPI.Path[CoinParams]):
504
+ endpointPath: str = "{id}"
505
+
506
+ class CoinTickersPath(xAPI.Path[CoinParams]):
507
+ endpointPath: str = "{id}/tickers"
508
+
509
+ class CategoriesPath(xAPI.Path):
510
+ endpointPath: str = "categories"
511
+
512
+ # --- Main ---
513
+
514
+ async def main():
515
+ auth = xAPI.APIKey(
516
+ keyName="x_cg_demo_api_key",
517
+ apiKey="YOUR_KEY",
518
+ scope=xAPI.Scope.All
519
+ )
520
+
521
+ async with xAPI.Client(
522
+ url="https://api.coingecko.com/api/v3/",
523
+ authentication=auth,
524
+ rateLimit=xAPI.RateLimiter(maxCalls=30, perSecond=60),
525
+ retry=xAPI.RetryConfig(attempts=3),
526
+ debug=True
527
+ ) as client:
528
+
529
+ # Build the resource tree
530
+ coins = xAPI.Resource("coins")
531
+ coins.addEndpoints([
532
+ xAPI.Endpoint(name="coin", path=CoinByID(), response=Coin),
533
+ xAPI.Endpoint(name="tickers", path=CoinTickersPath(), response=CoinTickers),
534
+ xAPI.Endpoint(name="categories", path=CategoriesPath(), response=list[Category]),
535
+ ])
536
+ client.add(coins)
537
+
538
+ # Fetch a coin with query parameters
539
+ query = xAPI.Query({
540
+ "localization": False,
541
+ "tickers": False,
542
+ "market_data": False,
543
+ "community_data": False,
544
+ "developer_data": False,
545
+ "sparkline": False,
546
+ })
547
+
548
+ try:
549
+ bitcoin = await client.coins.coin(
550
+ parameters=CoinParams.Bitcoin,
551
+ query=query
552
+ )
553
+ print(f"{bitcoin.name} ({bitcoin.symbol})")
554
+ print(bitcoin.toJson(indent=2))
555
+
556
+ # Fetch categories (list response)
557
+ categories = await client.coins.categories()
558
+ for cat in categories[:5]:
559
+ print(f" - {cat.name}")
560
+
561
+ except xAPI.NotFoundError:
562
+ print("Resource not found")
563
+ except xAPI.RateLimitError:
564
+ print("Rate limited")
565
+ except xAPI.APIStatusError as e:
566
+ print(f"API error: {e.status_code}")
567
+
568
+ asyncio.run(main())
569
+ ```
570
+
571
+ ---
572
+
573
+ ## API Reference
574
+
575
+ ### `xAPI.Client(url, authentication?, timeout?, rateLimit?, retry?, headers?, debug?)`
576
+
577
+ The async HTTP client. Manages connections, auth, and the resource tree.
578
+
579
+ ### `xAPI.Resource(name, prefix?, pathProtection?, requireAuth?)`
580
+
581
+ A named group of endpoints. Add to client with `client.add(resource)`.
582
+
583
+ ### `xAPI.Endpoint(name, path, response?, method?, nameOverride?, strict?)`
584
+
585
+ A single API endpoint definition.
586
+
587
+ ### `xAPI.Path[P]`
588
+
589
+ Protocol for URL path templates. Subclass and set `endpointPath`.
590
+
591
+ ### `xAPI.Parameters`
592
+
593
+ Base `StrEnum` for typed path parameters.
594
+
595
+ ### `xAPI.Query(queries)`
596
+
597
+ Query parameter builder. Filters out `"NOT_GIVEN"` values.
598
+
599
+ ### `xAPI.APIKey(keyName, apiKey, scope)`
600
+
601
+ API key authentication with configurable scope.
602
+
603
+ ### `xAPI.RateLimiter(maxCalls, perSecond)`
604
+
605
+ Sliding window rate limiter.
606
+
607
+ ### `xAPI.RetryConfig(attempts?, baseDelay?, maxDelay?)`
608
+
609
+ Exponential backoff retry configuration.
610
+
611
+ ### `xAPI.ResponseModel`
612
+
613
+ Base model for API responses. Extends Pydantic `BaseModel` with `toDict()` and `toJson()`.
614
+
615
+ ---
616
+ ## 📚 ・ xDev Utilities
617
+ This library is part of **xDev Utilities**. As set of power tool to streamline your workflow.
618
+
619
+ - **[xAPI](https://github.com/rkohl/xAPI)**: A lightweight, flexible asynchronous API client for Python built on Pydantic and httpx
620
+ - **[xEvents](https://github.com/rkohl/xEvents)**: A lightweight, thread-safe event system for Python
621
+
622
+ ---
623
+ ## License
624
+
625
+ BSD-3-Clause