geo-engine-python 0.1.2__tar.gz → 0.2.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.
Files changed (27) hide show
  1. geo_engine_python-0.2.0/PKG-INFO +278 -0
  2. geo_engine_python-0.2.0/README.md +259 -0
  3. geo_engine_python-0.2.0/geo_engine_python.egg-info/PKG-INFO +278 -0
  4. geo_engine_python-0.2.0/geo_engine_python.egg-info/SOURCES.txt +20 -0
  5. geo_engine_python-0.2.0/geoengine/__init__.py +55 -0
  6. geo_engine_python-0.2.0/geoengine/_http.py +208 -0
  7. geo_engine_python-0.2.0/geoengine/_version.py +1 -0
  8. geo_engine_python-0.2.0/geoengine/client.py +122 -0
  9. geo_engine_python-0.2.0/geoengine/errors.py +146 -0
  10. geo_engine_python-0.2.0/geoengine/geoidentity.py +186 -0
  11. geo_engine_python-0.2.0/geoengine/ingester.py +73 -0
  12. geo_engine_python-0.2.0/geoengine/models.py +218 -0
  13. geo_engine_python-0.2.0/geoengine/spatial.py +75 -0
  14. geo_engine_python-0.2.0/geoengine/users.py +72 -0
  15. geo_engine_python-0.2.0/geoengine/validation.py +57 -0
  16. {geo_engine_python-0.1.2 → geo_engine_python-0.2.0}/pyproject.toml +8 -5
  17. geo_engine_python-0.2.0/setup.py +29 -0
  18. geo_engine_python-0.2.0/tests/test_sdk.py +617 -0
  19. geo_engine_python-0.1.2/PKG-INFO +0 -117
  20. geo_engine_python-0.1.2/README.md +0 -103
  21. geo_engine_python-0.1.2/geo_engine_python.egg-info/PKG-INFO +0 -117
  22. geo_engine_python-0.1.2/geo_engine_python.egg-info/SOURCES.txt +0 -8
  23. geo_engine_python-0.1.2/geoengine.py +0 -84
  24. {geo_engine_python-0.1.2 → geo_engine_python-0.2.0}/geo_engine_python.egg-info/dependency_links.txt +0 -0
  25. {geo_engine_python-0.1.2 → geo_engine_python-0.2.0}/geo_engine_python.egg-info/requires.txt +0 -0
  26. {geo_engine_python-0.1.2 → geo_engine_python-0.2.0}/geo_engine_python.egg-info/top_level.txt +0 -0
  27. {geo_engine_python-0.1.2 → geo_engine_python-0.2.0}/setup.cfg +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,259 @@
1
+ # 🐍 Geo-Engine Python SDK
2
+
3
+ > *Read this in Spanish: [README.es.md](./README.es.md)*
4
+
5
+ Official **Python** client for the **Geo-Engine** platform.
6
+ 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.
7
+
8
+ Feature parity with the official Go SDK (`v2.1.0`), except for gRPC transport.
9
+
10
+ ---
11
+
12
+ ## ✨ Features
13
+
14
+ - 📍 **Real-time Ingestion:** Coordinate ingestion via HTTP REST.
15
+ - 🗺️ **Spatial Module:** Point-in-polygon validation against a single geofence or against all geofences.
16
+ - 👥 **Users Module:** Organization members, roles, and email invitations.
17
+ - 🛡️ **GeoIdentity Module:** Access rules (IP blacklists, geographic boundaries, Tor/datacenter blocking) assignable to users or to the entire organization.
18
+ - 🚧 **Geofences:** Creation with automatic GeoJSON polygon normalization.
19
+ - 🔄 **AsyncIngester:** Background queue with auto-flush by batch size and time interval.
20
+ - 🔐 **Security:** HMAC-SHA256 signature (`X-Signature`) with anti-replay timestamp and JWT Bearer token support.
21
+ - 🧯 **Typed Errors** for every business case.
22
+
23
+ ---
24
+
25
+ ## 🚀 Installation
26
+
27
+ ```bash
28
+ pip install geo-engine-python
29
+ ```
30
+
31
+ ---
32
+
33
+ ## ⚡ Quick Start
34
+
35
+ ```python
36
+ from geoengine import GeoEngine
37
+
38
+ # Connects to production environment by default
39
+ client = GeoEngine(api_key="geo_live_123456")
40
+
41
+ try:
42
+ res = client.send_location(device_id="truck-01", lat=19.4326, lng=-99.1332)
43
+ print(f"✅ Sent: {res}")
44
+ except Exception as e:
45
+ print(f"❌ Error: {e}")
46
+ finally:
47
+ client.close()
48
+ ```
49
+
50
+ Also works as a context manager: `with GeoEngine("geo_live_...") as client: ...`
51
+
52
+ ---
53
+
54
+ ## 🗺️ Spatial Module
55
+
56
+ ```python
57
+ from geoengine import GeofenceNotFoundError
58
+
59
+ # Against a specific geofence
60
+ try:
61
+ res = client.spatial.validate_point("geofence_123", 19.4326, -99.1332)
62
+ if res.is_inside():
63
+ print(f"Inside {res.geofence_name} ({res.geofence_id})")
64
+ print(res) # [INSIDE] geofence=geofence_123 (Downtown) at (19.432600, -99.133200)
65
+ except GeofenceNotFoundError:
66
+ print("The geofence does not exist")
67
+
68
+ # Against all active geofences in the organization
69
+ res = client.spatial.validate_general(19.4326, -99.1332)
70
+ if res.has_matches():
71
+ print(res.total_matches, res.geofence_names())
72
+ print(res.contains_geofence("CDMX Downtown"), res.find_match("CDMX Downtown"))
73
+
74
+ # Fluent query builder
75
+ single = client.spatial.check(19.4326, -99.1332).in_geofence("geofence_123").execute()
76
+ general = client.spatial.check(19.4326, -99.1332).in_all_geofences().execute()
77
+ ```
78
+
79
+ ---
80
+
81
+ ## 👥 Users Module
82
+
83
+ `client.users` manages members of the organization that owns the API key:
84
+
85
+ ```python
86
+ from geoengine import Roles, InvitationPendingError, LastAdminError
87
+
88
+ # List members and available roles
89
+ users = client.users.list()
90
+ roles = client.users.list_roles()
91
+ user = client.users.get("user_123")
92
+
93
+ # Invite a new member (GeoEngine sends the invitation email)
94
+ try:
95
+ inv = client.users.invite("ana@acme.com", Roles.VIEWER, lang="en") # "es" (default) | "en" | "pt"
96
+ print("Invitation link:", inv.invite_url)
97
+ except InvitationPendingError:
98
+ print("There is already a pending invitation for this email")
99
+
100
+ # Update a member's role or revoke their access
101
+ try:
102
+ client.users.update_role("user_123", Roles.DISPATCHER)
103
+ client.users.remove("user_123")
104
+ except LastAdminError:
105
+ print("The organization must retain at least one administrator")
106
+ ```
107
+
108
+ ---
109
+
110
+ ## 🛡️ GeoIdentity Module
111
+
112
+ `client.geoidentity` creates access rules and assigns them to a user or to the entire organization:
113
+
114
+ ```python
115
+ from geoengine import RuleAction, SubjectAssignment, SubjectType, polygon_from_lat_lng
116
+
117
+ area = polygon_from_lat_lng([
118
+ [19.40, -99.20], [19.40, -99.10], [19.50, -99.10], # [lat, lng]
119
+ ])
120
+
121
+ rule = client.geoidentity.create_rule(
122
+ "CDMX Only",
123
+ action=RuleAction.BLOCK,
124
+ black_ip_list=["203.0.113.0/24"],
125
+ block_tor=True,
126
+ geo_limit=area,
127
+ assigned_subjects=[SubjectAssignment(SubjectType.USER, "user_123")],
128
+ )
129
+
130
+ # Query, update, and delete
131
+ rules = client.geoidentity.list_rules(is_active=True)
132
+ detail = client.geoidentity.get_rule(rule.id) # includes assigned_subjects
133
+ client.geoidentity.update_rule(rule.id, is_active=False, geo_limit=None) # None removes the geographic boundary
134
+ client.geoidentity.delete_rule(rule.id)
135
+
136
+ # Assign or unassign the rule
137
+ client.geoidentity.assign_to_user(rule.id, "user_456")
138
+ client.geoidentity.assign_to_organization(rule.id)
139
+ client.geoidentity.unassign_from_user(rule.id, "user_456")
140
+
141
+ # Rules that apply to a user (direct, by role, or organization-wide)
142
+ user_rules = client.geoidentity.list_user_rules("user_123")
143
+ ```
144
+
145
+ > 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).
146
+ >
147
+ > `update_rule` only sends the parameters you provide; unspecified parameters remain unchanged.
148
+
149
+ ---
150
+
151
+ ## 🚧 Geofences
152
+
153
+ The SDK takes `[latitude, longitude]` pairs, converts them to GeoJSON, and closes the polygon:
154
+
155
+ ```python
156
+ client.create_geofence(
157
+ "North Logistics Park",
158
+ [[19.4326, -99.1332], [19.4426, -99.1332], [19.4426, -99.1232]],
159
+ webhook_url="https://my-api.com/webhooks/geofences", # optional
160
+ )
161
+ ```
162
+
163
+ ---
164
+
165
+ ## 🔄 Asynchronous Ingestion (`AsyncIngester`)
166
+
167
+ ```python
168
+ from geoengine import LocationPing
169
+
170
+ # 5000 pings queue; flushes every 100 items or every 0.5 s
171
+ ingester = client.create_async_ingester(buffer_size=5000, batch_size=100, flush_interval=0.5)
172
+
173
+ ok = ingester.enqueue(LocationPing("truck-45", 19.4326, -99.1332))
174
+ if not ok:
175
+ print("Queue full or ingester stopped")
176
+
177
+ # On service shutdown: flushes pending items and stops the background thread
178
+ ingester.stop()
179
+ client.close()
180
+ ```
181
+
182
+ Background sending errors are silently dropped.
183
+
184
+ ---
185
+
186
+ ## 🔧 Advanced Configuration
187
+
188
+ ```python
189
+ client = GeoEngine(
190
+ api_key="sk_test_...",
191
+ environment="test", # "live" (default) | "test"
192
+ jwt_token="my.jwt.token", # optional
193
+ timeout=5, # seconds
194
+ ingest_url="http://localhost:8080",
195
+ management_url="http://localhost:8081",
196
+ api_service_url="http://localhost:8082", # Spatial, Users, and GeoIdentity
197
+ # or a single URL for all three (local tests / mocks):
198
+ # base_url="http://localhost:8080",
199
+ )
200
+ ```
201
+
202
+ ---
203
+
204
+ ## 🧯 Errors
205
+
206
+ All errors inherit from `GeoEngineError`.
207
+
208
+ | Error | When it occurs |
209
+ | :--- | :--- |
210
+ | `ValidationError` (also `ValueError`) and subclasses: `DeviceIDRequiredError`, `GeofenceIDRequiredError`, `InvalidLatitudeError`, `InvalidLongitudeError`, `InvalidCoordinatesError`, `UserIDRequiredError`, `RuleIDRequiredError`, `RuleNameRequiredError`, `EmailRequiredError`, `RoleRequiredError` | Invalid parameter; API is not called |
211
+ | `APIError` | Ingestion or management HTTP error (`status_code`, `api_message`) |
212
+ | `APIServiceError` | API service error; adds `code` and `details` |
213
+ | `GeofenceNotFoundError` | Geofence does not exist (Spatial, 204/404) |
214
+ | `UserNotFoundError` | `USER_NOT_FOUND`, `USER_REVOKED` |
215
+ | `RuleNotFoundError` | `RULE_NOT_FOUND` |
216
+ | `UserAlreadyExistsError` | `USER_ALREADY_EXISTS` |
217
+ | `InvitationPendingError` | `INVITATION_PENDING` |
218
+ | `LastAdminError` | `LAST_ADMIN` |
219
+ | `LastSubjectError` | `LAST_SUBJECT` |
220
+ | `AuthenticationError` / `ForbiddenError` / `QuotaExceededError` | 401 / 403 / 402 |
221
+ | `NetworkError`, `RequestTimeoutError` | Network failure or request timeout |
222
+ | `ClientClosedError` | Client used after calling `close()` |
223
+
224
+ The SDK does not automatically retry requests.
225
+
226
+ ---
227
+
228
+ ## 🔐 Authentication
229
+
230
+ The SDK authenticates using **API Keys** and signs each request.
231
+ Keep them secret and **never expose them in frontend applications**.
232
+
233
+ ---
234
+
235
+ ## 🧪 Testing
236
+
237
+ ```bash
238
+ python -m unittest discover -s tests -t .
239
+ ```
240
+
241
+ ---
242
+
243
+ ## 🧩 Compatibility
244
+
245
+ * Python **3.7+**
246
+ * Django, Flask, FastAPI, CLI scripts, Jupyter / Data Science
247
+
248
+ ---
249
+
250
+ ## 📄 License
251
+
252
+ MIT © Geo-Engine
253
+
254
+ ---
255
+
256
+ ## 🤝 Support
257
+
258
+ Have questions or suggestions?
259
+ Open an issue or contact us at **[support@geoengine.dev](mailto:support@geoengine.dev)**