lexicon-python 0.2.0__tar.gz → 0.4.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 (47) hide show
  1. {lexicon_python-0.2.0/src/lexicon_python.egg-info → lexicon_python-0.4.0}/PKG-INFO +167 -44
  2. {lexicon_python-0.2.0 → lexicon_python-0.4.0}/README.md +165 -42
  3. {lexicon_python-0.2.0 → lexicon_python-0.4.0}/pyproject.toml +22 -2
  4. lexicon_python-0.4.0/src/lexicon/__init__.py +35 -0
  5. {lexicon_python-0.2.0 → lexicon_python-0.4.0}/src/lexicon/client.py +55 -4
  6. lexicon_python-0.4.0/src/lexicon/colors.py +29 -0
  7. {lexicon_python-0.2.0 → lexicon_python-0.4.0}/src/lexicon/resources/_common_types.py +102 -53
  8. {lexicon_python-0.2.0 → lexicon_python-0.4.0}/src/lexicon/resources/base.py +3 -1
  9. {lexicon_python-0.2.0 → lexicon_python-0.4.0}/src/lexicon/resources/playlist_tracks.py +17 -13
  10. {lexicon_python-0.2.0 → lexicon_python-0.4.0}/src/lexicon/resources/playlists.py +121 -31
  11. {lexicon_python-0.2.0 → lexicon_python-0.4.0}/src/lexicon/resources/playlists_types.py +39 -19
  12. {lexicon_python-0.2.0 → lexicon_python-0.4.0}/src/lexicon/resources/tag_categories.py +28 -21
  13. lexicon_python-0.4.0/src/lexicon/resources/tag_categories_types.py +23 -0
  14. {lexicon_python-0.2.0 → lexicon_python-0.4.0}/src/lexicon/resources/tags.py +10 -6
  15. {lexicon_python-0.2.0 → lexicon_python-0.4.0}/src/lexicon/resources/tags_types.py +12 -6
  16. {lexicon_python-0.2.0 → lexicon_python-0.4.0}/src/lexicon/resources/tracks.py +425 -53
  17. {lexicon_python-0.2.0 → lexicon_python-0.4.0}/src/lexicon/resources/tracks_types.py +421 -106
  18. {lexicon_python-0.2.0 → lexicon_python-0.4.0}/src/lexicon/tools/__init__.py +0 -2
  19. {lexicon_python-0.2.0 → lexicon_python-0.4.0}/src/lexicon/tools/playlists.py +5 -1
  20. lexicon_python-0.4.0/src/lexicon/tools/tempo.py +74 -0
  21. lexicon_python-0.4.0/src/lexicon/tools/tracks.py +130 -0
  22. {lexicon_python-0.2.0 → lexicon_python-0.4.0}/src/lexicon/utils.py +1 -0
  23. {lexicon_python-0.2.0 → lexicon_python-0.4.0/src/lexicon_python.egg-info}/PKG-INFO +167 -44
  24. {lexicon_python-0.2.0 → lexicon_python-0.4.0}/src/lexicon_python.egg-info/SOURCES.txt +7 -0
  25. {lexicon_python-0.2.0 → lexicon_python-0.4.0}/tests/test_base.py +7 -2
  26. {lexicon_python-0.2.0 → lexicon_python-0.4.0}/tests/test_client.py +61 -17
  27. lexicon_python-0.4.0/tests/test_colors.py +34 -0
  28. {lexicon_python-0.2.0 → lexicon_python-0.4.0}/tests/test_common_types.py +1 -1
  29. lexicon_python-0.4.0/tests/test_integration_lifecycle.py +366 -0
  30. {lexicon_python-0.2.0 → lexicon_python-0.4.0}/tests/test_playlist_tracks.py +25 -10
  31. {lexicon_python-0.2.0 → lexicon_python-0.4.0}/tests/test_playlists.py +173 -36
  32. {lexicon_python-0.2.0 → lexicon_python-0.4.0}/tests/test_playlists_types.py +4 -1
  33. {lexicon_python-0.2.0 → lexicon_python-0.4.0}/tests/test_tag_categories.py +42 -19
  34. {lexicon_python-0.2.0 → lexicon_python-0.4.0}/tests/test_tags.py +4 -1
  35. lexicon_python-0.4.0/tests/test_tools_tempo.py +79 -0
  36. lexicon_python-0.4.0/tests/test_tools_tracks.py +103 -0
  37. {lexicon_python-0.2.0 → lexicon_python-0.4.0}/tests/test_tracks.py +786 -118
  38. lexicon_python-0.2.0/src/lexicon/__init__.py +0 -8
  39. lexicon_python-0.2.0/src/lexicon/resources/tag_categories_types.py +0 -18
  40. {lexicon_python-0.2.0 → lexicon_python-0.4.0}/LICENSE +0 -0
  41. {lexicon_python-0.2.0 → lexicon_python-0.4.0}/setup.cfg +0 -0
  42. {lexicon_python-0.2.0 → lexicon_python-0.4.0}/src/lexicon/resources/__init__.py +0 -0
  43. {lexicon_python-0.2.0 → lexicon_python-0.4.0}/src/lexicon/resources/playlist_tracks_types.py +0 -0
  44. {lexicon_python-0.2.0 → lexicon_python-0.4.0}/src/lexicon_python.egg-info/dependency_links.txt +0 -0
  45. {lexicon_python-0.2.0 → lexicon_python-0.4.0}/src/lexicon_python.egg-info/requires.txt +0 -0
  46. {lexicon_python-0.2.0 → lexicon_python-0.4.0}/src/lexicon_python.egg-info/top_level.txt +0 -0
  47. {lexicon_python-0.2.0 → lexicon_python-0.4.0}/tests/test_tools_playlists.py +0 -0
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lexicon-python
3
- Version: 0.2.0
3
+ Version: 0.4.0
4
4
  Summary: Python client for the Lexicon DJ API
5
5
  Author-email: Garrison Burger <burgerga123@gmail.com>
6
6
  License-Expression: MIT
7
7
  Project-URL: Homepage, https://github.com/photonicvelocity/lexicon-python
8
- Requires-Python: >=3.9
8
+ Requires-Python: >=3.10
9
9
  Description-Content-Type: text/markdown
10
10
  License-File: LICENSE
11
11
  Requires-Dist: InquirerPy>=0.3
@@ -17,9 +17,9 @@ Dynamic: license-file
17
17
 
18
18
  Python client for the Lexicon DJ Local API.
19
19
 
20
- This SDK wraps the Lexicon Local API with resource groups, sensible defaults, and
21
- optional validation. It is designed for scripting library automation, playlist
22
- management, and metadata edits while keeping a clean escape hatch to the raw API.
20
+ This SDK wraps the Lexicon Local API with resource groups, sensible defaults, and optional validation. It is designed
21
+ for scripting library automation, playlist management, and metadata edits while keeping a clean escape hatch to the raw
22
+ API.
23
23
 
24
24
  ## Features
25
25
 
@@ -41,28 +41,27 @@ management, and metadata edits while keeping a clean escape hatch to the raw API
41
41
  pip install lexicon-python
42
42
  ```
43
43
 
44
- Optional (for interactive playlist chooser):
45
-
46
- ```bash
47
- pip install InquirerPy
48
- ```
49
-
50
44
  ## Quickstart
51
45
 
52
46
  ```python
53
47
  from lexicon import Lexicon
54
48
 
55
- lex = Lexicon()
49
+ lex = Lexicon() # verifies the API is reachable; raises LexiconConnectionError otherwise
50
+ playlist = lex.playlists.choose()
56
51
 
57
- # list tracks (default fields)
52
+ # list tracks (default fields: id, artist, title, bpm, key, ...)
58
53
  tracks = lex.tracks.list(limit=10) or []
59
54
  for t in tracks:
60
55
  print(t.get("artist"), "-", t.get("title"))
61
56
 
62
- # search
57
+ # search — pass fields=[...] for a narrow projection, or fields="all" for the
58
+ # full payload (tempomarkers, cuepoints, tags, etc.)
63
59
  results = lex.tracks.search({"artist": "Daft Punk"}) or []
64
60
  print("matches:", len(results))
65
61
 
62
+ # get a single track by id — always returns the full record
63
+ full = lex.tracks.get(123)
64
+
66
65
  # get a playlist by path
67
66
  playlist = lex.playlists.get_by_path(["Genres", "Drum & Bass"], playlist_type="folder")
68
67
  print(playlist)
@@ -86,6 +85,8 @@ Other options:
86
85
  - `default_timeout`: request timeout in seconds
87
86
  - `session`: optional `requests.Session`
88
87
  - `raise_on_error`: raise HTTP errors instead of returning None
88
+ - `verify_connection` (default `True`): probe the API on construction and raise `LexiconConnectionError` if Lexicon
89
+ isn't reachable. Set to `False` to build a client without a running Lexicon (e.g. for tests).
89
90
 
90
91
  Environment variables:
91
92
 
@@ -98,8 +99,8 @@ export LEXICON_PORT=48624
98
99
 
99
100
  Many methods accept a `validation` parameter with three modes:
100
101
 
101
- - `"warn"` (default): invalid inputs are skipped with a warning. This avoids API
102
- failures, but intended changes may be ignored.
102
+ - `"warn"` (default): invalid inputs are skipped with a warning. This avoids API failures, but intended changes may be
103
+ ignored.
103
104
  - `"strict"`: invalid inputs raise `ValueError`.
104
105
  - `"off"`: skips normalization and sends inputs as-is (inputs must match API-native shapes)
105
106
 
@@ -113,9 +114,8 @@ lex.tracks.search({"rating": "bad"}, validation="off") # sends as-is
113
114
 
114
115
  ## Convenience vs Raw API
115
116
 
116
- This SDK adds several quality-of-life behaviors on top of the raw Lexicon API.
117
- In general, response shapes are unwrapped (e.g., `"data": {...}` is removed and
118
- single-item lists are collapsed to a single dict).
117
+ This SDK adds several quality-of-life behaviors on top of the raw Lexicon API. In general, response shapes are unwrapped
118
+ (e.g., `"data": {...}` is removed and single-item lists are collapsed to a single dict).
119
119
 
120
120
  ### Additional Methods (Not in the Raw API)
121
121
 
@@ -128,28 +128,25 @@ single-item lists are collapsed to a single dict).
128
128
 
129
129
  ### Input Normalization (Broader Accepted Inputs)
130
130
 
131
- - **ID normalization**: Many methods accept lists of IDs instead of a single ID
132
- for easier batch operations.
133
- - **Field selection**: By default, `tracks.list()` and `tracks.search()` return a
134
- minimal set of fields rather than the full payload:
135
- - `id`, `artist`, `title`, `albumTitle`, `bpm`, `key`, `duration`, `year`
136
- - Fields used as a search filter or sort item are also returned.
131
+ - **ID normalization**: Many methods accept lists of IDs instead of a single ID for easier batch operations.
132
+ - **Field selection**: By default, `tracks.list()` and `tracks.search()` return a minimal set of fields rather than the
133
+ full payload:
134
+ - `id`, `artist`, `title`, `albumTitle`, `bpm`, `key`, `duration`, `year`
135
+ - Fields used as a search filter or sort item are also returned.
137
136
  - **Search filter normalization**:
138
137
  - Text fields accept `None` (becomes `"NONE"` in filter context).
139
138
  - Numeric filters accept `None` (becomes `"0"`).
140
- - Date filters accept `YYYY-MM-DD`, full datetime strings, or `datetime.date` /
141
- `datetime.datetime` inputs (time is stripped).
142
- - Comparisons (`>YYYY-MM-DD`) are warned/blocked because the API currently ignores them.
139
+ - Date filters accept `YYYY-MM-DD`, full datetime strings, or `datetime.date` / `datetime.datetime` inputs (time is
140
+ stripped).
141
+ - Comparisons (`>YYYY-MM-DD`) are warned/blocked because the API currently ignores them.
143
142
  - **Sort normalization**:
144
143
  - Accepts tuple shorthand: `[("title", "asc")]`.
145
144
  - **Track update helpers**:
146
- - Cuepoint and tempomarker entries are normalized (e.g., cuepoint type accepts
147
- name/number variants).
145
+ - Cuepoint and tempomarker entries are normalized (e.g., cuepoint type accepts name/number variants).
148
146
  - Invalid entries can be skipped in `"warn"` mode without failing the update.
149
147
 
150
- If no SDK normalization is desired, use `validation="off"` and pass
151
- API-native payloads. API-native shapes are also accepted in `"warn"`/`"strict"`;
152
- those modes simply add normalization/validation on top. For fully raw access,
148
+ If no SDK normalization is desired, use `validation="off"` and pass API-native payloads. API-native shapes are also
149
+ accepted in `"warn"`/`"strict"`; those modes simply add normalization/validation on top. For fully raw access,
153
150
  `lex.request(...)` can always be called directly.
154
151
 
155
152
  ## Tracks
@@ -165,23 +162,60 @@ tracks = lex.tracks.search({"artist": "Daft Punk"})
165
162
  added = lex.tracks.add(["/path/to/file1.mp3", "/path/to/file2.mp3"])
166
163
  updated = lex.tracks.update(123, {"title": "New Title"})
167
164
  lex.tracks.delete([123, 456])
165
+
166
+ # Replace tempomarkers while keeping cuepoints anchored to the same beats
167
+ lex.tracks.update_tempogrid(123, [{"startTime": 0.0, "bpm": 124.0}])
168
+
169
+ # Start Lexicon's analyzer on specific tracks
170
+ lex.tracks.analyze([123, 456], analyze_beatgrid=True, do_not_overwrite=False)
168
171
  ```
169
172
 
170
173
  Notes:
171
174
 
172
175
  - `tracks.search()` results are capped at 1000 by the API.
173
176
  - `tracks.get_many()` preserves input order and returns `None` for missing IDs.
174
- - `fields=None` returns a minimal default set of fields.
175
- - In `validation="off"` mode, `fields=None` returns full payloads (API-default)
176
- - `fields="all"` or `fields="*"` requests full payloads.
177
- - `tracks.add()` returns track dicts, but analysis fields (tempo markers, key, etc.)
178
- may be populated later by Lexicon.
177
+ - `tracks.get()` always returns the full record (no `fields` parameter).
178
+ - For `tracks.list()` / `tracks.search()`:
179
+ - `fields=None` returns a minimal default set.
180
+ - In `validation="off"` mode, `fields=None` returns the full API payload.
181
+ - Pass a list of field names for a narrow projection (`fields=["id", "location"]`).
182
+ - `fields="all"` or `fields="*"` requests the full payload.
183
+ - Comma-separated strings (e.g. `"id,location"`) are _not_ accepted; pass a list. With `validation="warn"` the call
184
+ falls back to the default field set and logs.
185
+ - `tracks.add()` returns track dicts, but analysis fields (tempo markers, key, etc.) may be populated later by Lexicon.
186
+ - `tracks.update_tempogrid()` replaces tempomarkers while preserving each cuepoint's beat position by rewriting
187
+ `startTime`/`endTime` against the new grid. Use `tracks.update(track_id, {"tempomarkers": [...]})` directly if you
188
+ want the raw field replacement (cuepoints' seconds stay put, beats shift).
189
+ - `tracks.analyze()` returns as soon as Lexicon accepts the request. Analysis runs in the background and the API has no
190
+ completion signal, so poll `tracks.get()` and watch `tempomarkers`, `bpm`, `key`, or `dateModified`.
191
+ - `tracks.analyze(tempo_range=...)` takes one of the eight ids Lexicon's analyze dropdown stores: `"0_300"` (Any),
192
+ `"58_115"`, `"68_135"`, `"78_155"`, `"88_175"`, `"98_195"`, `"128_255"`, `"180_300"` (180+). An equivalent
193
+ `(min, max)` tuple works too. The value is written to Lexicon's stored analysis setting and persists after the call;
194
+ the boolean options apply to that run only.
195
+
196
+ ### Track Payload Shapes
197
+
198
+ Rich-payload fields like `cuepoints` and `tempomarkers` aren't in the default field set; request them explicitly
199
+ (`fields=["cuepoints", "tempomarkers"]`) or ask for everything (`fields="all"`). Their shapes are typed via
200
+ `CuePointResponse` and `TempoMarkerResponse`, both re-exported from `lexicon`:
201
+
202
+ ```python
203
+ from lexicon import TrackResponse, TempoMarkerResponse, CuePointResponse
204
+ ```
205
+
206
+ Color values (cuepoint/tag/etc. `color` fields) are Lexicon-specific name strings declared by the `Color` literal type.
207
+ To convert a name to RGB:
208
+
209
+ ```python
210
+ from lexicon import color_rgb
211
+
212
+ color_rgb("red_dark") # → (158, 15, 7)
213
+ ```
179
214
 
180
215
  ### Track Search, Filters, and Sort
181
216
 
182
- `tracks.search(filter=...)` accepts a dict of field names and values. The SDK
183
- validates fields/values in `"warn"`/`"strict"` modes and can send API-native values
184
- in `"off"` mode.
217
+ `tracks.search(filter=...)` accepts a dict of field names and values. The SDK validates fields/values in
218
+ `"warn"`/`"strict"` modes and can send API-native values in `"off"` mode.
185
219
 
186
220
  Examples:
187
221
 
@@ -239,6 +273,7 @@ Playlist type accepts:
239
273
  - `1`, `2`, `3` (or string numerals `"1"`, `"2"`, `"3"`)
240
274
 
241
275
  ### Playlist tracks helpers
276
+
242
277
  For getting the tracks of a playlist or editing the tracklist.
243
278
 
244
279
  ```python
@@ -292,6 +327,7 @@ print(path)
292
327
  ```
293
328
 
294
329
  ## Raw Requests (Escape Hatch)
330
+
295
331
  The API can always be accessed directly with `lex.request`:
296
332
 
297
333
  ```python
@@ -310,8 +346,8 @@ High level namespaces (full mapping in `docs/resource-map.md`):
310
346
 
311
347
  ## Type Hints
312
348
 
313
- The SDK includes TypedDict and Literal types for payloads and enums. These are
314
- intended to improve editor autocomplete and static checks.
349
+ The SDK includes TypedDict and Literal types for payloads and enums. These are intended to improve editor autocomplete
350
+ and static checks.
315
351
 
316
352
  For full payload schemas and endpoint details, refer to the Lexicon API docs:
317
353
 
@@ -320,10 +356,97 @@ For full payload schemas and endpoint details, refer to the Lexicon API docs:
320
356
 
321
357
  ## Development
322
358
 
359
+ ### Setup
360
+
361
+ #### Pip
362
+
363
+ Ensure that you have Python 3.9+ installed locally. To install all runtime and dev dependencies into a local virtual
364
+ environment using pip:
365
+
366
+ ```bash
367
+ python -m venv .venv
368
+ source .venv/bin/activate
369
+ pip install -e ".[dev]"
370
+ ```
371
+
372
+ #### uv
373
+
374
+ To use [uv](https://docs.astral.sh/uv/) to install all runtime and dev dependencies into a local virtual environment,
375
+ simply install `uv` and run:
376
+
323
377
  ```bash
324
- ./.venv/bin/python -m pytest --cov --cov-branch --cov-report=term-missing
378
+ uv sync --dev
325
379
  ```
326
380
 
381
+ The lockfile (`uv.lock`) is checked in to ensure reproducible installs.
382
+
383
+ ### Running Tests
384
+
385
+ ```bash
386
+ # Unit tests
387
+ make run-tests
388
+
389
+ # Integration tests (requires Lexicon running)
390
+ # Note: Integration tests enforce an empty library state to avoid destructive edits on existing libraries.
391
+ # The fixture setup will back up the existing library, clear it for testing, and restore it afterward.
392
+ make run-integration-tests
393
+ ```
394
+
395
+ ### Linting and Formatting
396
+
397
+ The project uses [ruff](https://docs.astral.sh/ruff/) for both linting and formatting.
398
+
399
+ `make test` runs the full suite: format, lint (with auto-fix), then tests. `make fix` runs all auto-fixers (lint +
400
+ format) without running tests.
401
+
402
+ ```bash
403
+ make test # format-fix → lint-fix → format-check → lint-check → tests
404
+ make fix # lint-fix → format-fix
405
+ make clean # remove __pycache__, .pytest_cache, .ruff_cache, etc.
406
+ ```
407
+
408
+ If you want to run the linters or formatters manually, you can use the following commands:
409
+
410
+ ```bash
411
+ make lint-check # check for lint issues
412
+ make lint-fix # auto-fix lint issues
413
+ make format-check # check formatting
414
+ make format-fix # auto-fix formatting
415
+ ```
416
+
417
+ ### Pre-commit hook (recommended)
418
+
419
+ The repo ships a [pre-commit](https://pre-commit.com/) config (`.pre-commit-config.yaml`) that mirrors the CI lint job.
420
+ After cloning, install the hook once:
421
+
422
+ ```bash
423
+ uv tool install pre-commit # or: pipx install pre-commit
424
+ pre-commit install
425
+ ```
426
+
427
+ `git commit` now runs `ruff format` + `ruff check --fix` on staged files and blocks the commit on any failure. Run
428
+ against the whole repo any time with:
429
+
430
+ ```bash
431
+ pre-commit run --all-files
432
+ ```
433
+
434
+ The ruff version is pinned in `.pre-commit-config.yaml`; bump it when CI bumps to keep them aligned.
435
+
436
+ ### CI
437
+
438
+ GitHub Actions runs on every push to `main` and on pull requests targeting those branches. The pipeline includes:
439
+
440
+ - **Tests** across Python 3.9, 3.10, 3.11, and 3.12
441
+ - **Lint and format checks** via ruff on Python 3.12
442
+
443
+ Before opening a PR, make sure `make test` passes locally.
444
+
445
+ ### Pull Requests
446
+
447
+ A PR template is provided at `.github/pull_request_template.md`. When opening a PR, fill in the description, check the
448
+ relevant change-type boxes, and confirm testing/checklist items.
449
+
327
450
  ## License
328
451
 
329
452
  MIT (see `LICENSE`).
@@ -2,9 +2,9 @@
2
2
 
3
3
  Python client for the Lexicon DJ Local API.
4
4
 
5
- This SDK wraps the Lexicon Local API with resource groups, sensible defaults, and
6
- optional validation. It is designed for scripting library automation, playlist
7
- management, and metadata edits while keeping a clean escape hatch to the raw API.
5
+ This SDK wraps the Lexicon Local API with resource groups, sensible defaults, and optional validation. It is designed
6
+ for scripting library automation, playlist management, and metadata edits while keeping a clean escape hatch to the raw
7
+ API.
8
8
 
9
9
  ## Features
10
10
 
@@ -26,28 +26,27 @@ management, and metadata edits while keeping a clean escape hatch to the raw API
26
26
  pip install lexicon-python
27
27
  ```
28
28
 
29
- Optional (for interactive playlist chooser):
30
-
31
- ```bash
32
- pip install InquirerPy
33
- ```
34
-
35
29
  ## Quickstart
36
30
 
37
31
  ```python
38
32
  from lexicon import Lexicon
39
33
 
40
- lex = Lexicon()
34
+ lex = Lexicon() # verifies the API is reachable; raises LexiconConnectionError otherwise
35
+ playlist = lex.playlists.choose()
41
36
 
42
- # list tracks (default fields)
37
+ # list tracks (default fields: id, artist, title, bpm, key, ...)
43
38
  tracks = lex.tracks.list(limit=10) or []
44
39
  for t in tracks:
45
40
  print(t.get("artist"), "-", t.get("title"))
46
41
 
47
- # search
42
+ # search — pass fields=[...] for a narrow projection, or fields="all" for the
43
+ # full payload (tempomarkers, cuepoints, tags, etc.)
48
44
  results = lex.tracks.search({"artist": "Daft Punk"}) or []
49
45
  print("matches:", len(results))
50
46
 
47
+ # get a single track by id — always returns the full record
48
+ full = lex.tracks.get(123)
49
+
51
50
  # get a playlist by path
52
51
  playlist = lex.playlists.get_by_path(["Genres", "Drum & Bass"], playlist_type="folder")
53
52
  print(playlist)
@@ -71,6 +70,8 @@ Other options:
71
70
  - `default_timeout`: request timeout in seconds
72
71
  - `session`: optional `requests.Session`
73
72
  - `raise_on_error`: raise HTTP errors instead of returning None
73
+ - `verify_connection` (default `True`): probe the API on construction and raise `LexiconConnectionError` if Lexicon
74
+ isn't reachable. Set to `False` to build a client without a running Lexicon (e.g. for tests).
74
75
 
75
76
  Environment variables:
76
77
 
@@ -83,8 +84,8 @@ export LEXICON_PORT=48624
83
84
 
84
85
  Many methods accept a `validation` parameter with three modes:
85
86
 
86
- - `"warn"` (default): invalid inputs are skipped with a warning. This avoids API
87
- failures, but intended changes may be ignored.
87
+ - `"warn"` (default): invalid inputs are skipped with a warning. This avoids API failures, but intended changes may be
88
+ ignored.
88
89
  - `"strict"`: invalid inputs raise `ValueError`.
89
90
  - `"off"`: skips normalization and sends inputs as-is (inputs must match API-native shapes)
90
91
 
@@ -98,9 +99,8 @@ lex.tracks.search({"rating": "bad"}, validation="off") # sends as-is
98
99
 
99
100
  ## Convenience vs Raw API
100
101
 
101
- This SDK adds several quality-of-life behaviors on top of the raw Lexicon API.
102
- In general, response shapes are unwrapped (e.g., `"data": {...}` is removed and
103
- single-item lists are collapsed to a single dict).
102
+ This SDK adds several quality-of-life behaviors on top of the raw Lexicon API. In general, response shapes are unwrapped
103
+ (e.g., `"data": {...}` is removed and single-item lists are collapsed to a single dict).
104
104
 
105
105
  ### Additional Methods (Not in the Raw API)
106
106
 
@@ -113,28 +113,25 @@ single-item lists are collapsed to a single dict).
113
113
 
114
114
  ### Input Normalization (Broader Accepted Inputs)
115
115
 
116
- - **ID normalization**: Many methods accept lists of IDs instead of a single ID
117
- for easier batch operations.
118
- - **Field selection**: By default, `tracks.list()` and `tracks.search()` return a
119
- minimal set of fields rather than the full payload:
120
- - `id`, `artist`, `title`, `albumTitle`, `bpm`, `key`, `duration`, `year`
121
- - Fields used as a search filter or sort item are also returned.
116
+ - **ID normalization**: Many methods accept lists of IDs instead of a single ID for easier batch operations.
117
+ - **Field selection**: By default, `tracks.list()` and `tracks.search()` return a minimal set of fields rather than the
118
+ full payload:
119
+ - `id`, `artist`, `title`, `albumTitle`, `bpm`, `key`, `duration`, `year`
120
+ - Fields used as a search filter or sort item are also returned.
122
121
  - **Search filter normalization**:
123
122
  - Text fields accept `None` (becomes `"NONE"` in filter context).
124
123
  - Numeric filters accept `None` (becomes `"0"`).
125
- - Date filters accept `YYYY-MM-DD`, full datetime strings, or `datetime.date` /
126
- `datetime.datetime` inputs (time is stripped).
127
- - Comparisons (`>YYYY-MM-DD`) are warned/blocked because the API currently ignores them.
124
+ - Date filters accept `YYYY-MM-DD`, full datetime strings, or `datetime.date` / `datetime.datetime` inputs (time is
125
+ stripped).
126
+ - Comparisons (`>YYYY-MM-DD`) are warned/blocked because the API currently ignores them.
128
127
  - **Sort normalization**:
129
128
  - Accepts tuple shorthand: `[("title", "asc")]`.
130
129
  - **Track update helpers**:
131
- - Cuepoint and tempomarker entries are normalized (e.g., cuepoint type accepts
132
- name/number variants).
130
+ - Cuepoint and tempomarker entries are normalized (e.g., cuepoint type accepts name/number variants).
133
131
  - Invalid entries can be skipped in `"warn"` mode without failing the update.
134
132
 
135
- If no SDK normalization is desired, use `validation="off"` and pass
136
- API-native payloads. API-native shapes are also accepted in `"warn"`/`"strict"`;
137
- those modes simply add normalization/validation on top. For fully raw access,
133
+ If no SDK normalization is desired, use `validation="off"` and pass API-native payloads. API-native shapes are also
134
+ accepted in `"warn"`/`"strict"`; those modes simply add normalization/validation on top. For fully raw access,
138
135
  `lex.request(...)` can always be called directly.
139
136
 
140
137
  ## Tracks
@@ -150,23 +147,60 @@ tracks = lex.tracks.search({"artist": "Daft Punk"})
150
147
  added = lex.tracks.add(["/path/to/file1.mp3", "/path/to/file2.mp3"])
151
148
  updated = lex.tracks.update(123, {"title": "New Title"})
152
149
  lex.tracks.delete([123, 456])
150
+
151
+ # Replace tempomarkers while keeping cuepoints anchored to the same beats
152
+ lex.tracks.update_tempogrid(123, [{"startTime": 0.0, "bpm": 124.0}])
153
+
154
+ # Start Lexicon's analyzer on specific tracks
155
+ lex.tracks.analyze([123, 456], analyze_beatgrid=True, do_not_overwrite=False)
153
156
  ```
154
157
 
155
158
  Notes:
156
159
 
157
160
  - `tracks.search()` results are capped at 1000 by the API.
158
161
  - `tracks.get_many()` preserves input order and returns `None` for missing IDs.
159
- - `fields=None` returns a minimal default set of fields.
160
- - In `validation="off"` mode, `fields=None` returns full payloads (API-default)
161
- - `fields="all"` or `fields="*"` requests full payloads.
162
- - `tracks.add()` returns track dicts, but analysis fields (tempo markers, key, etc.)
163
- may be populated later by Lexicon.
162
+ - `tracks.get()` always returns the full record (no `fields` parameter).
163
+ - For `tracks.list()` / `tracks.search()`:
164
+ - `fields=None` returns a minimal default set.
165
+ - In `validation="off"` mode, `fields=None` returns the full API payload.
166
+ - Pass a list of field names for a narrow projection (`fields=["id", "location"]`).
167
+ - `fields="all"` or `fields="*"` requests the full payload.
168
+ - Comma-separated strings (e.g. `"id,location"`) are _not_ accepted; pass a list. With `validation="warn"` the call
169
+ falls back to the default field set and logs.
170
+ - `tracks.add()` returns track dicts, but analysis fields (tempo markers, key, etc.) may be populated later by Lexicon.
171
+ - `tracks.update_tempogrid()` replaces tempomarkers while preserving each cuepoint's beat position by rewriting
172
+ `startTime`/`endTime` against the new grid. Use `tracks.update(track_id, {"tempomarkers": [...]})` directly if you
173
+ want the raw field replacement (cuepoints' seconds stay put, beats shift).
174
+ - `tracks.analyze()` returns as soon as Lexicon accepts the request. Analysis runs in the background and the API has no
175
+ completion signal, so poll `tracks.get()` and watch `tempomarkers`, `bpm`, `key`, or `dateModified`.
176
+ - `tracks.analyze(tempo_range=...)` takes one of the eight ids Lexicon's analyze dropdown stores: `"0_300"` (Any),
177
+ `"58_115"`, `"68_135"`, `"78_155"`, `"88_175"`, `"98_195"`, `"128_255"`, `"180_300"` (180+). An equivalent
178
+ `(min, max)` tuple works too. The value is written to Lexicon's stored analysis setting and persists after the call;
179
+ the boolean options apply to that run only.
180
+
181
+ ### Track Payload Shapes
182
+
183
+ Rich-payload fields like `cuepoints` and `tempomarkers` aren't in the default field set; request them explicitly
184
+ (`fields=["cuepoints", "tempomarkers"]`) or ask for everything (`fields="all"`). Their shapes are typed via
185
+ `CuePointResponse` and `TempoMarkerResponse`, both re-exported from `lexicon`:
186
+
187
+ ```python
188
+ from lexicon import TrackResponse, TempoMarkerResponse, CuePointResponse
189
+ ```
190
+
191
+ Color values (cuepoint/tag/etc. `color` fields) are Lexicon-specific name strings declared by the `Color` literal type.
192
+ To convert a name to RGB:
193
+
194
+ ```python
195
+ from lexicon import color_rgb
196
+
197
+ color_rgb("red_dark") # → (158, 15, 7)
198
+ ```
164
199
 
165
200
  ### Track Search, Filters, and Sort
166
201
 
167
- `tracks.search(filter=...)` accepts a dict of field names and values. The SDK
168
- validates fields/values in `"warn"`/`"strict"` modes and can send API-native values
169
- in `"off"` mode.
202
+ `tracks.search(filter=...)` accepts a dict of field names and values. The SDK validates fields/values in
203
+ `"warn"`/`"strict"` modes and can send API-native values in `"off"` mode.
170
204
 
171
205
  Examples:
172
206
 
@@ -224,6 +258,7 @@ Playlist type accepts:
224
258
  - `1`, `2`, `3` (or string numerals `"1"`, `"2"`, `"3"`)
225
259
 
226
260
  ### Playlist tracks helpers
261
+
227
262
  For getting the tracks of a playlist or editing the tracklist.
228
263
 
229
264
  ```python
@@ -277,6 +312,7 @@ print(path)
277
312
  ```
278
313
 
279
314
  ## Raw Requests (Escape Hatch)
315
+
280
316
  The API can always be accessed directly with `lex.request`:
281
317
 
282
318
  ```python
@@ -295,8 +331,8 @@ High level namespaces (full mapping in `docs/resource-map.md`):
295
331
 
296
332
  ## Type Hints
297
333
 
298
- The SDK includes TypedDict and Literal types for payloads and enums. These are
299
- intended to improve editor autocomplete and static checks.
334
+ The SDK includes TypedDict and Literal types for payloads and enums. These are intended to improve editor autocomplete
335
+ and static checks.
300
336
 
301
337
  For full payload schemas and endpoint details, refer to the Lexicon API docs:
302
338
 
@@ -305,10 +341,97 @@ For full payload schemas and endpoint details, refer to the Lexicon API docs:
305
341
 
306
342
  ## Development
307
343
 
344
+ ### Setup
345
+
346
+ #### Pip
347
+
348
+ Ensure that you have Python 3.9+ installed locally. To install all runtime and dev dependencies into a local virtual
349
+ environment using pip:
350
+
351
+ ```bash
352
+ python -m venv .venv
353
+ source .venv/bin/activate
354
+ pip install -e ".[dev]"
355
+ ```
356
+
357
+ #### uv
358
+
359
+ To use [uv](https://docs.astral.sh/uv/) to install all runtime and dev dependencies into a local virtual environment,
360
+ simply install `uv` and run:
361
+
308
362
  ```bash
309
- ./.venv/bin/python -m pytest --cov --cov-branch --cov-report=term-missing
363
+ uv sync --dev
310
364
  ```
311
365
 
366
+ The lockfile (`uv.lock`) is checked in to ensure reproducible installs.
367
+
368
+ ### Running Tests
369
+
370
+ ```bash
371
+ # Unit tests
372
+ make run-tests
373
+
374
+ # Integration tests (requires Lexicon running)
375
+ # Note: Integration tests enforce an empty library state to avoid destructive edits on existing libraries.
376
+ # The fixture setup will back up the existing library, clear it for testing, and restore it afterward.
377
+ make run-integration-tests
378
+ ```
379
+
380
+ ### Linting and Formatting
381
+
382
+ The project uses [ruff](https://docs.astral.sh/ruff/) for both linting and formatting.
383
+
384
+ `make test` runs the full suite: format, lint (with auto-fix), then tests. `make fix` runs all auto-fixers (lint +
385
+ format) without running tests.
386
+
387
+ ```bash
388
+ make test # format-fix → lint-fix → format-check → lint-check → tests
389
+ make fix # lint-fix → format-fix
390
+ make clean # remove __pycache__, .pytest_cache, .ruff_cache, etc.
391
+ ```
392
+
393
+ If you want to run the linters or formatters manually, you can use the following commands:
394
+
395
+ ```bash
396
+ make lint-check # check for lint issues
397
+ make lint-fix # auto-fix lint issues
398
+ make format-check # check formatting
399
+ make format-fix # auto-fix formatting
400
+ ```
401
+
402
+ ### Pre-commit hook (recommended)
403
+
404
+ The repo ships a [pre-commit](https://pre-commit.com/) config (`.pre-commit-config.yaml`) that mirrors the CI lint job.
405
+ After cloning, install the hook once:
406
+
407
+ ```bash
408
+ uv tool install pre-commit # or: pipx install pre-commit
409
+ pre-commit install
410
+ ```
411
+
412
+ `git commit` now runs `ruff format` + `ruff check --fix` on staged files and blocks the commit on any failure. Run
413
+ against the whole repo any time with:
414
+
415
+ ```bash
416
+ pre-commit run --all-files
417
+ ```
418
+
419
+ The ruff version is pinned in `.pre-commit-config.yaml`; bump it when CI bumps to keep them aligned.
420
+
421
+ ### CI
422
+
423
+ GitHub Actions runs on every push to `main` and on pull requests targeting those branches. The pipeline includes:
424
+
425
+ - **Tests** across Python 3.9, 3.10, 3.11, and 3.12
426
+ - **Lint and format checks** via ruff on Python 3.12
427
+
428
+ Before opening a PR, make sure `make test` passes locally.
429
+
430
+ ### Pull Requests
431
+
432
+ A PR template is provided at `.github/pull_request_template.md`. When opening a PR, fill in the description, check the
433
+ relevant change-type boxes, and confirm testing/checklist items.
434
+
312
435
  ## License
313
436
 
314
437
  MIT (see `LICENSE`).