ekodb-client 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ekodb_client-0.1.0/Cargo.toml +23 -0
- ekodb_client-0.1.0/PKG-INFO +461 -0
- ekodb_client-0.1.0/README.md +436 -0
- ekodb_client-0.1.0/ekodb-client-py/Cargo.lock +2556 -0
- ekodb_client-0.1.0/ekodb-client-py/Cargo.toml +23 -0
- ekodb_client-0.1.0/ekodb-client-py/DEVELOPMENT.md +84 -0
- ekodb_client-0.1.0/ekodb-client-py/README.md +436 -0
- ekodb_client-0.1.0/ekodb-client-py/python/ekodb_client/__init__.py +52 -0
- ekodb_client-0.1.0/ekodb-client-py/src/lib.rs +1216 -0
- ekodb_client-0.1.0/ekodb_client/.gitignore +14 -0
- ekodb_client-0.1.0/ekodb_client/CHANGELOG.md +40 -0
- ekodb_client-0.1.0/ekodb_client/Cargo.toml +41 -0
- ekodb_client-0.1.0/ekodb_client/DEVELOPMENT.md +241 -0
- ekodb_client-0.1.0/ekodb_client/README.md +644 -0
- ekodb_client-0.1.0/ekodb_client/src/auth.rs +148 -0
- ekodb_client-0.1.0/ekodb_client/src/batch.rs +105 -0
- ekodb_client-0.1.0/ekodb_client/src/chat.rs +464 -0
- ekodb_client-0.1.0/ekodb_client/src/client.rs +1120 -0
- ekodb_client-0.1.0/ekodb_client/src/error.rs +106 -0
- ekodb_client-0.1.0/ekodb_client/src/http.rs +1090 -0
- ekodb_client-0.1.0/ekodb_client/src/join.rs +134 -0
- ekodb_client-0.1.0/ekodb_client/src/lib.rs +77 -0
- ekodb_client-0.1.0/ekodb_client/src/query.rs +7 -0
- ekodb_client-0.1.0/ekodb_client/src/query_builder.rs +565 -0
- ekodb_client-0.1.0/ekodb_client/src/retry.rs +151 -0
- ekodb_client-0.1.0/ekodb_client/src/schema.rs +402 -0
- ekodb_client-0.1.0/ekodb_client/src/search.rs +462 -0
- ekodb_client-0.1.0/ekodb_client/src/types.rs +576 -0
- ekodb_client-0.1.0/ekodb_client/src/websocket.rs +215 -0
- ekodb_client-0.1.0/ekodb_client/tests/integration_test.rs +97 -0
- ekodb_client-0.1.0/pyproject.toml +38 -0
- ekodb_client-0.1.0/python/ekodb_client/__init__.py +52 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[workspace]
|
|
2
|
+
members = [
|
|
3
|
+
"ekodb_server",
|
|
4
|
+
"ekodb_client",
|
|
5
|
+
]
|
|
6
|
+
exclude = [
|
|
7
|
+
"examples/rust",
|
|
8
|
+
"ekodb-client-py", # Python package, built with maturin
|
|
9
|
+
]
|
|
10
|
+
resolver = "2"
|
|
11
|
+
|
|
12
|
+
[workspace.dependencies]
|
|
13
|
+
# Shared dependencies across workspace members
|
|
14
|
+
serde = { version = "1.0", features = ["derive"] }
|
|
15
|
+
serde_json = "1.0"
|
|
16
|
+
tokio = { version = "1", features = ["full"] }
|
|
17
|
+
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
|
|
18
|
+
chrono = { version = "0.4.38", features = ["serde"] }
|
|
19
|
+
rust_decimal = "1.33"
|
|
20
|
+
uuid = { version = "1.10.0", features = ["serde", "v4"] }
|
|
21
|
+
thiserror = "1.0"
|
|
22
|
+
base64 = "0.22.1"
|
|
23
|
+
log = "0.4"
|
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ekodb_client
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Classifier: Development Status :: 4 - Beta
|
|
5
|
+
Classifier: Intended Audience :: Developers
|
|
6
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
7
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Rust
|
|
15
|
+
Summary: Python client for ekoDB - a high-performance document database
|
|
16
|
+
Keywords: database,ekodb,document-database,nosql
|
|
17
|
+
Author: ekoDB Team
|
|
18
|
+
License: MIT OR Apache-2.0
|
|
19
|
+
Requires-Python: >=3.8
|
|
20
|
+
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
|
|
21
|
+
Project-URL: Homepage, https://github.com/ekoDB/ekodb
|
|
22
|
+
Project-URL: Repository, https://github.com/ekoDB/ekodb
|
|
23
|
+
Project-URL: Documentation, https://github.com/ekoDB/ekodb/tree/main/documentation
|
|
24
|
+
|
|
25
|
+
# ekoDB Python Client
|
|
26
|
+
|
|
27
|
+
High-performance Python client for ekoDB, built with Rust for speed and safety.
|
|
28
|
+
|
|
29
|
+
This package wraps the `ekodb_client` Rust library using PyO3 to provide a
|
|
30
|
+
native Python interface.
|
|
31
|
+
|
|
32
|
+
## Features
|
|
33
|
+
|
|
34
|
+
- ✅ **Fast**: Built with Rust, leveraging the same client library as the Rust
|
|
35
|
+
SDK
|
|
36
|
+
- ✅ **Type-safe**: Strong typing with Python type hints
|
|
37
|
+
- ✅ **Async/await**: Full async support using Python's asyncio
|
|
38
|
+
- ✅ **Easy to use**: Pythonic API that feels natural
|
|
39
|
+
- ✅ **Complete**: All ekoDB features supported
|
|
40
|
+
- ✅ **Query Builder** - Fluent API for complex queries with operators, sorting,
|
|
41
|
+
and pagination
|
|
42
|
+
- ✅ **Search** - Full-text search, fuzzy search, and field-specific search with
|
|
43
|
+
scoring
|
|
44
|
+
- ✅ **Schema Management** - Define and enforce data schemas with validation
|
|
45
|
+
- ✅ **Join Operations** - Single and multi-collection joins with queries
|
|
46
|
+
- ✅ **Rate limiting with automatic retry** (429, 503, network errors)
|
|
47
|
+
- ✅ **Rate limit tracking** (`X-RateLimit-*` headers)
|
|
48
|
+
- ✅ **Configurable retry behavior**
|
|
49
|
+
- ✅ **Retry-After header support**
|
|
50
|
+
|
|
51
|
+
## Installation
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pip install ekodb
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Or install from source:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
cd ekodb-py
|
|
61
|
+
pip install maturin
|
|
62
|
+
maturin develop
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Quick Start
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
import asyncio
|
|
69
|
+
from ekodb_client import Client, RateLimitError
|
|
70
|
+
|
|
71
|
+
async def main():
|
|
72
|
+
# Create client with configuration
|
|
73
|
+
client = Client.new(
|
|
74
|
+
"http://localhost:8080",
|
|
75
|
+
"your-api-key",
|
|
76
|
+
should_retry=True, # Enable automatic retries (default: True)
|
|
77
|
+
max_retries=3, # Maximum retry attempts (default: 3)
|
|
78
|
+
timeout_secs=30 # Request timeout in seconds (default: 30)
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
# Insert a document
|
|
83
|
+
record = await client.insert("users", {
|
|
84
|
+
"name": "John Doe",
|
|
85
|
+
"age": 30,
|
|
86
|
+
"email": "john@example.com",
|
|
87
|
+
"active": True
|
|
88
|
+
})
|
|
89
|
+
print(f"Inserted: {record['id']}")
|
|
90
|
+
|
|
91
|
+
# Find by ID
|
|
92
|
+
user = await client.find_by_id("users", record["id"])
|
|
93
|
+
print(f"Found: {user}")
|
|
94
|
+
|
|
95
|
+
# Find with query
|
|
96
|
+
results = await client.find("users", limit=10)
|
|
97
|
+
print(f"Found {len(results)} users")
|
|
98
|
+
|
|
99
|
+
# Update
|
|
100
|
+
updated = await client.update("users", record["id"], {
|
|
101
|
+
"age": 31
|
|
102
|
+
})
|
|
103
|
+
print(f"Updated: {updated}")
|
|
104
|
+
|
|
105
|
+
# Delete
|
|
106
|
+
await client.delete("users", record["id"])
|
|
107
|
+
print("Deleted")
|
|
108
|
+
|
|
109
|
+
except RateLimitError as e:
|
|
110
|
+
print(f"Rate limited! Retry after {e.retry_after_secs} seconds")
|
|
111
|
+
|
|
112
|
+
asyncio.run(main())
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Usage Examples
|
|
116
|
+
|
|
117
|
+
### Query Builder
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
from ekodb_client import Client, QueryBuilder
|
|
121
|
+
|
|
122
|
+
async def main():
|
|
123
|
+
client = Client.new("http://localhost:8080", "your-api-key")
|
|
124
|
+
|
|
125
|
+
# Simple query with operators
|
|
126
|
+
query = QueryBuilder() \
|
|
127
|
+
.eq("status", "active") \
|
|
128
|
+
.gte("age", 18) \
|
|
129
|
+
.lt("age", 65) \
|
|
130
|
+
.limit(10) \
|
|
131
|
+
.build()
|
|
132
|
+
|
|
133
|
+
results = await client.find("users", query)
|
|
134
|
+
|
|
135
|
+
# Complex query with sorting and pagination
|
|
136
|
+
query = QueryBuilder() \
|
|
137
|
+
.in_array("status", ["active", "pending"]) \
|
|
138
|
+
.contains("email", "@example.com") \
|
|
139
|
+
.sort_desc("created_at") \
|
|
140
|
+
.skip(20) \
|
|
141
|
+
.limit(10) \
|
|
142
|
+
.build()
|
|
143
|
+
|
|
144
|
+
results = await client.find("users", query)
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### Search Operations
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
# Basic text search
|
|
151
|
+
search_query = {
|
|
152
|
+
"query": "programming",
|
|
153
|
+
"min_score": 0.1,
|
|
154
|
+
"limit": 10
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
results = await client.search("articles", search_query)
|
|
158
|
+
for result in results["results"]:
|
|
159
|
+
print(f"Score: {result['score']:.4f} - {result['record']['title']}")
|
|
160
|
+
|
|
161
|
+
# Search with field weights
|
|
162
|
+
search_query = {
|
|
163
|
+
"query": "rust database",
|
|
164
|
+
"fields": ["title", "description"],
|
|
165
|
+
"weights": {"title": 2.0},
|
|
166
|
+
"limit": 5
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
results = await client.search("articles", search_query)
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### Schema Management
|
|
173
|
+
|
|
174
|
+
```python
|
|
175
|
+
# Create a collection with schema
|
|
176
|
+
schema = {
|
|
177
|
+
"fields": {
|
|
178
|
+
"name": {
|
|
179
|
+
"field_type": "String",
|
|
180
|
+
"required": True,
|
|
181
|
+
"regex": "^[a-zA-Z ]+$"
|
|
182
|
+
},
|
|
183
|
+
"email": {
|
|
184
|
+
"field_type": "String",
|
|
185
|
+
"required": True,
|
|
186
|
+
"unique": True
|
|
187
|
+
},
|
|
188
|
+
"age": {
|
|
189
|
+
"field_type": "Integer",
|
|
190
|
+
"min": 0,
|
|
191
|
+
"max": 150
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
await client.create_collection("users", schema)
|
|
197
|
+
|
|
198
|
+
# Get collection schema
|
|
199
|
+
schema = await client.get_schema("users")
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
### Join Operations
|
|
203
|
+
|
|
204
|
+
```python
|
|
205
|
+
# Single collection join
|
|
206
|
+
query = {
|
|
207
|
+
"$join": {
|
|
208
|
+
"collection": "departments",
|
|
209
|
+
"local_field": "department_id",
|
|
210
|
+
"foreign_field": "id",
|
|
211
|
+
"as": "department"
|
|
212
|
+
},
|
|
213
|
+
"$limit": 10
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
results = await client.find("users", query)
|
|
217
|
+
|
|
218
|
+
# Multi-collection join
|
|
219
|
+
query = {
|
|
220
|
+
"$join": [
|
|
221
|
+
{
|
|
222
|
+
"collection": "departments",
|
|
223
|
+
"local_field": "department_id",
|
|
224
|
+
"foreign_field": "id",
|
|
225
|
+
"as": "department"
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
"collection": "profiles",
|
|
229
|
+
"local_field": "id",
|
|
230
|
+
"foreign_field": "id",
|
|
231
|
+
"as": "profile"
|
|
232
|
+
}
|
|
233
|
+
],
|
|
234
|
+
"$limit": 10
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
results = await client.find("users", query)
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
## API Reference
|
|
241
|
+
|
|
242
|
+
### Client
|
|
243
|
+
|
|
244
|
+
#### `Client.new(base_url: str, api_key: str, should_retry: bool = True, max_retries: int = 3, timeout_secs: int = 30) -> Client`
|
|
245
|
+
|
|
246
|
+
Create a new ekoDB client.
|
|
247
|
+
|
|
248
|
+
**Parameters:**
|
|
249
|
+
|
|
250
|
+
- `base_url`: The base URL of the ekoDB server
|
|
251
|
+
- `api_key`: Your API key
|
|
252
|
+
- `should_retry`: Enable automatic retries (default: True)
|
|
253
|
+
- `max_retries`: Maximum number of retry attempts (default: 3)
|
|
254
|
+
- `timeout_secs`: Request timeout in seconds (default: 30)
|
|
255
|
+
|
|
256
|
+
**Returns:**
|
|
257
|
+
|
|
258
|
+
- A new `Client` instance
|
|
259
|
+
|
|
260
|
+
### RateLimitInfo
|
|
261
|
+
|
|
262
|
+
Rate limit information is automatically tracked and logged by the client. The
|
|
263
|
+
client will automatically retry on rate limit errors using the server's
|
|
264
|
+
`Retry-After` header.
|
|
265
|
+
|
|
266
|
+
#### Properties
|
|
267
|
+
|
|
268
|
+
- `limit: int` - Maximum requests allowed per window
|
|
269
|
+
- `remaining: int` - Requests remaining in current window
|
|
270
|
+
- `reset: int` - Unix timestamp when the rate limit resets
|
|
271
|
+
|
|
272
|
+
#### Methods
|
|
273
|
+
|
|
274
|
+
- `is_near_limit() -> bool` - Check if approaching rate limit (<10% remaining)
|
|
275
|
+
- `is_exceeded() -> bool` - Check if the rate limit has been exceeded
|
|
276
|
+
- `remaining_percentage() -> float` - Get the percentage of requests remaining
|
|
277
|
+
|
|
278
|
+
### RateLimitError
|
|
279
|
+
|
|
280
|
+
Exception raised when rate limit is exceeded (if retries are disabled or
|
|
281
|
+
exhausted).
|
|
282
|
+
|
|
283
|
+
#### Properties
|
|
284
|
+
|
|
285
|
+
- `retry_after_secs: int` - Number of seconds to wait before retrying
|
|
286
|
+
|
|
287
|
+
#### `await client.insert(collection: str, record: dict) -> dict`
|
|
288
|
+
|
|
289
|
+
Insert a document into a collection.
|
|
290
|
+
|
|
291
|
+
**Parameters:**
|
|
292
|
+
|
|
293
|
+
- `collection`: The collection name
|
|
294
|
+
- `record`: A dictionary representing the document
|
|
295
|
+
|
|
296
|
+
**Returns:**
|
|
297
|
+
|
|
298
|
+
- The inserted document with ID
|
|
299
|
+
|
|
300
|
+
#### `await client.find_by_id(collection: str, id: str) -> dict`
|
|
301
|
+
|
|
302
|
+
Find a document by ID.
|
|
303
|
+
|
|
304
|
+
**Parameters:**
|
|
305
|
+
|
|
306
|
+
- `collection`: The collection name
|
|
307
|
+
- `id`: The document ID
|
|
308
|
+
|
|
309
|
+
**Returns:**
|
|
310
|
+
|
|
311
|
+
- The found document
|
|
312
|
+
|
|
313
|
+
#### `await client.find(collection: str, limit: Optional[int] = None) -> List[dict]`
|
|
314
|
+
|
|
315
|
+
Find documents in a collection.
|
|
316
|
+
|
|
317
|
+
**Parameters:**
|
|
318
|
+
|
|
319
|
+
- `collection`: The collection name
|
|
320
|
+
- `limit`: Optional limit on number of results
|
|
321
|
+
|
|
322
|
+
**Returns:**
|
|
323
|
+
|
|
324
|
+
- List of matching documents
|
|
325
|
+
|
|
326
|
+
#### `await client.update(collection: str, id: str, updates: dict) -> dict`
|
|
327
|
+
|
|
328
|
+
Update a document.
|
|
329
|
+
|
|
330
|
+
**Parameters:**
|
|
331
|
+
|
|
332
|
+
- `collection`: The collection name
|
|
333
|
+
- `id`: The document ID
|
|
334
|
+
- `updates`: Dictionary of fields to update
|
|
335
|
+
|
|
336
|
+
**Returns:**
|
|
337
|
+
|
|
338
|
+
- The updated document
|
|
339
|
+
|
|
340
|
+
#### `await client.delete(collection: str, id: str) -> None`
|
|
341
|
+
|
|
342
|
+
Delete a document.
|
|
343
|
+
|
|
344
|
+
**Parameters:**
|
|
345
|
+
|
|
346
|
+
- `collection`: The collection name
|
|
347
|
+
- `id`: The document ID
|
|
348
|
+
|
|
349
|
+
#### `await client.list_collections() -> List[str]`
|
|
350
|
+
|
|
351
|
+
List all collections.
|
|
352
|
+
|
|
353
|
+
**Returns:**
|
|
354
|
+
|
|
355
|
+
- List of collection names
|
|
356
|
+
|
|
357
|
+
#### `await client.delete_collection(collection: str) -> None`
|
|
358
|
+
|
|
359
|
+
Delete a collection.
|
|
360
|
+
|
|
361
|
+
**Parameters:**
|
|
362
|
+
|
|
363
|
+
- `collection`: The collection name to delete
|
|
364
|
+
|
|
365
|
+
#### `await client.search(collection: str, query: dict) -> dict`
|
|
366
|
+
|
|
367
|
+
Perform full-text search on a collection.
|
|
368
|
+
|
|
369
|
+
**Parameters:**
|
|
370
|
+
|
|
371
|
+
- `collection`: The collection name
|
|
372
|
+
- `query`: Search query dictionary with fields like `query`, `fields`,
|
|
373
|
+
`weights`, `min_score`, `limit`
|
|
374
|
+
|
|
375
|
+
**Returns:**
|
|
376
|
+
|
|
377
|
+
- Search results with scores and matched records
|
|
378
|
+
|
|
379
|
+
#### `await client.create_collection(collection: str, schema: dict) -> None`
|
|
380
|
+
|
|
381
|
+
Create a collection with a schema.
|
|
382
|
+
|
|
383
|
+
**Parameters:**
|
|
384
|
+
|
|
385
|
+
- `collection`: The collection name
|
|
386
|
+
- `schema`: Schema definition dictionary
|
|
387
|
+
|
|
388
|
+
#### `await client.get_schema(collection: str) -> dict`
|
|
389
|
+
|
|
390
|
+
Get the schema for a collection.
|
|
391
|
+
|
|
392
|
+
**Parameters:**
|
|
393
|
+
|
|
394
|
+
- `collection`: The collection name
|
|
395
|
+
|
|
396
|
+
**Returns:**
|
|
397
|
+
|
|
398
|
+
- Schema definition dictionary
|
|
399
|
+
|
|
400
|
+
#### `await client.get_collection(collection: str) -> dict`
|
|
401
|
+
|
|
402
|
+
Get collection metadata including schema.
|
|
403
|
+
|
|
404
|
+
**Parameters:**
|
|
405
|
+
|
|
406
|
+
- `collection`: The collection name
|
|
407
|
+
|
|
408
|
+
**Returns:**
|
|
409
|
+
|
|
410
|
+
- Collection metadata dictionary
|
|
411
|
+
|
|
412
|
+
## Examples
|
|
413
|
+
|
|
414
|
+
See the
|
|
415
|
+
[examples directory](https://github.com/ekoDB/ekodb/tree/main/examples/python)
|
|
416
|
+
for complete working examples:
|
|
417
|
+
|
|
418
|
+
- `client_simple_crud.py` - Basic CRUD operations
|
|
419
|
+
- `client_query_builder.py` - Complex queries with QueryBuilder
|
|
420
|
+
- `client_search.py` - Full-text search operations
|
|
421
|
+
- `client_schema.py` - Schema management
|
|
422
|
+
- `client_joins.py` - Join operations
|
|
423
|
+
- `client_batch_operations.py` - Batch operations
|
|
424
|
+
- `client_kv_operations.py` - Key-value operations
|
|
425
|
+
- And more...
|
|
426
|
+
|
|
427
|
+
## Development
|
|
428
|
+
|
|
429
|
+
### Building
|
|
430
|
+
|
|
431
|
+
```bash
|
|
432
|
+
# Install maturin
|
|
433
|
+
pip install maturin
|
|
434
|
+
|
|
435
|
+
# Build and install in development mode
|
|
436
|
+
maturin develop
|
|
437
|
+
|
|
438
|
+
# Build release wheel
|
|
439
|
+
maturin build --release
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
### Testing
|
|
443
|
+
|
|
444
|
+
```bash
|
|
445
|
+
# Run Python tests
|
|
446
|
+
pytest
|
|
447
|
+
|
|
448
|
+
# Run with coverage
|
|
449
|
+
pytest --cov=ekodb
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
## License
|
|
453
|
+
|
|
454
|
+
MIT OR Apache-2.0
|
|
455
|
+
|
|
456
|
+
## Links
|
|
457
|
+
|
|
458
|
+
- [GitHub](https://github.com/ekoDB/ekodb)
|
|
459
|
+
- [Documentation](https://github.com/ekoDB/ekodb/tree/main/documentation)
|
|
460
|
+
- [Examples](https://github.com/ekoDB/ekodb/tree/main/examples/python)
|
|
461
|
+
|