geo-engine-python 0.1.3__py3-none-any.whl → 0.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.
- geo_engine_python-0.2.0.dist-info/METADATA +278 -0
- geo_engine_python-0.2.0.dist-info/RECORD +15 -0
- {geo_engine_python-0.1.3.dist-info → geo_engine_python-0.2.0.dist-info}/WHEEL +1 -1
- geoengine/__init__.py +55 -1
- geoengine/_http.py +208 -0
- geoengine/_version.py +1 -0
- geoengine/client.py +122 -0
- geoengine/errors.py +146 -0
- geoengine/geoidentity.py +186 -0
- geoengine/ingester.py +73 -0
- geoengine/models.py +218 -0
- geoengine/spatial.py +75 -0
- geoengine/users.py +72 -0
- geoengine/validation.py +57 -0
- geo_engine_python-0.1.3.dist-info/METADATA +0 -122
- geo_engine_python-0.1.3.dist-info/RECORD +0 -5
- {geo_engine_python-0.1.3.dist-info → geo_engine_python-0.2.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: geo-engine-python
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: SDK oficial para Geo-Engine: rastreo, geocercas, validación espacial, usuarios y GeoIdentity
|
|
5
|
+
Home-page: https://github.com/AlexG695/geo-engine-python
|
|
6
|
+
Author: AlexG695
|
|
7
|
+
Author-email: AlexG <alexg@geoengine.dev>
|
|
8
|
+
Project-URL: Homepage, https://geoengine.dev
|
|
9
|
+
Project-URL: Bug Tracker, https://github.com/AlexG695/geo-engine/issues
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Requires-Python: >=3.7
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
Requires-Dist: requests>=2.25.0
|
|
16
|
+
Dynamic: author
|
|
17
|
+
Dynamic: home-page
|
|
18
|
+
Dynamic: requires-python
|
|
19
|
+
|
|
20
|
+
# 🐍 Geo-Engine Python SDK
|
|
21
|
+
|
|
22
|
+
> *Read this in Spanish: [README.es.md](./README.es.md)*
|
|
23
|
+
|
|
24
|
+
Official **Python** client for the **Geo-Engine** platform.
|
|
25
|
+
Send real-time locations, manage geofences, validate points, administer organization members, and define GeoIdentity access rules from **Django**, **Flask**, **FastAPI**, scripts, or **Data Science** environments.
|
|
26
|
+
|
|
27
|
+
Feature parity with the official Go SDK (`v2.1.0`), except for gRPC transport.
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## ✨ Features
|
|
32
|
+
|
|
33
|
+
- 📍 **Real-time Ingestion:** Coordinate ingestion via HTTP REST.
|
|
34
|
+
- 🗺️ **Spatial Module:** Point-in-polygon validation against a single geofence or against all geofences.
|
|
35
|
+
- 👥 **Users Module:** Organization members, roles, and email invitations.
|
|
36
|
+
- 🛡️ **GeoIdentity Module:** Access rules (IP blacklists, geographic boundaries, Tor/datacenter blocking) assignable to users or to the entire organization.
|
|
37
|
+
- 🚧 **Geofences:** Creation with automatic GeoJSON polygon normalization.
|
|
38
|
+
- 🔄 **AsyncIngester:** Background queue with auto-flush by batch size and time interval.
|
|
39
|
+
- 🔐 **Security:** HMAC-SHA256 signature (`X-Signature`) with anti-replay timestamp and JWT Bearer token support.
|
|
40
|
+
- 🧯 **Typed Errors** for every business case.
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## 🚀 Installation
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pip install geo-engine-python
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## ⚡ Quick Start
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from geoengine import GeoEngine
|
|
56
|
+
|
|
57
|
+
# Connects to production environment by default
|
|
58
|
+
client = GeoEngine(api_key="geo_live_123456")
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
res = client.send_location(device_id="truck-01", lat=19.4326, lng=-99.1332)
|
|
62
|
+
print(f"✅ Sent: {res}")
|
|
63
|
+
except Exception as e:
|
|
64
|
+
print(f"❌ Error: {e}")
|
|
65
|
+
finally:
|
|
66
|
+
client.close()
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Also works as a context manager: `with GeoEngine("geo_live_...") as client: ...`
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## 🗺️ Spatial Module
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
from geoengine import GeofenceNotFoundError
|
|
77
|
+
|
|
78
|
+
# Against a specific geofence
|
|
79
|
+
try:
|
|
80
|
+
res = client.spatial.validate_point("geofence_123", 19.4326, -99.1332)
|
|
81
|
+
if res.is_inside():
|
|
82
|
+
print(f"Inside {res.geofence_name} ({res.geofence_id})")
|
|
83
|
+
print(res) # [INSIDE] geofence=geofence_123 (Downtown) at (19.432600, -99.133200)
|
|
84
|
+
except GeofenceNotFoundError:
|
|
85
|
+
print("The geofence does not exist")
|
|
86
|
+
|
|
87
|
+
# Against all active geofences in the organization
|
|
88
|
+
res = client.spatial.validate_general(19.4326, -99.1332)
|
|
89
|
+
if res.has_matches():
|
|
90
|
+
print(res.total_matches, res.geofence_names())
|
|
91
|
+
print(res.contains_geofence("CDMX Downtown"), res.find_match("CDMX Downtown"))
|
|
92
|
+
|
|
93
|
+
# Fluent query builder
|
|
94
|
+
single = client.spatial.check(19.4326, -99.1332).in_geofence("geofence_123").execute()
|
|
95
|
+
general = client.spatial.check(19.4326, -99.1332).in_all_geofences().execute()
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## 👥 Users Module
|
|
101
|
+
|
|
102
|
+
`client.users` manages members of the organization that owns the API key:
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
from geoengine import Roles, InvitationPendingError, LastAdminError
|
|
106
|
+
|
|
107
|
+
# List members and available roles
|
|
108
|
+
users = client.users.list()
|
|
109
|
+
roles = client.users.list_roles()
|
|
110
|
+
user = client.users.get("user_123")
|
|
111
|
+
|
|
112
|
+
# Invite a new member (GeoEngine sends the invitation email)
|
|
113
|
+
try:
|
|
114
|
+
inv = client.users.invite("ana@acme.com", Roles.VIEWER, lang="en") # "es" (default) | "en" | "pt"
|
|
115
|
+
print("Invitation link:", inv.invite_url)
|
|
116
|
+
except InvitationPendingError:
|
|
117
|
+
print("There is already a pending invitation for this email")
|
|
118
|
+
|
|
119
|
+
# Update a member's role or revoke their access
|
|
120
|
+
try:
|
|
121
|
+
client.users.update_role("user_123", Roles.DISPATCHER)
|
|
122
|
+
client.users.remove("user_123")
|
|
123
|
+
except LastAdminError:
|
|
124
|
+
print("The organization must retain at least one administrator")
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
## 🛡️ GeoIdentity Module
|
|
130
|
+
|
|
131
|
+
`client.geoidentity` creates access rules and assigns them to a user or to the entire organization:
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
from geoengine import RuleAction, SubjectAssignment, SubjectType, polygon_from_lat_lng
|
|
135
|
+
|
|
136
|
+
area = polygon_from_lat_lng([
|
|
137
|
+
[19.40, -99.20], [19.40, -99.10], [19.50, -99.10], # [lat, lng]
|
|
138
|
+
])
|
|
139
|
+
|
|
140
|
+
rule = client.geoidentity.create_rule(
|
|
141
|
+
"CDMX Only",
|
|
142
|
+
action=RuleAction.BLOCK,
|
|
143
|
+
black_ip_list=["203.0.113.0/24"],
|
|
144
|
+
block_tor=True,
|
|
145
|
+
geo_limit=area,
|
|
146
|
+
assigned_subjects=[SubjectAssignment(SubjectType.USER, "user_123")],
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
# Query, update, and delete
|
|
150
|
+
rules = client.geoidentity.list_rules(is_active=True)
|
|
151
|
+
detail = client.geoidentity.get_rule(rule.id) # includes assigned_subjects
|
|
152
|
+
client.geoidentity.update_rule(rule.id, is_active=False, geo_limit=None) # None removes the geographic boundary
|
|
153
|
+
client.geoidentity.delete_rule(rule.id)
|
|
154
|
+
|
|
155
|
+
# Assign or unassign the rule
|
|
156
|
+
client.geoidentity.assign_to_user(rule.id, "user_456")
|
|
157
|
+
client.geoidentity.assign_to_organization(rule.id)
|
|
158
|
+
client.geoidentity.unassign_from_user(rule.id, "user_456")
|
|
159
|
+
|
|
160
|
+
# Rules that apply to a user (direct, by role, or organization-wide)
|
|
161
|
+
user_rules = client.geoidentity.list_user_rules("user_123")
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
> A rule created without `assigned_subjects` applies to the entire organization. A rule must always retain at least one subject: removing the last one raises `LastSubjectError` (deactivate or delete the rule instead).
|
|
165
|
+
>
|
|
166
|
+
> `update_rule` only sends the parameters you provide; unspecified parameters remain unchanged.
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
## 🚧 Geofences
|
|
171
|
+
|
|
172
|
+
The SDK takes `[latitude, longitude]` pairs, converts them to GeoJSON, and closes the polygon:
|
|
173
|
+
|
|
174
|
+
```python
|
|
175
|
+
client.create_geofence(
|
|
176
|
+
"North Logistics Park",
|
|
177
|
+
[[19.4326, -99.1332], [19.4426, -99.1332], [19.4426, -99.1232]],
|
|
178
|
+
webhook_url="https://my-api.com/webhooks/geofences", # optional
|
|
179
|
+
)
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
---
|
|
183
|
+
|
|
184
|
+
## 🔄 Asynchronous Ingestion (`AsyncIngester`)
|
|
185
|
+
|
|
186
|
+
```python
|
|
187
|
+
from geoengine import LocationPing
|
|
188
|
+
|
|
189
|
+
# 5000 pings queue; flushes every 100 items or every 0.5 s
|
|
190
|
+
ingester = client.create_async_ingester(buffer_size=5000, batch_size=100, flush_interval=0.5)
|
|
191
|
+
|
|
192
|
+
ok = ingester.enqueue(LocationPing("truck-45", 19.4326, -99.1332))
|
|
193
|
+
if not ok:
|
|
194
|
+
print("Queue full or ingester stopped")
|
|
195
|
+
|
|
196
|
+
# On service shutdown: flushes pending items and stops the background thread
|
|
197
|
+
ingester.stop()
|
|
198
|
+
client.close()
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Background sending errors are silently dropped.
|
|
202
|
+
|
|
203
|
+
---
|
|
204
|
+
|
|
205
|
+
## 🔧 Advanced Configuration
|
|
206
|
+
|
|
207
|
+
```python
|
|
208
|
+
client = GeoEngine(
|
|
209
|
+
api_key="sk_test_...",
|
|
210
|
+
environment="test", # "live" (default) | "test"
|
|
211
|
+
jwt_token="my.jwt.token", # optional
|
|
212
|
+
timeout=5, # seconds
|
|
213
|
+
ingest_url="http://localhost:8080",
|
|
214
|
+
management_url="http://localhost:8081",
|
|
215
|
+
api_service_url="http://localhost:8082", # Spatial, Users, and GeoIdentity
|
|
216
|
+
# or a single URL for all three (local tests / mocks):
|
|
217
|
+
# base_url="http://localhost:8080",
|
|
218
|
+
)
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
---
|
|
222
|
+
|
|
223
|
+
## 🧯 Errors
|
|
224
|
+
|
|
225
|
+
All errors inherit from `GeoEngineError`.
|
|
226
|
+
|
|
227
|
+
| Error | When it occurs |
|
|
228
|
+
| :--- | :--- |
|
|
229
|
+
| `ValidationError` (also `ValueError`) and subclasses: `DeviceIDRequiredError`, `GeofenceIDRequiredError`, `InvalidLatitudeError`, `InvalidLongitudeError`, `InvalidCoordinatesError`, `UserIDRequiredError`, `RuleIDRequiredError`, `RuleNameRequiredError`, `EmailRequiredError`, `RoleRequiredError` | Invalid parameter; API is not called |
|
|
230
|
+
| `APIError` | Ingestion or management HTTP error (`status_code`, `api_message`) |
|
|
231
|
+
| `APIServiceError` | API service error; adds `code` and `details` |
|
|
232
|
+
| `GeofenceNotFoundError` | Geofence does not exist (Spatial, 204/404) |
|
|
233
|
+
| `UserNotFoundError` | `USER_NOT_FOUND`, `USER_REVOKED` |
|
|
234
|
+
| `RuleNotFoundError` | `RULE_NOT_FOUND` |
|
|
235
|
+
| `UserAlreadyExistsError` | `USER_ALREADY_EXISTS` |
|
|
236
|
+
| `InvitationPendingError` | `INVITATION_PENDING` |
|
|
237
|
+
| `LastAdminError` | `LAST_ADMIN` |
|
|
238
|
+
| `LastSubjectError` | `LAST_SUBJECT` |
|
|
239
|
+
| `AuthenticationError` / `ForbiddenError` / `QuotaExceededError` | 401 / 403 / 402 |
|
|
240
|
+
| `NetworkError`, `RequestTimeoutError` | Network failure or request timeout |
|
|
241
|
+
| `ClientClosedError` | Client used after calling `close()` |
|
|
242
|
+
|
|
243
|
+
The SDK does not automatically retry requests.
|
|
244
|
+
|
|
245
|
+
---
|
|
246
|
+
|
|
247
|
+
## 🔐 Authentication
|
|
248
|
+
|
|
249
|
+
The SDK authenticates using **API Keys** and signs each request.
|
|
250
|
+
Keep them secret and **never expose them in frontend applications**.
|
|
251
|
+
|
|
252
|
+
---
|
|
253
|
+
|
|
254
|
+
## 🧪 Testing
|
|
255
|
+
|
|
256
|
+
```bash
|
|
257
|
+
python -m unittest discover -s tests -t .
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
---
|
|
261
|
+
|
|
262
|
+
## 🧩 Compatibility
|
|
263
|
+
|
|
264
|
+
* Python **3.7+**
|
|
265
|
+
* Django, Flask, FastAPI, CLI scripts, Jupyter / Data Science
|
|
266
|
+
|
|
267
|
+
---
|
|
268
|
+
|
|
269
|
+
## 📄 License
|
|
270
|
+
|
|
271
|
+
MIT © Geo-Engine
|
|
272
|
+
|
|
273
|
+
---
|
|
274
|
+
|
|
275
|
+
## 🤝 Support
|
|
276
|
+
|
|
277
|
+
Have questions or suggestions?
|
|
278
|
+
Open an issue or contact us at **[support@geoengine.dev](mailto:support@geoengine.dev)**
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
geoengine/__init__.py,sha256=nvhIaAET6ecVpm22TANQZXPLpOIhAehLSM9gsjsS8uQ,1383
|
|
2
|
+
geoengine/_http.py,sha256=UGKjyBSQbpGph--7ZMDtfkvlmk6lg5UMeojXFvTs-SY,6930
|
|
3
|
+
geoengine/_version.py,sha256=Zn1KFblwuFHiDRdRAiRnDBRkbPttWh44jKa5zG2ov0E,22
|
|
4
|
+
geoengine/client.py,sha256=unvB7HWci9VL2WkPzRIzhk8hIi7GrfJJVX4nfdSw64s,5164
|
|
5
|
+
geoengine/errors.py,sha256=Mq3j-9dtD4VXpjMGlTITK0QFcm8eUEXTdY6iZnyxlSo,4523
|
|
6
|
+
geoengine/geoidentity.py,sha256=r8lv1kfw_cbVfJUrx0JRJgezqHz_9kwgUpRz-4D1xq8,7456
|
|
7
|
+
geoengine/ingester.py,sha256=MDsyOyFNTSOECTkGoN1JsLaFeNkuCbAOMT8g4k37bEk,2500
|
|
8
|
+
geoengine/models.py,sha256=9cggYlZysHUei5OeYitx3uuXN8AC7UgJbVBi2I2gmUk,5993
|
|
9
|
+
geoengine/spatial.py,sha256=huBL4C7YgofICk7q_gQWbX16bPGMJkkzRPqO_zmmSKw,2876
|
|
10
|
+
geoengine/users.py,sha256=BV2ixzfBjiFNxIyxgJntUDDaHC6oGOhVBg7aaX2_4Js,2936
|
|
11
|
+
geoengine/validation.py,sha256=xDwuFqcNVtW9lMwAJa9D0iEVVu5MszMQpMZSSw-aO00,1815
|
|
12
|
+
geo_engine_python-0.2.0.dist-info/METADATA,sha256=IxEq5eBTY8-kdymVoKUb0-JY4OBXGU6Nbqkw619KYdI,8906
|
|
13
|
+
geo_engine_python-0.2.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
14
|
+
geo_engine_python-0.2.0.dist-info/top_level.txt,sha256=bqE5zIDfI6saPEFHhCIwqCBOhsiQ998LGjP_srSIg9E,10
|
|
15
|
+
geo_engine_python-0.2.0.dist-info/RECORD,,
|
geoengine/__init__.py
CHANGED
|
@@ -1 +1,55 @@
|
|
|
1
|
-
|
|
1
|
+
"""SDK oficial de Python para la plataforma Geo-Engine."""
|
|
2
|
+
from ._http import generate_hmac_signature
|
|
3
|
+
from ._version import __version__
|
|
4
|
+
from .client import GeoEngine
|
|
5
|
+
from .errors import (
|
|
6
|
+
APIError,
|
|
7
|
+
APIServiceError,
|
|
8
|
+
AuthenticationError,
|
|
9
|
+
ClientClosedError,
|
|
10
|
+
DeviceIDRequiredError,
|
|
11
|
+
EmailRequiredError,
|
|
12
|
+
ForbiddenError,
|
|
13
|
+
GeoEngineError,
|
|
14
|
+
GeofenceIDRequiredError,
|
|
15
|
+
GeofenceNotFoundError,
|
|
16
|
+
InvalidCoordinatesError,
|
|
17
|
+
InvalidLatitudeError,
|
|
18
|
+
InvalidLongitudeError,
|
|
19
|
+
InvitationPendingError,
|
|
20
|
+
LastAdminError,
|
|
21
|
+
LastSubjectError,
|
|
22
|
+
NetworkError,
|
|
23
|
+
QuotaExceededError,
|
|
24
|
+
RequestTimeoutError,
|
|
25
|
+
RoleRequiredError,
|
|
26
|
+
RuleIDRequiredError,
|
|
27
|
+
RuleNameRequiredError,
|
|
28
|
+
RuleNotFoundError,
|
|
29
|
+
UserAlreadyExistsError,
|
|
30
|
+
UserIDRequiredError,
|
|
31
|
+
UserNotFoundError,
|
|
32
|
+
ValidationError,
|
|
33
|
+
)
|
|
34
|
+
from .geoidentity import UNSET, GeoIdentityService, polygon_from_lat_lng
|
|
35
|
+
from .ingester import AsyncIngester
|
|
36
|
+
from .models import (
|
|
37
|
+
GeofenceMatch,
|
|
38
|
+
Invitation,
|
|
39
|
+
LocationPing,
|
|
40
|
+
Role,
|
|
41
|
+
Roles,
|
|
42
|
+
Rule,
|
|
43
|
+
RuleAction,
|
|
44
|
+
SpatialGeneralResult,
|
|
45
|
+
SpatialValidationResult,
|
|
46
|
+
SubjectAssignment,
|
|
47
|
+
SubjectType,
|
|
48
|
+
User,
|
|
49
|
+
UserStatus,
|
|
50
|
+
)
|
|
51
|
+
from .spatial import SpatialService
|
|
52
|
+
from .users import UsersService
|
|
53
|
+
from .validation import validate_coordinates
|
|
54
|
+
|
|
55
|
+
__all__ = [name for name in dir() if not name.startswith("_")] + ["__version__"]
|
geoengine/_http.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Transporte HTTP interno: cabeceras firmadas, envelope del api-service y errores tipados."""
|
|
2
|
+
import hashlib
|
|
3
|
+
import hmac
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
|
|
10
|
+
from ._version import __version__
|
|
11
|
+
from .errors import (
|
|
12
|
+
APIError,
|
|
13
|
+
APIServiceError,
|
|
14
|
+
AuthenticationError,
|
|
15
|
+
ClientClosedError,
|
|
16
|
+
ForbiddenError,
|
|
17
|
+
GeoEngineError,
|
|
18
|
+
GeofenceNotFoundError,
|
|
19
|
+
InvitationPendingError,
|
|
20
|
+
LastAdminError,
|
|
21
|
+
LastSubjectError,
|
|
22
|
+
NetworkError,
|
|
23
|
+
QuotaExceededError,
|
|
24
|
+
RequestTimeoutError,
|
|
25
|
+
RuleNotFoundError,
|
|
26
|
+
UserAlreadyExistsError,
|
|
27
|
+
UserNotFoundError,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
USER_AGENT = f"GeoEnginePythonSDK/{__version__}"
|
|
31
|
+
|
|
32
|
+
# Tipos de endpoint: definen cómo se interpretan la respuesta y los errores.
|
|
33
|
+
PLAIN = "plain" # ingesta / gestión: JSON tal cual, errores como APIError
|
|
34
|
+
SPATIAL = "spatial" # api-service spatial: envelope {success, data}; 204/404 = geocerca inexistente
|
|
35
|
+
API_SERVICE = "api_service" # api-service users / geoidentity: envelope {success, data}
|
|
36
|
+
|
|
37
|
+
_CODE_ERRORS = {
|
|
38
|
+
"USER_NOT_FOUND": UserNotFoundError,
|
|
39
|
+
"USER_REVOKED": UserNotFoundError,
|
|
40
|
+
"RULE_NOT_FOUND": RuleNotFoundError,
|
|
41
|
+
"USER_ALREADY_EXISTS": UserAlreadyExistsError,
|
|
42
|
+
"INVITATION_PENDING": InvitationPendingError,
|
|
43
|
+
"LAST_ADMIN": LastAdminError,
|
|
44
|
+
"LAST_SUBJECT": LastSubjectError,
|
|
45
|
+
"QUOTA_EXCEEDED": QuotaExceededError,
|
|
46
|
+
"MISSING_API_KEY": AuthenticationError,
|
|
47
|
+
"MISSING_ORGANIZATION": AuthenticationError,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
_STATUS_ERRORS = {
|
|
51
|
+
401: AuthenticationError,
|
|
52
|
+
402: QuotaExceededError,
|
|
53
|
+
403: ForbiddenError,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def generate_hmac_signature(key: str, timestamp_ms: int) -> str:
|
|
58
|
+
"""Firma HMAC-SHA256 (hex) del timestamp en milisegundos usando la API key como secreto."""
|
|
59
|
+
if not key:
|
|
60
|
+
return ""
|
|
61
|
+
return hmac.new(key.encode("utf-8"), str(timestamp_ms).encode("utf-8"), hashlib.sha256).hexdigest()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def parse_api_service_error(status_code: int, body_text: str, spatial: bool = False) -> APIServiceError:
|
|
65
|
+
"""Convierte el cuerpo de error del api-service en la excepción tipada correspondiente."""
|
|
66
|
+
code = ""
|
|
67
|
+
message = ""
|
|
68
|
+
details = []
|
|
69
|
+
|
|
70
|
+
container = None
|
|
71
|
+
try:
|
|
72
|
+
parsed = json.loads(body_text)
|
|
73
|
+
if isinstance(parsed, dict) and isinstance(parsed.get("error"), dict):
|
|
74
|
+
container = parsed["error"]
|
|
75
|
+
except ValueError:
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
if container is not None:
|
|
79
|
+
code = container.get("status") or ""
|
|
80
|
+
message = container.get("message") or ""
|
|
81
|
+
raw_details = container.get("details")
|
|
82
|
+
if isinstance(raw_details, list):
|
|
83
|
+
details = [d for d in raw_details if isinstance(d, dict)]
|
|
84
|
+
for detail in details:
|
|
85
|
+
if detail.get("reason"):
|
|
86
|
+
code = detail["reason"]
|
|
87
|
+
break
|
|
88
|
+
else:
|
|
89
|
+
message = (body_text or "").strip()
|
|
90
|
+
|
|
91
|
+
if not message:
|
|
92
|
+
message = f"HTTP {status_code}"
|
|
93
|
+
|
|
94
|
+
error_class = _CODE_ERRORS.get(code)
|
|
95
|
+
if error_class is None and spatial and (status_code in (204, 404) or code == "NOT_FOUND"):
|
|
96
|
+
error_class = GeofenceNotFoundError
|
|
97
|
+
code = code or "NOT_FOUND"
|
|
98
|
+
if error_class is None:
|
|
99
|
+
error_class = _STATUS_ERRORS.get(status_code, APIServiceError)
|
|
100
|
+
|
|
101
|
+
return error_class(status_code, code, message, details)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class Transport:
|
|
105
|
+
"""Envía peticiones autenticadas. Seguro para usarse desde varios hilos."""
|
|
106
|
+
|
|
107
|
+
def __init__(
|
|
108
|
+
self,
|
|
109
|
+
api_key: str,
|
|
110
|
+
jwt_token: Optional[str],
|
|
111
|
+
environment: str,
|
|
112
|
+
timeout: float,
|
|
113
|
+
session: Optional[requests.Session] = None,
|
|
114
|
+
):
|
|
115
|
+
self.api_key = api_key
|
|
116
|
+
self.jwt_token = jwt_token
|
|
117
|
+
self.environment = environment
|
|
118
|
+
self.timeout = timeout
|
|
119
|
+
self.session = session or requests.Session()
|
|
120
|
+
self._owns_session = session is None
|
|
121
|
+
self.closed = False
|
|
122
|
+
|
|
123
|
+
def close(self) -> None:
|
|
124
|
+
if not self.closed:
|
|
125
|
+
self.closed = True
|
|
126
|
+
if self._owns_session:
|
|
127
|
+
self.session.close()
|
|
128
|
+
|
|
129
|
+
def headers(self, has_body: bool) -> dict:
|
|
130
|
+
now = int(time.time() * 1000)
|
|
131
|
+
headers = {
|
|
132
|
+
"Accept": "application/json",
|
|
133
|
+
"X-API-Key": self.api_key,
|
|
134
|
+
"X-Geo-Environment": self.environment,
|
|
135
|
+
"X-Request-Time": str(now),
|
|
136
|
+
"X-Signature": generate_hmac_signature(self.api_key, now),
|
|
137
|
+
"User-Agent": USER_AGENT,
|
|
138
|
+
}
|
|
139
|
+
if has_body:
|
|
140
|
+
headers["Content-Type"] = "application/json"
|
|
141
|
+
if self.jwt_token:
|
|
142
|
+
headers["Authorization"] = f"Bearer {self.jwt_token}"
|
|
143
|
+
return headers
|
|
144
|
+
|
|
145
|
+
def request(self, method: str, url: str, body: Any = None, kind: str = PLAIN) -> Any:
|
|
146
|
+
if self.closed:
|
|
147
|
+
raise ClientClosedError()
|
|
148
|
+
|
|
149
|
+
has_body = body is not None
|
|
150
|
+
try:
|
|
151
|
+
response = self.session.request(
|
|
152
|
+
method,
|
|
153
|
+
url,
|
|
154
|
+
data=json.dumps(body).encode("utf-8") if has_body else None,
|
|
155
|
+
headers=self.headers(has_body),
|
|
156
|
+
timeout=self.timeout,
|
|
157
|
+
)
|
|
158
|
+
except requests.exceptions.Timeout as exc:
|
|
159
|
+
raise RequestTimeoutError(
|
|
160
|
+
f"geoengine: request exceeded the timeout limit of {self.timeout}s"
|
|
161
|
+
) from exc
|
|
162
|
+
except requests.exceptions.RequestException as exc:
|
|
163
|
+
raise NetworkError(f"geoengine: network error: {exc}") from exc
|
|
164
|
+
|
|
165
|
+
status = response.status_code
|
|
166
|
+
text = response.text or ""
|
|
167
|
+
|
|
168
|
+
if status == 204:
|
|
169
|
+
if kind == SPATIAL:
|
|
170
|
+
raise GeofenceNotFoundError(204, "NOT_FOUND", "geofence not found")
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
if status >= 400:
|
|
174
|
+
if kind == PLAIN:
|
|
175
|
+
raise APIError(status, _plain_error_message(text) or f"HTTP {status}")
|
|
176
|
+
raise parse_api_service_error(status, text, spatial=kind == SPATIAL)
|
|
177
|
+
|
|
178
|
+
if not text.strip():
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
payload = response.json()
|
|
183
|
+
except ValueError as exc:
|
|
184
|
+
raise GeoEngineError("geoengine: failed to parse response JSON") from exc
|
|
185
|
+
|
|
186
|
+
if kind != PLAIN and isinstance(payload, dict) and "success" in payload:
|
|
187
|
+
if not payload["success"]:
|
|
188
|
+
raise parse_api_service_error(status, text, spatial=kind == SPATIAL)
|
|
189
|
+
return payload.get("data")
|
|
190
|
+
|
|
191
|
+
return payload
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _plain_error_message(text: str) -> str:
|
|
195
|
+
"""Extrae el mensaje del servidor ({"error": "..."}) o devuelve el cuerpo tal cual."""
|
|
196
|
+
try:
|
|
197
|
+
parsed = json.loads(text)
|
|
198
|
+
except ValueError:
|
|
199
|
+
return text.strip()
|
|
200
|
+
if isinstance(parsed, dict):
|
|
201
|
+
error = parsed.get("error")
|
|
202
|
+
if isinstance(error, str) and error:
|
|
203
|
+
return error
|
|
204
|
+
if isinstance(error, dict) and error.get("message"):
|
|
205
|
+
return str(error["message"])
|
|
206
|
+
if isinstance(parsed.get("message"), str) and parsed["message"]:
|
|
207
|
+
return parsed["message"]
|
|
208
|
+
return text.strip()
|
geoengine/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.0"
|
geoengine/client.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Cliente principal de GeoEngine."""
|
|
2
|
+
import time
|
|
3
|
+
from typing import Any, Dict, Optional, Sequence
|
|
4
|
+
|
|
5
|
+
import requests
|
|
6
|
+
|
|
7
|
+
from ._http import PLAIN, Transport
|
|
8
|
+
from .errors import ValidationError
|
|
9
|
+
from .geoidentity import GeoIdentityService
|
|
10
|
+
from .ingester import AsyncIngester
|
|
11
|
+
from .models import SpatialGeneralResult, SpatialValidationResult
|
|
12
|
+
from .spatial import SpatialService
|
|
13
|
+
from .users import UsersService
|
|
14
|
+
from .validation import normalize_polygon, validate_device_id
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class GeoEngine:
|
|
18
|
+
"""Cliente de las APIs de GeoEngine. Seguro para usarse desde varios hilos.
|
|
19
|
+
|
|
20
|
+
Módulos: ``spatial`` (validación de puntos), ``users`` (miembros, roles e invitaciones)
|
|
21
|
+
y ``geoidentity`` (reglas de acceso).
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
DEFAULT_MANAGEMENT_URL = "https://management.geoengine.dev"
|
|
25
|
+
DEFAULT_INGEST_URL = "https://ingest.geoengine.dev"
|
|
26
|
+
DEFAULT_API_SERVICE_URL = "https://api.geoengine.dev"
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
api_key: str,
|
|
31
|
+
management_url: Optional[str] = None,
|
|
32
|
+
ingest_url: Optional[str] = None,
|
|
33
|
+
timeout: float = 10,
|
|
34
|
+
*,
|
|
35
|
+
api_service_url: Optional[str] = None,
|
|
36
|
+
base_url: Optional[str] = None,
|
|
37
|
+
environment: str = "live",
|
|
38
|
+
jwt_token: Optional[str] = None,
|
|
39
|
+
session: Optional[requests.Session] = None,
|
|
40
|
+
):
|
|
41
|
+
"""
|
|
42
|
+
Inicializa el cliente de GeoEngine.
|
|
43
|
+
|
|
44
|
+
:param api_key: Tu clave secreta de API.
|
|
45
|
+
:param management_url: URL base para gestión de geocercas (opcional).
|
|
46
|
+
:param ingest_url: URL base para ingestión (opcional).
|
|
47
|
+
:param timeout: Tiempo de espera en segundos para requests (default: 10).
|
|
48
|
+
:param api_service_url: URL base del api-service: Spatial, Users y GeoIdentity (opcional).
|
|
49
|
+
:param base_url: Sobrescribe las tres URLs a la vez; útil para pruebas locales.
|
|
50
|
+
:param environment: "live" (default) o "test"; se envía como X-Geo-Environment.
|
|
51
|
+
:param jwt_token: Token Bearer para operaciones autenticadas (opcional).
|
|
52
|
+
:param session: requests.Session propia (opcional); el cliente no la cierra.
|
|
53
|
+
"""
|
|
54
|
+
if not isinstance(api_key, str) or not api_key.strip():
|
|
55
|
+
raise ValidationError("GeoEngine: API Key es requerida")
|
|
56
|
+
|
|
57
|
+
self.api_key = api_key
|
|
58
|
+
self.management_url = (base_url or management_url or self.DEFAULT_MANAGEMENT_URL).rstrip("/")
|
|
59
|
+
self.ingest_url = (base_url or ingest_url or self.DEFAULT_INGEST_URL).rstrip("/")
|
|
60
|
+
self.api_service_url = (base_url or api_service_url or self.DEFAULT_API_SERVICE_URL).rstrip("/")
|
|
61
|
+
self.environment = environment
|
|
62
|
+
self.timeout = timeout
|
|
63
|
+
|
|
64
|
+
self._transport = Transport(api_key, jwt_token, environment, timeout, session)
|
|
65
|
+
self.spatial = SpatialService(self._transport, self.api_service_url)
|
|
66
|
+
self.users = UsersService(self._transport, self.api_service_url)
|
|
67
|
+
self.geoidentity = GeoIdentityService(self._transport, self.api_service_url)
|
|
68
|
+
|
|
69
|
+
# --- Ingesta y geocercas ---
|
|
70
|
+
|
|
71
|
+
def send_location(self, device_id: str, lat: float, lng: float) -> Optional[Dict[str, Any]]:
|
|
72
|
+
"""Envía la ubicación de un dispositivo. Devuelve la respuesta de la API."""
|
|
73
|
+
payload = {
|
|
74
|
+
"device_id": validate_device_id(device_id),
|
|
75
|
+
"latitude": float(lat),
|
|
76
|
+
"longitude": float(lng),
|
|
77
|
+
"timestamp": int(time.time()),
|
|
78
|
+
}
|
|
79
|
+
return self._transport.request("POST", f"{self.ingest_url}/ingest", payload, PLAIN)
|
|
80
|
+
|
|
81
|
+
def create_geofence(
|
|
82
|
+
self,
|
|
83
|
+
name: str,
|
|
84
|
+
coordinates: Sequence[Sequence[float]],
|
|
85
|
+
webhook_url: Optional[str] = None,
|
|
86
|
+
) -> Optional[Dict[str, Any]]:
|
|
87
|
+
"""Crea una geocerca a partir de pares [lat, lng] (mínimo 3). El polígono se cierra solo."""
|
|
88
|
+
if not isinstance(name, str) or not name.strip():
|
|
89
|
+
raise ValidationError("geoengine: name is required")
|
|
90
|
+
payload: Dict[str, Any] = {
|
|
91
|
+
"name": name.strip(),
|
|
92
|
+
"geojson": {"type": "Polygon", "coordinates": [normalize_polygon(coordinates)]},
|
|
93
|
+
}
|
|
94
|
+
if webhook_url:
|
|
95
|
+
payload["webhook_url"] = webhook_url
|
|
96
|
+
return self._transport.request("POST", f"{self.management_url}/geofences", payload, PLAIN)
|
|
97
|
+
|
|
98
|
+
def create_async_ingester(
|
|
99
|
+
self, buffer_size: int = 5000, batch_size: int = 100, flush_interval: float = 0.5
|
|
100
|
+
) -> AsyncIngester:
|
|
101
|
+
"""Crea una cola de ingesta en segundo plano (flush_interval en segundos)."""
|
|
102
|
+
return AsyncIngester(self, buffer_size, batch_size, flush_interval)
|
|
103
|
+
|
|
104
|
+
# --- Atajos de Spatial ---
|
|
105
|
+
|
|
106
|
+
def validate_spatial_point(self, geofence_id: str, lat: float, lng: float) -> SpatialValidationResult:
|
|
107
|
+
return self.spatial.validate_point(geofence_id, lat, lng)
|
|
108
|
+
|
|
109
|
+
def validate_spatial_general(self, lat: float, lng: float) -> SpatialGeneralResult:
|
|
110
|
+
return self.spatial.validate_general(lat, lng)
|
|
111
|
+
|
|
112
|
+
# --- Ciclo de vida ---
|
|
113
|
+
|
|
114
|
+
def close(self) -> None:
|
|
115
|
+
"""Cierra el cliente. Las llamadas posteriores lanzan ClientClosedError."""
|
|
116
|
+
self._transport.close()
|
|
117
|
+
|
|
118
|
+
def __enter__(self) -> "GeoEngine":
|
|
119
|
+
return self
|
|
120
|
+
|
|
121
|
+
def __exit__(self, *exc_info) -> None:
|
|
122
|
+
self.close()
|