inmotion-sdk 0.2.3__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.
@@ -0,0 +1,18 @@
1
+ __pycache__
2
+ .bsp
3
+ .venv
4
+ .env*
5
+ !.env.example
6
+ .idea
7
+ .python-version
8
+ out
9
+ outputs
10
+ dist
11
+ docs
12
+ uv.lock
13
+ /inmotion-python-sdk.iml
14
+ examples/*.csv
15
+
16
+ # Coding-agent context (kept locally, not committed)
17
+ CLAUDE.md
18
+ .claude/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jason Waring
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,488 @@
1
+ Metadata-Version: 2.5
2
+ Name: inmotion-sdk
3
+ Version: 0.2.3
4
+ Summary: An SDK for integrating with inMotion API
5
+ Project-URL: Homepage, https://github.com/jwaring/inmotion-python-sdk
6
+ Project-URL: Repository, https://github.com/jwaring/inmotion-python-sdk
7
+ Project-URL: Issues, https://github.com/jwaring/inmotion-python-sdk/issues
8
+ Project-URL: Documentation, https://inmotion.io/assets/sdk-docs/inmotion.html
9
+ Author-email: Jason Waring <jason@softwaringsolutions.com>
10
+ License: MIT
11
+ License-File: LICENSE
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Requires-Python: >=3.10
18
+ Requires-Dist: marshmallow
19
+ Requires-Dist: marshmallow-dataclass
20
+ Requires-Dist: pycryptodome
21
+ Requires-Dist: python-dotenv
22
+ Requires-Dist: requests
23
+ Provides-Extra: dev
24
+ Requires-Dist: pdoc; extra == 'dev'
25
+ Requires-Dist: pytest; extra == 'dev'
26
+ Requires-Dist: tox; extra == 'dev'
27
+ Description-Content-Type: text/markdown
28
+
29
+ # inmotion-python-sdk
30
+
31
+ A Python Software Development Kit (SDK) for integration with inMotion APIs.
32
+
33
+ This is still a fledgling project as only a handful of endpoints have been implemented.
34
+
35
+ Full API reference (generated from docstrings): https://inmotion.io/assets/sdk-docs/inmotion.html
36
+
37
+ # Prerequisites
38
+
39
+ * Python 3.10 or higher
40
+ * An inMotion account with either a Dev Key/Secret + API Key pair, or a username/password —
41
+ see "Authenticating" below for where to create these
42
+
43
+ # Install
44
+
45
+ ```bash
46
+ pip install inmotion-sdk
47
+ ```
48
+
49
+ The distribution is `inmotion-sdk`, but the importable package is still `inmotion`:
50
+
51
+ ```python
52
+ import inmotion
53
+ ```
54
+
55
+ # Quickstart
56
+
57
+ Every operation in this SDK hangs off a `session`, obtained from a client. There are two ways to
58
+ authenticate, depending on what credentials you have.
59
+
60
+ ## Authenticating with a Dev Key/Secret + API Key
61
+
62
+ Create a Dev Key/Secret under your inMotion account's `Settings` menu, `Dev Keys` tab (only shown
63
+ if the account allows key creation), and an API Key under the `API Keys` tab (`Consumer` authority
64
+ or higher is recommended). You'll also need your account key.
65
+
66
+ ```python
67
+ from inmotion.apikey_client import InMotionAPIKeyClient
68
+
69
+ client = InMotionAPIKeyClient(
70
+ base_url="https://api.inmotion.io",
71
+ dev_key="...",
72
+ dev_secret="...",
73
+ api_key="...",
74
+ )
75
+ session = client.get_session(account="...")
76
+ ```
77
+
78
+ ## Authenticating with a username and password
79
+
80
+ ```python
81
+ from inmotion.credentials_client import InMotionCredentialsClient
82
+
83
+ client = InMotionCredentialsClient(base_url="https://api.inmotion.io", dev_key="...", dev_secret="...")
84
+ session = client.get_session(account="...", username="...", password="...")
85
+ ```
86
+
87
+ Once you have a `session`, it exposes the feature areas documented below, e.g.
88
+ `session.activities()`, `session.accounts()`, `session.folio()`.
89
+
90
+ # Examples
91
+
92
+ `examples/` has two small, self-contained scripts demonstrating the Site and Track activity APIs:
93
+
94
+ - `site_timeseries_example.py` — generates a synthetic weather-sensor CSV (temperature + humidity),
95
+ then reads it back and uploads it as a Site activity timeseries.
96
+ - `track_timeseries_example.py` — generates a synthetic vehicle-track CSV (lat/lon/altitude +
97
+ speed), then reads it back and uploads it as a Track activity timeseries.
98
+
99
+ Each script both generates its own input data and uploads it, so there's nothing to fetch — they're
100
+ meant to be read top-to-bottom as a concrete illustration of find-or-create-activity plus
101
+ publish-records. To run one:
102
+
103
+ ```bash
104
+ cp examples/.env.example examples/.env # fill in real credentials
105
+ python3 examples/site_timeseries_example.py
106
+ ```
107
+
108
+ # Feature Areas
109
+
110
+ ## Upgrading from an older version
111
+
112
+ If you were already calling `find_track_activity`, `find_site_activity`, `get_track_records`,
113
+ `get_site_records`, `find_activities_within_time_range`, or `find_latest_activity_stats`, note that
114
+ earlier versions of this SDK issued every HTTP request as a `POST`, even for these read-only,
115
+ `GET`-only endpoints. Against a real inMotion server this either silently invoked the wrong
116
+ operation or returned a 404. These methods now issue the correct HTTP verb — no code changes are
117
+ required to call them, but double-check any code that was working around the previous failures.
118
+
119
+ `InMotionCredentialsClient.connect(...)` has been renamed to `get_session(...)` to match
120
+ `InMotionAPIKeyClient`, and now authenticates against the current `/api/latest/authenticate`
121
+ endpoint rather than the deprecated `/api/authenticate`.
122
+
123
+ ## Track and Site Activities
124
+
125
+ In addition to create/update/find/records, the activities interface now supports the full
126
+ lifecycle of a track or site activity:
127
+
128
+ ```python
129
+ activities = session.activities()
130
+
131
+ activities.delete_track_activity(track_key)
132
+ activities.unlock_track_activity(track_key)
133
+ activities.find_all_track_records(track_key) # no time-range restriction
134
+ records_bytes = activities.download_track_records(track_key, "csv") # 'csv', 'json' or 'gpx'
135
+
136
+ activities.share_track_activity(track_key, "any") # or "private"
137
+ activities.unshare_track_activity(track_key, "any")
138
+ activities.find_shared_track_activity(track_key)
139
+ activities.find_all_shared_track_records(track_key)
140
+ ```
141
+
142
+ The same methods exist for sites (`delete_site_activity`, `unlock_site_activity`,
143
+ `find_all_site_records`, `download_site_records`, `share_site_activity`, `unshare_site_activity`,
144
+ `find_shared_site_activity`, `find_shared_site_records`).
145
+
146
+ Cross-account analytics, a batch create/update endpoint, and activity master data are also
147
+ available:
148
+
149
+ ```python
150
+ activities.find_activity_master_data() # profile types and activity types
151
+
152
+ analytics = activities.find_activity_analytics(ActivityAnalyticsRequestModel(groupBy=["activityType"]))
153
+ track_metrics = activities.find_activity_track_metrics(ActivityAnalyticsRequestModel(accounts=[account_key]))
154
+ variable_stats = activities.find_activity_variable_stats(ActivityAnalyticsRequestModel(accounts=[account_key]))
155
+
156
+ activities.find_latest_activity_stats_by_type(since, CoordinateConvention.TRACK)
157
+
158
+ activities.batch_record_update(ActivityBatchCommandsModel(
159
+ tracks=TrackActivityBatchCommandsModel(create=[TrackCreateActivityBatchModel(activity=..., recordInterval=1000)]),
160
+ ))
161
+ ```
162
+
163
+ ## Accounts
164
+
165
+ `session.accounts()` exposes account management operations:
166
+
167
+ ```python
168
+ accounts = session.accounts()
169
+
170
+ account = accounts.find_account(account_key)
171
+ tags = accounts.find_account_tags(account_key)
172
+ accounts.update_account(account_key, AccountModel(name="Acme", address=None, accountType="1", attrs={}, profiles=[]))
173
+
174
+ users = accounts.find_account_users(account_key)
175
+ accounts.register_account_user(account_key, user_key, privileges)
176
+ accounts.unregister_account_user(account_key, user_key)
177
+ accounts.batch_update_account_users(account_key, [AccountUpdateBatchCommandModel(action="register", userName="jdoe", privileges=None)])
178
+
179
+ new_account = accounts.create_account_only(AccountModel(name="New Co", address=None, accountType="I", attrs={}, profiles=[]))
180
+ accounts.mark_account_for_deletion(account_key, and_user=False)
181
+
182
+ accounts.find_my_accounts() # accounts the authenticated user belongs to
183
+ ```
184
+
185
+ It also manages an account's Standard Data Type/Variant Type overrides (submitted as raw YAML
186
+ text, requires the "custom-sdt" account feature) and its Device Config lifecycle (requires the
187
+ "custom-device-config" account feature):
188
+
189
+ ```python
190
+ accounts.list_standard_data_types(account_key)
191
+ accounts.create_standard_data_type(account_key, yaml_document)
192
+ accounts.update_standard_data_type(account_key, key, yaml_document)
193
+ accounts.delete_standard_data_type(account_key, key)
194
+ # ...and the equivalent list/create/update/delete_standard_data_variant_type methods
195
+
196
+ accounts.fetch_device_configs(account_key) # merged, consumer-facing fetch
197
+ accounts.list_device_configs(account_key) # full version history, for an editing UI
198
+ created = accounts.create_device_config(account_key, yaml_document)
199
+ accounts.start_device_config_development(account_key, created["name"])
200
+ accounts.save_device_config(account_key, created["name"], yaml_document)
201
+ accounts.publish_device_config(account_key, created["name"], "1.0.0")
202
+ accounts.withdraw_device_config(account_key, created["name"], 1)
203
+ accounts.republish_device_config(account_key, created["name"], 1)
204
+ accounts.discard_device_config_development(account_key, created["name"])
205
+ accounts.delete_device_config(account_key, created["name"])
206
+
207
+ # System-wide (non-account-scoped) global tier, and a combined sync delta across global + accounts
208
+ accounts.fetch_global_device_configs()
209
+ accounts.sync_device_configs(DeviceConfigSyncRequestModel(accountKeys=[account_key]))
210
+ ```
211
+
212
+ ## Activity Configuration
213
+
214
+ `session.activity_config()` manages the Quality Control, Processing, and Custom Data sections of
215
+ an activity's configuration, and supports bad-period detection for track activities:
216
+
217
+ ```python
218
+ config = session.activity_config()
219
+
220
+ full_config = config.find_activity_config(activity_key)
221
+
222
+ config.update_qc_config(activity_key, ActivityConfigQCUpdateModel(qualityControl=QCConfigModel(regions=[...])))
223
+ config.update_processing_config(activity_key, ActivityConfigProcessingUpdateModel(processing={...}))
224
+ config.update_custom_data_config(activity_key, ActivityConfigCustomDataUpdateModel(entries=[...]))
225
+
226
+ # Workflow: detect candidate bad periods on a track activity, then merge them into the QC config
227
+ detected = config.detect_bad_periods(activity_key, ActivityConfigBadPeriodDetectRequestModel())
228
+ config.merge_bad_periods(activity_key, ActivityConfigBadPeriodMergeRequestModel(
229
+ periods=detected.periods, detectorVersion="v1", dryRun=False))
230
+
231
+ regions = config.generate_qc_regions(activity_key, ActivityConfigQCRegionGenerateRequestModel())
232
+
233
+ config.delete_activity_config(activity_key)
234
+ ```
235
+
236
+ ## Developer Keys and API Keys
237
+
238
+ `session.dev_keys()` and `session.api_keys()` manage the developer key / secret pairs and API keys
239
+ issued against an account:
240
+
241
+ ```python
242
+ dev_keys = session.dev_keys()
243
+ keys = dev_keys.find_dev_keys(account_key)
244
+ new_key = dev_keys.create_dev_key(account_key, AccountDevKeyCreatorModel(name="ci", hmacEnabled=True, expiryOn=None))
245
+ dev_keys.update_dev_key(account_key, new_key.devKey, AccountDevKeyUpdatorModel(name="ci-renamed", hmacEnabled=None))
246
+ dev_keys.delete_dev_key(account_key, new_key.devKey)
247
+
248
+ api_keys = session.api_keys()
249
+ api_key = api_keys.create_api_key(account_key, AccountAPIKeyCreatorModel(name="integration", privs=privileges, expiryOn=None))
250
+ ```
251
+
252
+ ## User Management
253
+
254
+ `session.user()` manages the currently authenticated user, and account-scoped user registration:
255
+
256
+ ```python
257
+ user = session.user()
258
+
259
+ user.find_user_attributes()
260
+ user.update_user_attributes(UserAttributesModel(userName="jdoe", displayName="J Doe", email="j@x.com",
261
+ publicUserName=False, firstName=None, lastName=None,
262
+ avatarUrl=None, attrs={}))
263
+
264
+ # Register a new user and grant them a privilege level ('view', 'contribute', or 'admin') on an account
265
+ user.create_user_against_account(account_key, "view", registration)
266
+
267
+ user.request_password_reset(UserPasswordRequestModel(userNameOrEmail="jdoe"))
268
+ user.unregister_from_account(account_key)
269
+
270
+ # A one-time code key pair, for device pairing/bootstrap flows
271
+ otc = user.create_otc()
272
+ ```
273
+
274
+ ## Uploads
275
+
276
+ `session.upload()` manages file uploads (e.g. track/route/coverage data files) and their
277
+ processing lifecycle:
278
+
279
+ ```python
280
+ upload = session.upload()
281
+
282
+ created = upload.upload_file(account_key, "/path/to/track.gpx", content_type="application/gpx+xml")
283
+ uuid = next(iter(created))
284
+
285
+ upload.find_upload_metadata(uuid)
286
+ upload.update_upload_metadata(uuid, UploadMetadataChangeCommandModel(
287
+ mimeType="application/gpx+xml", nature="track", attributes={}))
288
+
289
+ preview = upload.find_upload_preview(uuid, "track")
290
+ upload.process_upload(uuid) # commit the upload into inMotion once its nature/metadata are set
291
+
292
+ upload.cancel_upload(uuid) # or, once no longer needed:
293
+ upload.delete_upload(uuid)
294
+
295
+ upload.find_uploads(account_key) # all tracked uploads for the account
296
+
297
+ upload.upload_diagnostics(account_key, "/path/to/crash.log") # stored server-side, outside the tracked-upload pipeline
298
+ ```
299
+
300
+ **Note:** `upload_file`'s multipart request signing has been verified against the server's
301
+ signing/verification code (`APIActions.scala`) but not yet against a live inMotion instance —
302
+ test it against `.env.test` before relying on it in production.
303
+
304
+ ## Folio
305
+
306
+ `session.folio()` manages folios: a tree-structured document attached to an account - a versioned
307
+ root plus an arbitrary tree of named sections, each holding items that are either inline
308
+ structured text or references to an Activity, DataStream, or another Folio.
309
+
310
+ ```python
311
+ folio_api = session.folio()
312
+
313
+ root = FolioRootModel(attrs={}, items=[], sections=[])
314
+ f = folio_api.create_folio(FolioModel(name="Site A", description="...", accountKey=account_key, owner=user_key, created=0, root=root))
315
+ folio_api.update_folio(f.key, FolioModel(name="Site A (renamed)", description="...", accountKey=account_key, owner=user_key, created=0, root=root))
316
+ folio_api.find_folio(f.key)
317
+ folio_api.find_folios(account_key, name="Site A")
318
+ folio_api.find_folios_by_reference(account_key, data_stream_key)
319
+
320
+ # Sections and items are addressed by a "/"-separated `path` from the root (omitted = the root itself)
321
+ folio_api.create_section(f.key, FolioSectionCreateModel(name="Sensors", description="..."))
322
+ folio_api.find_section(f.key, path="Sensors", deep=True)
323
+ folio_api.update_section(f.key, FolioSectionUpdateModel(description="Updated"), path="Sensors")
324
+
325
+ folio_api.add_items(f.key, [FolioItemModel(kind="dataStream", name="Reading 1", dataStreamKey=ds_key, owned=True)], path="Sensors")
326
+ folio_api.update_item(f.key, "Reading 1", FolioItemModel(kind="text", name="Reading 1", format="YAML", content="..."), path="Sensors")
327
+ folio_api.delete_item(f.key, "Reading 1", path="Sensors")
328
+ folio_api.delete_items(f.key, ["Reading 2"], path="Sensors", cascade=True)
329
+
330
+ folio_api.delete_section(f.key, path="Sensors", cascade=True)
331
+ folio_api.validate_folio(f.key) # check against the folio's optional template, if any
332
+
333
+ folio_api.delete_folio(f.key)
334
+ ```
335
+
336
+ ## Shapes
337
+
338
+ `session.shape()` manages shapes: a named, classified collection of polygons (which may have
339
+ holes/islands), stored as a single GeoJSON FeatureCollection, and owned directly by an account
340
+ (not gated by Folio's role/contributor model).
341
+
342
+ ```python
343
+ shape_api = session.shape()
344
+
345
+ s = shape_api.create_shape(ShapeModel(name="Field 12", accountKey=account_key, geojson=geojson_str))
346
+ shape_api.find_shapes(account_key, classification="Boundary")
347
+ shape_api.find_shape(s.key)
348
+
349
+ shape_api.update_shape(s.key, ShapeUpdateModel(name="Field 12 (renamed)"))
350
+ shape_api.update_shape_geometry(s.key, ShapeGeometryModel(geojson=updated_geojson_str))
351
+
352
+ shape_api.delete_shape(s.key)
353
+ ```
354
+
355
+ A track activity's GPS records can also be converted into a standalone Route shape (gated by the
356
+ "track-to-shape" account feature) via `session.activities().convert_track_to_route(track_key)`,
357
+ which returns the new shape's key.
358
+
359
+ ## Events
360
+
361
+ `session.events()` manages events: an Activity peer of Track/Site whose payload is arbitrary
362
+ (photo, sqlite file, diagnostics, ...) rather than structured data - a single point in space/time,
363
+ optionally carrying a thumbnail/icon.
364
+
365
+ ```python
366
+ events = session.events()
367
+
368
+ location = EventLocationModel(latitude=51.5, longitude=-0.1, timeUtc=int(time.time() * 1000))
369
+ event = events.create_event(EventCreatorModel(dataStream=data_stream_creator, location=location))
370
+
371
+ events.update_event(event.dataStream.key, EventUpdateModel(dataStream=data_stream_creator))
372
+ events.find_event(event.dataStream.key)
373
+ events.unlock_event(event.dataStream.key)
374
+
375
+ events.find_events(DataStreamFilterModel(accounts=[account_key]))
376
+ events.find_nearby_events(EventNearbyFilterModel(
377
+ accounts=[account_key], minTime=0, maxTime=int(time.time() * 1000),
378
+ minLatitude=51.0, maxLatitude=52.0, minLongitude=-1.0, maxLongitude=1.0))
379
+
380
+ events.delete_event(event.dataStream.key)
381
+ ```
382
+
383
+ ## Data Streams
384
+
385
+ `session.data_stream()` manages data streams and their two kinds of data channel: hyperslab
386
+ (array/gridded numeric data) and blob (byte-oriented data, e.g. images or arbitrary binary blobs).
387
+
388
+ ```python
389
+ ds_api = session.data_stream()
390
+
391
+ ds = ds_api.create_data_stream(DataStreamCreatorModel(
392
+ name="Weather Station 1", description="...", account=account_key, owner=user_key, tags=[],
393
+ sourceIdentifier="ws1", sourceCategory="weather", sourceProfile="standard", sourceName="WS1",
394
+ acqConv="raw", coordConv="wgs84", timezone="UTC", attrs={}, created=0))
395
+
396
+ ds_api.find_data_stream(ds.key)
397
+ ds_api.find_data_streams(DataStreamFilterModel(accounts=[account_key]))
398
+ ds_api.find_data_streams_by_name(account_key, "Weather")
399
+
400
+ # Hyperslab (numeric) channels
401
+ ds_api.create_hyperslab_channel(ds.key, DataChannelCreatorModel(
402
+ channelType="temperature", profiles={}, unlimitedDim="time", fixedDims={}, vars={}, created=0))
403
+ ds_api.update_invariant_hyperslab_data(ds.key, "temperature", {"units": "celsius"})
404
+ ds_api.update_hyperslab_record_data(ds.key, "temperature", {"timeUtc": [...], "value": [...]})
405
+ ds_api.find_hyperslab_record_data(ds.key, "temperature", start, end)
406
+
407
+ # Blob (byte-oriented) channels
408
+ ds_api.create_blob_channel(ds.key, DataChannelCreatorModel(
409
+ channelType="image", profiles={}, unlimitedDim=None, fixedDims={}, vars={}, created=0))
410
+ ds_api.update_invariant_blob_data(ds.key, "image", "raw", image_bytes)
411
+ ds_api.find_latest_blob_record_data(ds.key, "image", "raw")
412
+ raw_bytes = ds_api.open_blob_stream(ds.key, blob_key)
413
+
414
+ ds_api.find_blobs(DataStreamFilterModel(accounts=[account_key]))
415
+ ds_api.unlock_data_stream(ds.key)
416
+ ds_api.delete_data_stream(ds.key)
417
+ ```
418
+
419
+ **Note:** the hyperslab data endpoints (`find`/`update_invariant_hyperslab_data`,
420
+ `find`/`update_hyperslab_record_data`) and the three channel management calls
421
+ (`create`/`update`/`delete_hyperslab_channel`, `create`/`update`/`delete_blob_channel`) return a raw
422
+ `dict` rather than a typed model — the server itself has no fixed schema for these (the shape is
423
+ derived per data-channel variable definition), so a fixed dataclass here would be guessing a schema
424
+ the server doesn't have. The blob byte-upload methods (`update_invariant_blob_data`,
425
+ `update_blob_record_data`) have been verified against the server's signing code but not yet against
426
+ a live inMotion instance — test them against `.env.test` before relying on them in production.
427
+
428
+ # Development
429
+
430
+ The following sections are for contributing to the SDK itself, not for consuming it.
431
+
432
+ ## Build
433
+
434
+ To build the SDK, you can use the following command:
435
+
436
+ ```bash
437
+ uv build
438
+ ```
439
+
440
+ ## Generate Docs
441
+
442
+ Static HTML API docs, generated from the package's docstrings via [pdoc](https://pdoc.dev/):
443
+
444
+ ```bash
445
+ bin/generate-docs.sh
446
+ ```
447
+
448
+ Output lands in `docs/` by default (override with `INMOTION_DOCS_DIR`); it's gitignored since
449
+ it's a generated artifact, not source.
450
+
451
+ ## Unit Tests
452
+
453
+ The `tests/` directory contains a `pytest`-based unit test suite covering request signing, error
454
+ handling, and the API key / credentials client authentication flows. These tests mock all HTTP
455
+ calls, so no live inMotion environment is required.
456
+
457
+ ```bash
458
+ source .venv/bin/activate
459
+ uv pip install -e ".[dev]"
460
+ python -m pytest tests/
461
+ ```
462
+
463
+ ## Integration Tests
464
+
465
+ * Ensure that there is an inMotion integration test environment available.
466
+ * Configure the environment file (`.env.test` in the root directory) with the necessary credentials and URLs.
467
+
468
+ ```dotenv
469
+ BASE_URL="http://localhost:9000"
470
+ DEV_KEY="xxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
471
+ DEV_SECRET="xxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
472
+ API_KEY="xxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
473
+ ACCOUNT="xxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
474
+ ```
475
+
476
+ The `DEV_KEY`, `DEV_SECRET` can be created using the inMotion `Settings ...` menu under the right hand side. If the account allows the
477
+ creation of keys, a tab called `Dev Keys` will be shown. Create the key / secret and copy the values.
478
+
479
+ The `API_KEY` can be created using the `API Keys` tab. The `ACCOUNT` key and will need to be copied. Note that it is recommended that the
480
+ API Key be at least `Consumer` authority to support the text.
481
+
482
+ Please run the tests in a virtual environment to avoid dependency conflicts.
483
+
484
+ ```bash
485
+ source .venv/bin/activate
486
+ uv pip install -e .
487
+ python3 scripts/test.py
488
+ ```