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.
@@ -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,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (78.1.1)
2
+ Generator: setuptools (84.2.1)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
geoengine/__init__.py CHANGED
@@ -1 +1,55 @@
1
- from .client import GeoEngine, GeoEngineError
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()