wirexaapi 1.0.0__tar.gz → 1.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.
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wirexaapi
3
- Version: 1.0.0
4
- Summary: Wirexa API SDK - SignalWire/Twilio compatible voice API client
3
+ Version: 1.1.0
4
+ Summary: Wirexa API SDK - voice API client (SignalWire/Twilio compatible) and Wirexa DB document database client
5
5
  Author-email: Wirexa Team <dev@wirexa.io>
6
6
  License: MIT
7
7
  Project-URL: Homepage, https://github.com/wirexa/wirexa-api
@@ -84,3 +84,35 @@ The rest of your code stays the same!
84
84
  ## License
85
85
 
86
86
  MIT
87
+
88
+
89
+ ## Wirexa DB (base de datos de documentos)
90
+
91
+ ```python
92
+ from wirexaapi import MongoClient
93
+
94
+ client = MongoClient(
95
+ "wirexaapi+srv://usuario:password@cluster0-abc1234.wirexaapi.com/?appName=cluster0",
96
+ serverSelectionTimeoutMS=5000,
97
+ connectTimeoutMS=5000,
98
+ socketTimeoutMS=20000,
99
+ )
100
+ db = client["miapp"]
101
+ users = db["users"] # las colecciones se crean solas
102
+
103
+ users.insert_one({"name": "Ana", "age": 30})
104
+ users.find_one({"name": "Ana"})
105
+ for u in users.find({"age": {"$gt": 25}}).sort("name").limit(10):
106
+ print(u)
107
+ users.update_one({"name": "Ana"}, {"$set": {"age": 31}})
108
+ users.create_index("email", unique=True)
109
+ ```
110
+
111
+ Soporta filtros (`$eq $ne $gt $gte $lt $lte $in $nin $exists $regex $and $or $nor $not $size $all $elemMatch`),
112
+ updates (`$set $unset $inc $mul $min $max $push $addToSet $pull $pop $rename $currentDate $setOnInsert`), upserts,
113
+ `find_one_and_*`, índices (incluidos únicos), `distinct`, `count_documents` y `aggregate`
114
+ (`$match $sort $skip $limit $project $addFields $unset $unwind $group $count $lookup`).
115
+
116
+ Si tu backend corre en la misma VPS que el cluster, usa la URI local que da el panel
117
+ (`wirexaapi://...@127.0.0.1:27501/?cluster=<id>&tls=false`): ~1 ms por consulta.
118
+ Instala `wirexaapi[srv]` para resolver el registro SRV del cluster.
@@ -0,0 +1,96 @@
1
+ # Wirexa API SDK
2
+
3
+ A production-ready Python SDK for Wirexa API - a self-hosted telephony platform compatible with SignalWire and Twilio REST API.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install wirexaapi
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```python
14
+ from wirexaapi import Client
15
+
16
+ # Connect to your Wirexa API instance
17
+ client = Client(
18
+ project_id="your-project-id",
19
+ api_token="your-api-token",
20
+ signalwire_space_url="your-space.example.com:5000"
21
+ )
22
+
23
+ # Make a call
24
+ call = client.calls.create(
25
+ to="+573001234567",
26
+ from_="+18005550100",
27
+ url="https://your-backend.com/xml",
28
+ method="POST",
29
+ timeout=28,
30
+ record=True,
31
+ status_callback="https://your-backend.com/status",
32
+ status_callback_event=["initiated", "ringing", "completed"],
33
+ )
34
+
35
+ print(f"Call SID: {call.sid}")
36
+ print(f"Call Status: {call.status}")
37
+ ```
38
+
39
+ ## Migration from SignalWire
40
+
41
+ If you're migrating from SignalWire SDK:
42
+
43
+ ```python
44
+ # Before
45
+ from signalwire.rest import Client as SignalWireClient
46
+
47
+ # After
48
+ from wirexaapi import Client as SignalWireClient
49
+ ```
50
+
51
+ The rest of your code stays the same!
52
+
53
+ ## Features
54
+
55
+ - Full SignalWire/Twilio API compatibility
56
+ - Call creation, retrieval, and listing
57
+ - Machine Detection (AMD)
58
+ - Recording
59
+ - Status callbacks
60
+ - Async AMD support
61
+
62
+ ## License
63
+
64
+ MIT
65
+
66
+
67
+ ## Wirexa DB (base de datos de documentos)
68
+
69
+ ```python
70
+ from wirexaapi import MongoClient
71
+
72
+ client = MongoClient(
73
+ "wirexaapi+srv://usuario:password@cluster0-abc1234.wirexaapi.com/?appName=cluster0",
74
+ serverSelectionTimeoutMS=5000,
75
+ connectTimeoutMS=5000,
76
+ socketTimeoutMS=20000,
77
+ )
78
+ db = client["miapp"]
79
+ users = db["users"] # las colecciones se crean solas
80
+
81
+ users.insert_one({"name": "Ana", "age": 30})
82
+ users.find_one({"name": "Ana"})
83
+ for u in users.find({"age": {"$gt": 25}}).sort("name").limit(10):
84
+ print(u)
85
+ users.update_one({"name": "Ana"}, {"$set": {"age": 31}})
86
+ users.create_index("email", unique=True)
87
+ ```
88
+
89
+ Soporta filtros (`$eq $ne $gt $gte $lt $lte $in $nin $exists $regex $and $or $nor $not $size $all $elemMatch`),
90
+ updates (`$set $unset $inc $mul $min $max $push $addToSet $pull $pop $rename $currentDate $setOnInsert`), upserts,
91
+ `find_one_and_*`, índices (incluidos únicos), `distinct`, `count_documents` y `aggregate`
92
+ (`$match $sort $skip $limit $project $addFields $unset $unwind $group $count $lookup`).
93
+
94
+ Si tu backend corre en la misma VPS que el cluster, usa la URI local que da el panel
95
+ (`wirexaapi://...@127.0.0.1:27501/?cluster=<id>&tls=false`): ~1 ms por consulta.
96
+ Instala `wirexaapi[srv]` para resolver el registro SRV del cluster.
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "wirexaapi"
7
- version = "1.0.0"
8
- description = "Wirexa API SDK - SignalWire/Twilio compatible voice API client"
7
+ version = "1.1.0"
8
+ description = "Wirexa API SDK - voice API client (SignalWire/Twilio compatible) and Wirexa DB document database client"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
11
11
  authors = [
@@ -0,0 +1,38 @@
1
+ """
2
+ Wirexa API SDK - SDK compatible con la API de voz WirexaAPI.
3
+
4
+ CREDENCIALES (las obtienes del dashboard):
5
+ space_url = "your-space.example.com:5000"
6
+ project_id = "your-project-id"
7
+ api_token = "your-api-token"
8
+
9
+ USO:
10
+
11
+ ANTES (SignalWire original):
12
+ from signalwire.rest import Client
13
+ client = Client(project_id, api_token, signalwire_space_url=space_url)
14
+
15
+ DESPUÉS (Wirexa API):
16
+ from wirexaapi import Client
17
+ client = Client(project_id, api_token, signalwire_space_url=space_url)
18
+
19
+ El resto del código NO cambia.
20
+
21
+ BASE DE DATOS (Wirexa DB):
22
+
23
+ from wirexaapi import MongoClient
24
+ client = MongoClient("wirexaapi+srv://user:pass@cluster0-abc1234.wirexaapi.com/")
25
+ client["miapp"]["users"].insert_one({"name": "Ana"})
26
+ """
27
+ from wirexaapi.rest import Client
28
+ from wirexaapi.db import (
29
+ WirexaClient, MongoClient, ASCENDING, DESCENDING, ReturnDocument, ObjectId,
30
+ WirexaDBError, OperationFailure, DuplicateKeyError, ConnectionFailure,
31
+ ServerSelectionTimeoutError,
32
+ )
33
+
34
+ __all__ = [
35
+ "Client", "WirexaClient", "MongoClient", "ASCENDING", "DESCENDING", "ReturnDocument", "ObjectId",
36
+ "WirexaDBError", "OperationFailure", "DuplicateKeyError", "ConnectionFailure",
37
+ "ServerSelectionTimeoutError",
38
+ ]
@@ -0,0 +1,665 @@
1
+ """
2
+ Wirexa DB - cliente de la base de datos de documentos de Wirexa.
3
+
4
+ USO:
5
+
6
+ from wirexaapi import MongoClient # alias de WirexaClient
7
+
8
+ client = MongoClient(
9
+ "wirexaapi+srv://usuario:password@cluster0-abc1234.wirexaapi.com/?appName=cluster0",
10
+ serverSelectionTimeoutMS=5000,
11
+ connectTimeoutMS=5000,
12
+ socketTimeoutMS=20000,
13
+ )
14
+ db = client["miapp"]
15
+ users = db["users"] # o db.users; la colección nace sola
16
+
17
+ users.insert_one({"name": "Ana", "age": 30})
18
+ users.find_one({"name": "Ana"})
19
+ for u in users.find({"age": {"$gt": 25}}).sort("name").limit(10):
20
+ print(u)
21
+ users.update_one({"name": "Ana"}, {"$set": {"age": 31}})
22
+ users.create_index("email", unique=True)
23
+
24
+ Si tu backend corre en la misma VPS que el cluster usa la URI local
25
+ (wirexaapi://usuario:password@127.0.0.1:27501/?cluster=<id>&tls=false):
26
+ evita TLS y la red, y responde en ~1 ms.
27
+ """
28
+ import base64
29
+ import datetime as _dt
30
+ import decimal
31
+ import http.client
32
+ import json
33
+ import re
34
+ import socket
35
+ import ssl
36
+ import threading
37
+ from urllib.parse import parse_qs, unquote, urlsplit
38
+
39
+ __all__ = [
40
+ "WirexaClient", "MongoClient", "Database", "Collection", "Cursor",
41
+ "ASCENDING", "DESCENDING", "ReturnDocument", "ObjectId",
42
+ "WirexaDBError", "PyMongoError", "OperationFailure", "DuplicateKeyError",
43
+ "ConnectionFailure", "ServerSelectionTimeoutError", "ConfigurationError",
44
+ ]
45
+
46
+ ASCENDING = 1
47
+ DESCENDING = -1
48
+ DEFAULT_PORT = 443 # vía nginx; pasa por cualquier firewall
49
+ LOCAL_PORT = 27501 # backends en la misma VPS (sin TLS ni red)
50
+ _PATH = "/_wdb/v1/op"
51
+
52
+
53
+ class ReturnDocument:
54
+ BEFORE = False
55
+ AFTER = True
56
+
57
+
58
+ class ObjectId(str):
59
+ """Los _id generados por el servidor son cadenas de 24 hex (mismo esquema
60
+ que un ObjectId: ordenan por fecha). Esta clase existe por compatibilidad:
61
+ ObjectId("...") devuelve la misma cadena."""
62
+
63
+ def __new__(cls, oid=None):
64
+ if oid is None:
65
+ raise ValueError("ObjectId() sin argumento: deja que el servidor genere el _id")
66
+ return str.__new__(cls, str(oid))
67
+
68
+ @property
69
+ def generation_time(self):
70
+ return _dt.datetime.fromtimestamp(int(self[:8], 16), tz=_dt.timezone.utc)
71
+
72
+
73
+ # ------------------------------------------------------------------ errores
74
+
75
+ class WirexaDBError(Exception):
76
+ pass
77
+
78
+
79
+ PyMongoError = WirexaDBError # mismo nombre que pymongo para except existentes
80
+
81
+
82
+ class ConfigurationError(WirexaDBError):
83
+ pass
84
+
85
+
86
+ class ConnectionFailure(WirexaDBError):
87
+ pass
88
+
89
+
90
+ class ServerSelectionTimeoutError(ConnectionFailure):
91
+ pass
92
+
93
+
94
+ class OperationFailure(WirexaDBError):
95
+ def __init__(self, message, code=None, details=None):
96
+ super().__init__(message)
97
+ self.code = code
98
+ self.details = details or {}
99
+
100
+ @property
101
+ def code_name(self):
102
+ return self.details.get("codeName")
103
+
104
+
105
+ class DuplicateKeyError(OperationFailure):
106
+ pass
107
+
108
+
109
+ # ------------------------------------------------------------------ JSON extendido
110
+
111
+ def _encode(v):
112
+ if isinstance(v, dict):
113
+ return {str(k): _encode(x) for k, x in v.items()}
114
+ if isinstance(v, (list, tuple, set)):
115
+ return [_encode(x) for x in v]
116
+ if isinstance(v, _dt.datetime):
117
+ if v.tzinfo is None:
118
+ v = v.replace(tzinfo=_dt.timezone.utc)
119
+ v = v.astimezone(_dt.timezone.utc)
120
+ return {"$date": v.strftime("%Y-%m-%dT%H:%M:%S.") + "%03dZ" % (v.microsecond // 1000)}
121
+ if isinstance(v, _dt.date):
122
+ return _encode(_dt.datetime(v.year, v.month, v.day, tzinfo=_dt.timezone.utc))
123
+ if isinstance(v, (bytes, bytearray)):
124
+ return {"$binary": base64.b64encode(bytes(v)).decode()}
125
+ if isinstance(v, decimal.Decimal):
126
+ return {"$numberDecimal": str(v)}
127
+ if isinstance(v, re.Pattern):
128
+ opts = ""
129
+ if v.flags & re.IGNORECASE:
130
+ opts += "i"
131
+ if v.flags & re.MULTILINE:
132
+ opts += "m"
133
+ if v.flags & re.DOTALL:
134
+ opts += "s"
135
+ return {"$regex": v.pattern, "$options": opts}
136
+ return v
137
+
138
+
139
+ def _decode_hook(d):
140
+ if len(d) == 1:
141
+ if "$date" in d:
142
+ s = d["$date"]
143
+ try:
144
+ return _dt.datetime.strptime(s, "%Y-%m-%dT%H:%M:%S.%fZ").replace(tzinfo=_dt.timezone.utc)
145
+ except (TypeError, ValueError):
146
+ return d
147
+ if "$binary" in d:
148
+ try:
149
+ return base64.b64decode(d["$binary"])
150
+ except Exception:
151
+ return d
152
+ if "$numberDecimal" in d:
153
+ return decimal.Decimal(d["$numberDecimal"])
154
+ return d
155
+
156
+
157
+ def _sort_spec(key_or_list, direction=None):
158
+ """Acepta los mismos formatos que pymongo: "campo", ("campo", -1),
159
+ [("a", 1), ("b", -1)] o {"a": 1}."""
160
+ if key_or_list is None:
161
+ return None
162
+ if isinstance(key_or_list, str):
163
+ return [[key_or_list, direction if direction is not None else ASCENDING]]
164
+ if isinstance(key_or_list, dict):
165
+ return [[k, v] for k, v in key_or_list.items()]
166
+ if isinstance(key_or_list, tuple) and len(key_or_list) == 2 and isinstance(key_or_list[0], str) \
167
+ and isinstance(key_or_list[1], int):
168
+ return [list(key_or_list)]
169
+ out = []
170
+ for item in key_or_list:
171
+ if isinstance(item, str):
172
+ out.append([item, ASCENDING])
173
+ else:
174
+ out.append([item[0], item[1]])
175
+ return out
176
+
177
+
178
+ def _projection(p):
179
+ if p is None:
180
+ return None
181
+ if isinstance(p, (list, tuple)):
182
+ return {k: 1 for k in p}
183
+ return p
184
+
185
+
186
+ # ------------------------------------------------------------------ resultados
187
+
188
+ class InsertOneResult:
189
+ def __init__(self, inserted_id):
190
+ self.inserted_id = inserted_id
191
+ self.acknowledged = True
192
+
193
+ def __repr__(self):
194
+ return "InsertOneResult(%r)" % (self.inserted_id,)
195
+
196
+
197
+ class InsertManyResult:
198
+ def __init__(self, inserted_ids):
199
+ self.inserted_ids = inserted_ids
200
+ self.acknowledged = True
201
+
202
+
203
+ class UpdateResult:
204
+ def __init__(self, r):
205
+ self.matched_count = r.get("matchedCount", 0)
206
+ self.modified_count = r.get("modifiedCount", 0)
207
+ self.upserted_id = r.get("upsertedId")
208
+ self.raw_result = r
209
+ self.acknowledged = True
210
+
211
+ def __repr__(self):
212
+ return "UpdateResult(matched=%d, modified=%d, upserted_id=%r)" % (
213
+ self.matched_count, self.modified_count, self.upserted_id)
214
+
215
+
216
+ class DeleteResult:
217
+ def __init__(self, r):
218
+ self.deleted_count = r.get("deletedCount", 0)
219
+ self.raw_result = r
220
+ self.acknowledged = True
221
+
222
+ def __repr__(self):
223
+ return "DeleteResult(deleted=%d)" % self.deleted_count
224
+
225
+
226
+ # ------------------------------------------------------------------ cliente
227
+
228
+ def _parse_uri(uri):
229
+ parts = urlsplit(uri)
230
+ scheme = parts.scheme.lower()
231
+ if scheme not in ("wirexaapi", "wirexaapi+srv", "wirexa", "wirexa+srv"):
232
+ raise ConfigurationError("la URI debe empezar con wirexaapi:// o wirexaapi+srv://")
233
+ if not parts.hostname:
234
+ raise ConfigurationError("la URI no tiene host")
235
+ q = {k: v[-1] for k, v in parse_qs(parts.query).items()}
236
+ host = parts.hostname
237
+ label = host.split(".")[0]
238
+ cluster = q.get("cluster") or (label.rsplit("-", 1)[1] if "-" in label else "")
239
+ if not cluster:
240
+ raise ConfigurationError("no se pudo deducir el id del cluster; añade ?cluster=<id>")
241
+ local = host in ("127.0.0.1", "localhost", "::1")
242
+ tls = q.get("tls", q.get("ssl", "false" if local else "true")).lower() != "false"
243
+ port = parts.port
244
+ if port is None:
245
+ port = LOCAL_PORT if local and not tls else DEFAULT_PORT
246
+ if scheme.endswith("+srv"):
247
+ port = _resolve_srv(host) or port
248
+ return {
249
+ "username": unquote(parts.username or ""),
250
+ "password": unquote(parts.password or ""),
251
+ "host": host, "port": port, "tls": tls, "cluster": cluster,
252
+ "appName": q.get("appName", ""), "options": q,
253
+ }
254
+
255
+
256
+ def _resolve_srv(host):
257
+ """Resuelve _wirexadb._tcp.<host> si dnspython está instalado; si no,
258
+ se usa el puerto por defecto (27500)."""
259
+ try:
260
+ import dns.resolver # type: ignore
261
+ except ImportError:
262
+ return None
263
+ try:
264
+ ans = dns.resolver.resolve("_wirexadb._tcp." + host, "SRV", lifetime=3)
265
+ best = sorted(ans, key=lambda r: (r.priority, -r.weight))[0]
266
+ return int(best.port)
267
+ except Exception:
268
+ return None
269
+
270
+
271
+ class WirexaClient:
272
+ """Cliente de Wirexa DB. Thread-safe: una conexión keep-alive por hilo."""
273
+
274
+ def __init__(self, host=None, serverSelectionTimeoutMS=30000, connectTimeoutMS=20000,
275
+ socketTimeoutMS=None, maxPoolSize=100, connect=True, tlsCAFile=None, **kwargs):
276
+ if host is None:
277
+ raise ConfigurationError("falta la URI del cluster")
278
+ cfg = _parse_uri(host)
279
+ if not cfg["username"]:
280
+ raise ConfigurationError("la URI no incluye usuario y contraseña")
281
+ self._cfg = cfg
282
+ self._connect_timeout = min(connectTimeoutMS, serverSelectionTimeoutMS) / 1000.0
283
+ self._read_timeout = (socketTimeoutMS / 1000.0) if socketTimeoutMS else 60.0
284
+ self._ssl = ssl.create_default_context(cafile=tlsCAFile) if cfg["tls"] else None
285
+ token = base64.b64encode(("%s:%s" % (cfg["username"], cfg["password"])).encode()).decode()
286
+ self._headers = {
287
+ "Authorization": "Basic " + token,
288
+ "X-Wirexa-Cluster": cfg["cluster"],
289
+ "Content-Type": "application/json",
290
+ "User-Agent": "wirexaapi-py/db " + (cfg["appName"] or "-"),
291
+ }
292
+ self._local = threading.local()
293
+ self._all = []
294
+ self._lock = threading.Lock()
295
+ self._closed = False
296
+ self.address = (cfg["host"], cfg["port"])
297
+
298
+ def _conn(self):
299
+ c = getattr(self._local, "conn", None)
300
+ if c is None:
301
+ host, port = self.address
302
+ if self._ssl is not None:
303
+ c = http.client.HTTPSConnection(host, port, timeout=self._connect_timeout, context=self._ssl)
304
+ else:
305
+ c = http.client.HTTPConnection(host, port, timeout=self._connect_timeout)
306
+ self._local.conn = c
307
+ with self._lock:
308
+ self._all.append(c)
309
+ if c.sock is None:
310
+ try:
311
+ c.connect()
312
+ except socket.timeout as e:
313
+ raise ServerSelectionTimeoutError("%s:%d: %s" % (self.address + (e,)))
314
+ except OSError as e:
315
+ raise ConnectionFailure("%s:%d: %s" % (self.address + (e,)))
316
+ c.sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
317
+ c.sock.settimeout(self._read_timeout)
318
+ return c
319
+
320
+ def _op(self, op, retry_safe=False, **fields):
321
+ if self._closed:
322
+ raise ConnectionFailure("el cliente está cerrado")
323
+ body = json.dumps(_encode(dict(op=op, **fields)), separators=(",", ":")).encode()
324
+ for attempt in (0, 1):
325
+ c = self._conn()
326
+ reused = getattr(c, "_wdb_used", False)
327
+ try:
328
+ c.request("POST", _PATH, body, self._headers)
329
+ r = c.getresponse()
330
+ status, raw = r.status, r.read()
331
+ c._wdb_used = True
332
+ break
333
+ except (http.client.RemoteDisconnected, ConnectionResetError, BrokenPipeError,
334
+ http.client.CannotSendRequest, http.client.ResponseNotReady) as e:
335
+ # Conexión keep-alive cerrada por el servidor mientras estaba
336
+ # ociosa: se reabre y se reintenta una sola vez.
337
+ c.close()
338
+ c._wdb_used = False
339
+ if attempt == 1 or not (reused or retry_safe):
340
+ raise ConnectionFailure("%s:%d: %s" % (self.address + (e,)))
341
+ except socket.timeout as e:
342
+ c.close()
343
+ raise ConnectionFailure("timeout esperando respuesta: %s" % e)
344
+ except OSError as e:
345
+ c.close()
346
+ raise ConnectionFailure("%s:%d: %s" % (self.address + (e,)))
347
+ try:
348
+ data = json.loads(raw, object_hook=_decode_hook)
349
+ except ValueError:
350
+ raise OperationFailure("respuesta inválida del servidor (HTTP %d)" % status)
351
+ if not data.get("ok"):
352
+ msg = data.get("errmsg", "error")
353
+ code = data.get("code")
354
+ if code == 11000:
355
+ raise DuplicateKeyError(msg, code, data)
356
+ raise OperationFailure(msg, code, data)
357
+ return data
358
+
359
+ def __getitem__(self, name):
360
+ return Database(self, name)
361
+
362
+ def __getattr__(self, name):
363
+ if name.startswith("_"):
364
+ raise AttributeError(name)
365
+ return Database(self, name)
366
+
367
+ def get_database(self, name=None, **kwargs):
368
+ return Database(self, name or "test")
369
+
370
+ def list_database_names(self):
371
+ return [d["name"] for d in self._op("listDatabases", True)["databases"]]
372
+
373
+ def drop_database(self, name):
374
+ if isinstance(name, Database):
375
+ name = name.name
376
+ self._op("dropDatabase", db=name)
377
+
378
+ def server_info(self):
379
+ self._op("ping", True)
380
+ return {"ok": 1.0, "version": "wirexadb-1"}
381
+
382
+ def close(self):
383
+ self._closed = True
384
+ with self._lock:
385
+ for c in self._all:
386
+ c.close()
387
+ self._all = []
388
+
389
+ def __enter__(self):
390
+ return self
391
+
392
+ def __exit__(self, *a):
393
+ self.close()
394
+
395
+ def __repr__(self):
396
+ return "WirexaClient(host=%r, cluster=%r)" % ("%s:%d" % self.address, self._cfg["cluster"])
397
+
398
+
399
+ MongoClient = WirexaClient # mismo nombre que pymongo: cambiar solo el import
400
+
401
+
402
+ class Database:
403
+ def __init__(self, client, name):
404
+ self.client = client
405
+ self.name = name
406
+
407
+ def __getitem__(self, name):
408
+ return Collection(self, name)
409
+
410
+ def __getattr__(self, name):
411
+ if name.startswith("_"):
412
+ raise AttributeError(name)
413
+ return Collection(self, name)
414
+
415
+ def get_collection(self, name, **kwargs):
416
+ return Collection(self, name)
417
+
418
+ def list_collection_names(self, **kwargs):
419
+ return self.client._op("listCollections", True, db=self.name)["collections"]
420
+
421
+ def drop_collection(self, name):
422
+ if isinstance(name, Collection):
423
+ name = name.name
424
+ self.client._op("drop", db=self.name, coll=name)
425
+
426
+ def create_collection(self, name, **kwargs):
427
+ return Collection(self, name)
428
+
429
+ def command(self, cmd, *args, **kwargs):
430
+ if cmd == "ping" or (isinstance(cmd, dict) and "ping" in cmd):
431
+ self.client._op("ping", True)
432
+ return {"ok": 1.0}
433
+ raise OperationFailure("comando no soportado: %r" % (cmd,))
434
+
435
+ def __repr__(self):
436
+ return "Database(%r)" % self.name
437
+
438
+
439
+ class Collection:
440
+ def __init__(self, database, name):
441
+ self.database = database
442
+ self.name = name
443
+ self._client = database.client
444
+
445
+ @property
446
+ def full_name(self):
447
+ return "%s.%s" % (self.database.name, self.name)
448
+
449
+ def __getattr__(self, name): # db.users.archived → colección "users.archived"
450
+ if name.startswith("_"):
451
+ raise AttributeError(name)
452
+ return Collection(self.database, "%s.%s" % (self.name, name))
453
+
454
+ def __getitem__(self, name):
455
+ return Collection(self.database, "%s.%s" % (self.name, name))
456
+
457
+ def _op(self, op, retry_safe=False, **f):
458
+ return self._client._op(op, retry_safe, db=self.database.name, coll=self.name, **f)
459
+
460
+ # -------- escritura
461
+ def insert_one(self, document, **kwargs):
462
+ r = self._op("insertOne", doc=document)
463
+ if "_id" not in document:
464
+ document["_id"] = r["insertedId"] # como pymongo: el dict queda con su _id
465
+ return InsertOneResult(r["insertedId"])
466
+
467
+ def insert_many(self, documents, ordered=True, **kwargs):
468
+ documents = list(documents)
469
+ if not documents:
470
+ raise TypeError("documents debe ser una lista no vacía")
471
+ r = self._op("insertMany", docs=documents, ordered=ordered)
472
+ for d, i in zip(documents, r["insertedIds"]):
473
+ d.setdefault("_id", i)
474
+ return InsertManyResult(r["insertedIds"])
475
+
476
+ def update_one(self, filter, update, upsert=False, **kwargs):
477
+ return UpdateResult(self._op("updateOne", filter=filter, update=update, upsert=upsert))
478
+
479
+ def update_many(self, filter, update, upsert=False, **kwargs):
480
+ return UpdateResult(self._op("updateMany", filter=filter, update=update, upsert=upsert))
481
+
482
+ def replace_one(self, filter, replacement, upsert=False, **kwargs):
483
+ return UpdateResult(self._op("replaceOne", filter=filter, replacement=replacement, upsert=upsert))
484
+
485
+ def delete_one(self, filter, **kwargs):
486
+ return DeleteResult(self._op("deleteOne", filter=filter))
487
+
488
+ def delete_many(self, filter, **kwargs):
489
+ return DeleteResult(self._op("deleteMany", filter=filter))
490
+
491
+ def find_one_and_update(self, filter, update, projection=None, sort=None, upsert=False,
492
+ return_document=ReturnDocument.BEFORE, **kwargs):
493
+ return self._op("findOneAndUpdate", filter=filter, update=update, projection=_projection(projection),
494
+ sort=_sort_spec(sort), upsert=upsert,
495
+ returnDocument="after" if return_document else "before")["doc"]
496
+
497
+ def find_one_and_replace(self, filter, replacement, projection=None, sort=None, upsert=False,
498
+ return_document=ReturnDocument.BEFORE, **kwargs):
499
+ return self._op("findOneAndReplace", filter=filter, replacement=replacement,
500
+ projection=_projection(projection), sort=_sort_spec(sort), upsert=upsert,
501
+ returnDocument="after" if return_document else "before")["doc"]
502
+
503
+ def find_one_and_delete(self, filter, projection=None, sort=None, **kwargs):
504
+ return self._op("findOneAndDelete", filter=filter, projection=_projection(projection),
505
+ sort=_sort_spec(sort))["doc"]
506
+
507
+ # -------- lectura
508
+ def find(self, filter=None, projection=None, skip=0, limit=0, sort=None, **kwargs):
509
+ return Cursor(self, filter or {}, _projection(projection), skip, limit, _sort_spec(sort))
510
+
511
+ def find_one(self, filter=None, projection=None, *args, sort=None, **kwargs):
512
+ if filter is not None and not isinstance(filter, dict):
513
+ filter = {"_id": filter}
514
+ return self._op("findOne", True, filter=filter or {}, projection=_projection(projection),
515
+ sort=_sort_spec(sort))["doc"]
516
+
517
+ def count_documents(self, filter, skip=0, limit=0, **kwargs):
518
+ return self._op("countDocuments", True, filter=filter, skip=skip, limit=limit)["n"]
519
+
520
+ def estimated_document_count(self, **kwargs):
521
+ return self._op("estimatedDocumentCount", True)["n"]
522
+
523
+ def distinct(self, key, filter=None, **kwargs):
524
+ return self._op("distinct", True, key=key, filter=filter or {})["values"]
525
+
526
+ def aggregate(self, pipeline, **kwargs):
527
+ return CommandCursor(self._op("aggregate", True, pipeline=list(pipeline))["docs"])
528
+
529
+ # -------- índices
530
+ def create_index(self, keys, unique=False, name=None, **kwargs):
531
+ f = {"keys": _sort_spec(keys), "unique": bool(unique)}
532
+ if name:
533
+ f["name"] = name
534
+ return self._op("createIndex", **f)["name"]
535
+
536
+ def create_indexes(self, indexes):
537
+ return [self.create_index(i.keys, **i.options) for i in indexes]
538
+
539
+ def drop_index(self, index_or_name):
540
+ name = index_or_name
541
+ if not isinstance(name, str):
542
+ spec = _sort_spec(index_or_name)
543
+ name = "_".join("%s_%s" % (k, d) for k, d in spec)
544
+ self._op("dropIndex", name=name)
545
+
546
+ def list_indexes(self):
547
+ out = []
548
+ for i in self._op("listIndexes", True)["indexes"]:
549
+ i["key"] = {k: d for k, d in i["key"]}
550
+ out.append(i)
551
+ return CommandCursor(out)
552
+
553
+ def index_information(self):
554
+ return {i["name"]: {"key": list(i["key"].items()), "unique": i.get("unique", False)}
555
+ for i in self.list_indexes()}
556
+
557
+ def drop(self):
558
+ self._op("drop")
559
+
560
+ def __repr__(self):
561
+ return "Collection(%r)" % self.full_name
562
+
563
+
564
+ class IndexModel:
565
+ def __init__(self, keys, **kwargs):
566
+ self.keys = keys
567
+ self.options = kwargs
568
+
569
+
570
+ class Cursor:
571
+ """Cursor perezoso: la consulta sale al iterar (o con to_list)."""
572
+
573
+ def __init__(self, coll, filter, projection, skip, limit, sort):
574
+ self._coll = coll
575
+ self._filter = filter
576
+ self._projection = projection
577
+ self._skip = skip
578
+ self._limit = limit
579
+ self._sort = sort
580
+ self._docs = None
581
+ self._pos = 0
582
+
583
+ def _check(self):
584
+ if self._docs is not None:
585
+ raise WirexaDBError("no se puede modificar un cursor ya iterado")
586
+
587
+ def sort(self, key_or_list, direction=None):
588
+ self._check()
589
+ self._sort = _sort_spec(key_or_list, direction)
590
+ return self
591
+
592
+ def skip(self, n):
593
+ self._check()
594
+ self._skip = n
595
+ return self
596
+
597
+ def limit(self, n):
598
+ self._check()
599
+ self._limit = n
600
+ return self
601
+
602
+ def batch_size(self, n):
603
+ return self
604
+
605
+ def _fetch(self):
606
+ if self._docs is None:
607
+ self._docs = self._coll._op("find", True, filter=self._filter, projection=self._projection,
608
+ skip=self._skip, limit=self._limit, sort=self._sort)["docs"]
609
+ return self._docs
610
+
611
+ def __iter__(self):
612
+ return self
613
+
614
+ def __next__(self):
615
+ docs = self._fetch()
616
+ if self._pos >= len(docs):
617
+ raise StopIteration
618
+ d = docs[self._pos]
619
+ self._pos += 1
620
+ return d
621
+
622
+ next = __next__
623
+
624
+ def to_list(self, length=None):
625
+ docs = self._fetch()[self._pos:]
626
+ return docs if length is None else docs[:length]
627
+
628
+ def rewind(self):
629
+ self._docs = None
630
+ self._pos = 0
631
+ return self
632
+
633
+ def close(self):
634
+ pass
635
+
636
+ def __enter__(self):
637
+ return self
638
+
639
+ def __exit__(self, *a):
640
+ pass
641
+
642
+
643
+ class CommandCursor:
644
+ def __init__(self, docs):
645
+ self._docs = docs
646
+ self._pos = 0
647
+
648
+ def __iter__(self):
649
+ return self
650
+
651
+ def __next__(self):
652
+ if self._pos >= len(self._docs):
653
+ raise StopIteration
654
+ d = self._docs[self._pos]
655
+ self._pos += 1
656
+ return d
657
+
658
+ next = __next__
659
+
660
+ def to_list(self, length=None):
661
+ docs = self._docs[self._pos:]
662
+ return docs if length is None else docs[:length]
663
+
664
+ def close(self):
665
+ pass
@@ -1,23 +1,3 @@
1
- """
2
- Wirexa API SDK - Drop-in replacement del SDK de SignalWire/Twilio.
3
-
4
- CREDENCIALES (las obtienes del dashboard):
5
- space_url = "your-space.example.com:5000"
6
- project_id = "your-project-id"
7
- api_token = "your-api-token"
8
-
9
- USO:
10
-
11
- ANTES (SignalWire original):
12
- from signalwire.rest import Client
13
- client = Client(project_id, api_token, signalwire_space_url=space_url)
14
-
15
- DESPUÉS (Wirexa API):
16
- from wirexaapi import Client
17
- client = Client(project_id, api_token, signalwire_space_url=space_url)
18
-
19
- El resto del código NO cambia.
20
- """
21
1
  from __future__ import annotations
22
2
 
23
3
  import logging
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wirexaapi
3
- Version: 1.0.0
4
- Summary: Wirexa API SDK - SignalWire/Twilio compatible voice API client
3
+ Version: 1.1.0
4
+ Summary: Wirexa API SDK - voice API client (SignalWire/Twilio compatible) and Wirexa DB document database client
5
5
  Author-email: Wirexa Team <dev@wirexa.io>
6
6
  License: MIT
7
7
  Project-URL: Homepage, https://github.com/wirexa/wirexa-api
@@ -84,3 +84,35 @@ The rest of your code stays the same!
84
84
  ## License
85
85
 
86
86
  MIT
87
+
88
+
89
+ ## Wirexa DB (base de datos de documentos)
90
+
91
+ ```python
92
+ from wirexaapi import MongoClient
93
+
94
+ client = MongoClient(
95
+ "wirexaapi+srv://usuario:password@cluster0-abc1234.wirexaapi.com/?appName=cluster0",
96
+ serverSelectionTimeoutMS=5000,
97
+ connectTimeoutMS=5000,
98
+ socketTimeoutMS=20000,
99
+ )
100
+ db = client["miapp"]
101
+ users = db["users"] # las colecciones se crean solas
102
+
103
+ users.insert_one({"name": "Ana", "age": 30})
104
+ users.find_one({"name": "Ana"})
105
+ for u in users.find({"age": {"$gt": 25}}).sort("name").limit(10):
106
+ print(u)
107
+ users.update_one({"name": "Ana"}, {"$set": {"age": 31}})
108
+ users.create_index("email", unique=True)
109
+ ```
110
+
111
+ Soporta filtros (`$eq $ne $gt $gte $lt $lte $in $nin $exists $regex $and $or $nor $not $size $all $elemMatch`),
112
+ updates (`$set $unset $inc $mul $min $max $push $addToSet $pull $pop $rename $currentDate $setOnInsert`), upserts,
113
+ `find_one_and_*`, índices (incluidos únicos), `distinct`, `count_documents` y `aggregate`
114
+ (`$match $sort $skip $limit $project $addFields $unset $unwind $group $count $lookup`).
115
+
116
+ Si tu backend corre en la misma VPS que el cluster, usa la URI local que da el panel
117
+ (`wirexaapi://...@127.0.0.1:27501/?cluster=<id>&tls=false`): ~1 ms por consulta.
118
+ Instala `wirexaapi[srv]` para resolver el registro SRV del cluster.
@@ -1,8 +1,10 @@
1
1
  README.md
2
2
  pyproject.toml
3
3
  wirexaapi/__init__.py
4
+ wirexaapi/db.py
4
5
  wirexaapi.egg-info/PKG-INFO
5
6
  wirexaapi.egg-info/SOURCES.txt
6
7
  wirexaapi.egg-info/dependency_links.txt
7
8
  wirexaapi.egg-info/requires.txt
8
- wirexaapi.egg-info/top_level.txt
9
+ wirexaapi.egg-info/top_level.txt
10
+ wirexaapi/rest/__init__.py
wirexaapi-1.0.0/README.md DELETED
@@ -1,64 +0,0 @@
1
- # Wirexa API SDK
2
-
3
- A production-ready Python SDK for Wirexa API - a self-hosted telephony platform compatible with SignalWire and Twilio REST API.
4
-
5
- ## Installation
6
-
7
- ```bash
8
- pip install wirexaapi
9
- ```
10
-
11
- ## Quick Start
12
-
13
- ```python
14
- from wirexaapi import Client
15
-
16
- # Connect to your Wirexa API instance
17
- client = Client(
18
- project_id="your-project-id",
19
- api_token="your-api-token",
20
- signalwire_space_url="your-space.example.com:5000"
21
- )
22
-
23
- # Make a call
24
- call = client.calls.create(
25
- to="+573001234567",
26
- from_="+18005550100",
27
- url="https://your-backend.com/xml",
28
- method="POST",
29
- timeout=28,
30
- record=True,
31
- status_callback="https://your-backend.com/status",
32
- status_callback_event=["initiated", "ringing", "completed"],
33
- )
34
-
35
- print(f"Call SID: {call.sid}")
36
- print(f"Call Status: {call.status}")
37
- ```
38
-
39
- ## Migration from SignalWire
40
-
41
- If you're migrating from SignalWire SDK:
42
-
43
- ```python
44
- # Before
45
- from signalwire.rest import Client as SignalWireClient
46
-
47
- # After
48
- from wirexaapi import Client as SignalWireClient
49
- ```
50
-
51
- The rest of your code stays the same!
52
-
53
- ## Features
54
-
55
- - Full SignalWire/Twilio API compatibility
56
- - Call creation, retrieval, and listing
57
- - Machine Detection (AMD)
58
- - Recording
59
- - Status callbacks
60
- - Async AMD support
61
-
62
- ## License
63
-
64
- MIT
File without changes