lexicon-python 0.2.0__tar.gz → 0.3.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 (42) hide show
  1. {lexicon_python-0.2.0/src/lexicon_python.egg-info → lexicon_python-0.3.0}/PKG-INFO +73 -9
  2. {lexicon_python-0.2.0 → lexicon_python-0.3.0}/README.md +71 -7
  3. {lexicon_python-0.2.0 → lexicon_python-0.3.0}/pyproject.toml +22 -2
  4. lexicon_python-0.3.0/src/lexicon/__init__.py +23 -0
  5. {lexicon_python-0.2.0 → lexicon_python-0.3.0}/src/lexicon/client.py +19 -4
  6. {lexicon_python-0.2.0 → lexicon_python-0.3.0}/src/lexicon/resources/_common_types.py +102 -53
  7. {lexicon_python-0.2.0 → lexicon_python-0.3.0}/src/lexicon/resources/base.py +3 -1
  8. {lexicon_python-0.2.0 → lexicon_python-0.3.0}/src/lexicon/resources/playlist_tracks.py +17 -13
  9. {lexicon_python-0.2.0 → lexicon_python-0.3.0}/src/lexicon/resources/playlists.py +121 -31
  10. {lexicon_python-0.2.0 → lexicon_python-0.3.0}/src/lexicon/resources/playlists_types.py +39 -19
  11. {lexicon_python-0.2.0 → lexicon_python-0.3.0}/src/lexicon/resources/tag_categories.py +28 -21
  12. lexicon_python-0.3.0/src/lexicon/resources/tag_categories_types.py +23 -0
  13. {lexicon_python-0.2.0 → lexicon_python-0.3.0}/src/lexicon/resources/tags.py +10 -6
  14. {lexicon_python-0.2.0 → lexicon_python-0.3.0}/src/lexicon/resources/tags_types.py +12 -6
  15. {lexicon_python-0.2.0 → lexicon_python-0.3.0}/src/lexicon/resources/tracks.py +201 -46
  16. {lexicon_python-0.2.0 → lexicon_python-0.3.0}/src/lexicon/resources/tracks_types.py +361 -105
  17. {lexicon_python-0.2.0 → lexicon_python-0.3.0}/src/lexicon/tools/__init__.py +0 -2
  18. {lexicon_python-0.2.0 → lexicon_python-0.3.0}/src/lexicon/tools/playlists.py +5 -1
  19. lexicon_python-0.3.0/src/lexicon/tools/tracks.py +130 -0
  20. {lexicon_python-0.2.0 → lexicon_python-0.3.0}/src/lexicon/utils.py +1 -0
  21. {lexicon_python-0.2.0 → lexicon_python-0.3.0/src/lexicon_python.egg-info}/PKG-INFO +73 -9
  22. {lexicon_python-0.2.0 → lexicon_python-0.3.0}/src/lexicon_python.egg-info/SOURCES.txt +2 -0
  23. {lexicon_python-0.2.0 → lexicon_python-0.3.0}/tests/test_base.py +7 -2
  24. {lexicon_python-0.2.0 → lexicon_python-0.3.0}/tests/test_client.py +3 -2
  25. {lexicon_python-0.2.0 → lexicon_python-0.3.0}/tests/test_common_types.py +1 -1
  26. {lexicon_python-0.2.0 → lexicon_python-0.3.0}/tests/test_playlist_tracks.py +25 -10
  27. {lexicon_python-0.2.0 → lexicon_python-0.3.0}/tests/test_playlists.py +173 -36
  28. {lexicon_python-0.2.0 → lexicon_python-0.3.0}/tests/test_playlists_types.py +4 -1
  29. {lexicon_python-0.2.0 → lexicon_python-0.3.0}/tests/test_tag_categories.py +42 -19
  30. {lexicon_python-0.2.0 → lexicon_python-0.3.0}/tests/test_tags.py +4 -1
  31. lexicon_python-0.3.0/tests/test_tools_tracks.py +103 -0
  32. {lexicon_python-0.2.0 → lexicon_python-0.3.0}/tests/test_tracks.py +523 -117
  33. lexicon_python-0.2.0/src/lexicon/__init__.py +0 -8
  34. lexicon_python-0.2.0/src/lexicon/resources/tag_categories_types.py +0 -18
  35. {lexicon_python-0.2.0 → lexicon_python-0.3.0}/LICENSE +0 -0
  36. {lexicon_python-0.2.0 → lexicon_python-0.3.0}/setup.cfg +0 -0
  37. {lexicon_python-0.2.0 → lexicon_python-0.3.0}/src/lexicon/resources/__init__.py +0 -0
  38. {lexicon_python-0.2.0 → lexicon_python-0.3.0}/src/lexicon/resources/playlist_tracks_types.py +0 -0
  39. {lexicon_python-0.2.0 → lexicon_python-0.3.0}/src/lexicon_python.egg-info/dependency_links.txt +0 -0
  40. {lexicon_python-0.2.0 → lexicon_python-0.3.0}/src/lexicon_python.egg-info/requires.txt +0 -0
  41. {lexicon_python-0.2.0 → lexicon_python-0.3.0}/src/lexicon_python.egg-info/top_level.txt +0 -0
  42. {lexicon_python-0.2.0 → lexicon_python-0.3.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.3.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
@@ -41,12 +41,6 @@ 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
@@ -320,10 +314,80 @@ For full payload schemas and endpoint details, refer to the Lexicon API docs:
320
314
 
321
315
  ## Development
322
316
 
317
+ ### Setup
318
+
319
+ #### Pip
320
+
321
+ Ensure that you have Python 3.9+ installed locally.
322
+ To install all runtime and dev dependencies into a local virtual environment using pip:
323
+
324
+ ```bash
325
+ python -m venv .venv
326
+ source .venv/bin/activate
327
+ pip install -e ".[dev]"
328
+ ```
329
+
330
+ #### uv
331
+
332
+ To use [uv](https://docs.astral.sh/uv/) to install all runtime and dev dependencies into a local virtual environment, simply install `uv` and run:
333
+
334
+ ```bash
335
+ uv sync --dev
336
+ ```
337
+
338
+ The lockfile (`uv.lock`) is checked in to ensure reproducible installs.
339
+
340
+ ### Running Tests
341
+
323
342
  ```bash
324
- ./.venv/bin/python -m pytest --cov --cov-branch --cov-report=term-missing
343
+ # Unit tests
344
+ make run-tests
345
+
346
+ # Integration tests (requires Lexicon running)
347
+ # Note: Integration tests enforce an empty library state to avoid destructive edits on existing libraries.
348
+ # The fixture setup will back up the existing library, clear it for testing, and restore it afterward.
349
+ make run-integration-tests
325
350
  ```
326
351
 
352
+ ### Linting and Formatting
353
+
354
+ The project uses [ruff](https://docs.astral.sh/ruff/) for both linting and
355
+ formatting.
356
+
357
+ `make test` runs the full suite: format, lint (with auto-fix), then tests.
358
+ `make fix` runs all auto-fixers (lint + format) without running tests.
359
+
360
+ ```bash
361
+ make test # format-fix → lint-fix → format-check → lint-check → tests
362
+ make fix # lint-fix → format-fix
363
+ make clean # remove __pycache__, .pytest_cache, .ruff_cache, etc.
364
+ ```
365
+
366
+ If you want to run the linters or formatters manually, you can use the following commands:
367
+
368
+ ```bash
369
+ make lint-check # check for lint issues
370
+ make lint-fix # auto-fix lint issues
371
+ make format-check # check formatting
372
+ make format-fix # auto-fix formatting
373
+ ```
374
+
375
+ ### CI
376
+
377
+ GitHub Actions runs on every push to `main` and on pull requests
378
+ targeting those branches. The pipeline includes:
379
+
380
+ - **Tests** across Python 3.9, 3.10, 3.11, and 3.12
381
+ - **Lint and format checks** via ruff on Python 3.12
382
+
383
+ Before opening a PR, make sure `make test` passes locally.
384
+
385
+ ### Pull Requests
386
+
387
+ A PR template is provided at `.github/pull_request_template.md`. When opening a
388
+ PR, fill in the description, check the relevant change-type boxes, and confirm
389
+ testing/checklist items.
390
+
327
391
  ## License
328
392
 
329
393
  MIT (see `LICENSE`).
@@ -26,12 +26,6 @@ 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
@@ -305,10 +299,80 @@ For full payload schemas and endpoint details, refer to the Lexicon API docs:
305
299
 
306
300
  ## Development
307
301
 
302
+ ### Setup
303
+
304
+ #### Pip
305
+
306
+ Ensure that you have Python 3.9+ installed locally.
307
+ To install all runtime and dev dependencies into a local virtual environment using pip:
308
+
309
+ ```bash
310
+ python -m venv .venv
311
+ source .venv/bin/activate
312
+ pip install -e ".[dev]"
313
+ ```
314
+
315
+ #### uv
316
+
317
+ To use [uv](https://docs.astral.sh/uv/) to install all runtime and dev dependencies into a local virtual environment, simply install `uv` and run:
318
+
319
+ ```bash
320
+ uv sync --dev
321
+ ```
322
+
323
+ The lockfile (`uv.lock`) is checked in to ensure reproducible installs.
324
+
325
+ ### Running Tests
326
+
308
327
  ```bash
309
- ./.venv/bin/python -m pytest --cov --cov-branch --cov-report=term-missing
328
+ # Unit tests
329
+ make run-tests
330
+
331
+ # Integration tests (requires Lexicon running)
332
+ # Note: Integration tests enforce an empty library state to avoid destructive edits on existing libraries.
333
+ # The fixture setup will back up the existing library, clear it for testing, and restore it afterward.
334
+ make run-integration-tests
310
335
  ```
311
336
 
337
+ ### Linting and Formatting
338
+
339
+ The project uses [ruff](https://docs.astral.sh/ruff/) for both linting and
340
+ formatting.
341
+
342
+ `make test` runs the full suite: format, lint (with auto-fix), then tests.
343
+ `make fix` runs all auto-fixers (lint + format) without running tests.
344
+
345
+ ```bash
346
+ make test # format-fix → lint-fix → format-check → lint-check → tests
347
+ make fix # lint-fix → format-fix
348
+ make clean # remove __pycache__, .pytest_cache, .ruff_cache, etc.
349
+ ```
350
+
351
+ If you want to run the linters or formatters manually, you can use the following commands:
352
+
353
+ ```bash
354
+ make lint-check # check for lint issues
355
+ make lint-fix # auto-fix lint issues
356
+ make format-check # check formatting
357
+ make format-fix # auto-fix formatting
358
+ ```
359
+
360
+ ### CI
361
+
362
+ GitHub Actions runs on every push to `main` and on pull requests
363
+ targeting those branches. The pipeline includes:
364
+
365
+ - **Tests** across Python 3.9, 3.10, 3.11, and 3.12
366
+ - **Lint and format checks** via ruff on Python 3.12
367
+
368
+ Before opening a PR, make sure `make test` passes locally.
369
+
370
+ ### Pull Requests
371
+
372
+ A PR template is provided at `.github/pull_request_template.md`. When opening a
373
+ PR, fill in the description, check the relevant change-type boxes, and confirm
374
+ testing/checklist items.
375
+
312
376
  ## License
313
377
 
314
378
  MIT (see `LICENSE`).
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "lexicon-python"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "Python client for the Lexicon DJ API"
9
9
  readme = "README.md"
10
10
  license = "MIT"
11
11
  license-files = ["LICENSE"]
12
- requires-python = ">=3.9"
12
+ requires-python = ">=3.10"
13
13
  authors = [
14
14
  {name = "Garrison Burger", email = "burgerga123@gmail.com"}
15
15
  ]
@@ -19,6 +19,15 @@ dependencies = [
19
19
  "typing_extensions>=4.0"
20
20
  ]
21
21
 
22
+ [dependency-groups]
23
+ dev = [
24
+ "pytest>=7.0",
25
+ "pytest-cov>=4.0",
26
+ "ruff>=0.1.0",
27
+ "mypy>=1.0",
28
+ "ipython>=8.12.3",
29
+ ]
30
+
22
31
  [project.urls]
23
32
  Homepage = "https://github.com/photonicvelocity/lexicon-python"
24
33
 
@@ -28,6 +37,17 @@ where = ["src"]
28
37
  [tool.setuptools]
29
38
  package-dir = {"" = "src"}
30
39
 
40
+ [tool.pytest.ini_options]
41
+ testpaths = ["tests"]
42
+ markers = [
43
+ "integration: integration tests requiring a running Lexicon instance",
44
+ ]
45
+ addopts = "-m 'not integration'"
46
+
47
+ [tool.uv]
48
+ # Dev dependencies are specified in [dependency-groups]
49
+ default-groups = ["dev"]
50
+
31
51
  [tool.pydocstyle]
32
52
  convention = "numpy"
33
53
 
@@ -0,0 +1,23 @@
1
+ """Public package surface for the lexicon Python client."""
2
+
3
+ from .client import DEFAULT_HOST, LEXICON_PORT, Lexicon
4
+ from .resources.playlists_types import PlaylistResponse
5
+ from .resources.tag_categories_types import TagCategoryResponse
6
+ from .resources.tags_types import TagResponse
7
+ from .resources.tracks_types import (
8
+ CuePointResponse,
9
+ TempoMarkerResponse,
10
+ TrackResponse,
11
+ )
12
+
13
+ __all__ = [
14
+ "DEFAULT_HOST",
15
+ "LEXICON_PORT",
16
+ "Lexicon",
17
+ "TrackResponse",
18
+ "PlaylistResponse",
19
+ "TagResponse",
20
+ "TagCategoryResponse",
21
+ "CuePointResponse",
22
+ "TempoMarkerResponse",
23
+ ]
@@ -14,17 +14,26 @@ from .resources.tag_categories import TagCategories
14
14
  from .resources.tags import Tags
15
15
  from .resources.tracks import Tracks
16
16
  from .tools import playlists as playlist_tools
17
+ from .tools import tracks as track_tools
18
+
17
19
  DEFAULT_HOST = os.environ.get("LEXICON_HOST", "localhost")
18
20
  LEXICON_PORT = int(os.environ.get("LEXICON_PORT", "48624"))
19
21
 
20
22
 
23
+ class Tools:
24
+ """Namespace for helper tools."""
25
+
26
+ playlists = playlist_tools
27
+ tracks = track_tools
28
+
29
+
21
30
  class Lexicon:
22
31
  """Resource-grouped client for the Lexicon Local API."""
23
32
 
24
33
  tracks: Tracks
25
34
  playlists: Playlists
26
35
  tags: Tags
27
- tools: Any
36
+ tools: Tools
28
37
 
29
38
  def __init__(
30
39
  self,
@@ -34,6 +43,7 @@ class Lexicon:
34
43
  default_timeout: int = 20,
35
44
  session: Optional[requests.Session] = None,
36
45
  raise_on_error: bool = False,
46
+ raw_enums: bool = True,
37
47
  ) -> None:
38
48
  """Create a Lexicon client bound to an API instance.
39
49
 
@@ -49,21 +59,26 @@ class Lexicon:
49
59
  Optional requests session to reuse connections.
50
60
  raise_on_error
51
61
  If True, raise HTTP errors instead of returning None.
62
+ raw_enums
63
+ If True (default), return API enum values as raw codes (e.g. ``"1"``).
64
+ If False, convert to human-readable names (e.g. ``"normal"``).
52
65
  """
53
66
  self.host = host or DEFAULT_HOST
54
67
  self.port = int(port or LEXICON_PORT)
55
68
  self.default_timeout = default_timeout
56
69
  self.raise_on_error = raise_on_error
70
+ self.raw_enums = raw_enums
57
71
  self._logger = logging.getLogger(__name__)
58
72
  self._session = session
59
73
 
60
74
  self.tracks: Tracks = Tracks(self)
61
75
  self.playlists: Playlists = Playlists(self)
62
- self.playlists.tracks = PlaylistTracks(self, tracks=self.tracks, playlists=self.playlists)
76
+ self.playlists.tracks = PlaylistTracks(
77
+ self, tracks=self.tracks, playlists=self.playlists
78
+ )
63
79
  self.tags: Tags = Tags(self)
64
80
  self.tags.categories = TagCategories(self)
65
- self.tools = type("Tools", (), {})()
66
- self.tools.playlists = playlist_tools
81
+ self.tools = Tools()
67
82
 
68
83
  def request(
69
84
  self,
@@ -22,62 +22,85 @@ ValidationMode = Literal["off", "warn", "strict"]
22
22
 
23
23
  # --- Color Types and Normalization --- #
24
24
  Color = Literal[
25
- "red_dark", "red", "red_light", "red_orange", "orange", "beige", "yellow_dark", "yellow",
26
- "lime", "green_light", "green", "green_dark", "teal", "aqua", "aqua_dark", "blue_light",
27
- "blue", "blue_dark", "blue_violet", "violet", "violet_light", "magenta", "magenta_dark",
28
- "magenta_red", "grey_light", "grey_dark", "black", "white",
25
+ "red_dark",
26
+ "red",
27
+ "red_light",
28
+ "red_orange",
29
+ "orange",
30
+ "beige",
31
+ "yellow_dark",
32
+ "yellow",
33
+ "lime",
34
+ "green_light",
35
+ "green",
36
+ "green_dark",
37
+ "teal",
38
+ "aqua",
39
+ "aqua_dark",
40
+ "blue_light",
41
+ "blue",
42
+ "blue_dark",
43
+ "blue_violet",
44
+ "violet",
45
+ "violet_light",
46
+ "magenta",
47
+ "magenta_dark",
48
+ "magenta_red",
49
+ "grey_light",
50
+ "grey_dark",
51
+ "black",
52
+ "white",
29
53
  ]
30
54
  COLORS: tuple[Color, ...] = get_args(Color)
31
55
  COLOR_RGBS: tuple[tuple[int, int, int], ...] = (
32
- (158, 15, 7), # red_dark
33
- (230, 15, 13), # red
34
- (242, 102, 92), # red_light
35
- (239, 89, 15), # red_orange
36
- (232, 137, 20), # orange
37
- (255, 225, 148), # beige
38
- (245, 208, 1), # yellow_dark
39
- (245, 245, 10), # yellow
40
- (186, 232, 22), # lime
41
- (174, 245, 95), # green_light
42
- (127, 231, 16), # green
43
- ( 75, 140, 8), # green_dark
44
- ( 20, 222, 120), # teal
45
- ( 20, 222, 202), # aqua
46
- ( 7, 148, 134), # aqua_dark
47
- ( 47, 168, 237), # blue_light
48
- ( 14, 88, 222), # blue
49
- ( 0, 40, 171), # blue_dark
50
- (132, 0, 255), # blue_violet
51
- (170, 59, 255), # violet
52
- (198, 140, 243), # violet_light
53
- (230, 15, 222), # magenta
54
- (170, 0, 188), # magenta_dark
55
- (222, 17, 92), # magenta_red
56
- (173, 173, 173), # grey_light
57
- ( 92, 92, 92), # grey_dark
58
- ( 48, 48, 48), # black
59
- (255, 255, 255), # white
56
+ (158, 15, 7), # red_dark
57
+ (230, 15, 13), # red
58
+ (242, 102, 92), # red_light
59
+ (239, 89, 15), # red_orange
60
+ (232, 137, 20), # orange
61
+ (255, 225, 148), # beige
62
+ (245, 208, 1), # yellow_dark
63
+ (245, 245, 10), # yellow
64
+ (186, 232, 22), # lime
65
+ (174, 245, 95), # green_light
66
+ (127, 231, 16), # green
67
+ (75, 140, 8), # green_dark
68
+ (20, 222, 120), # teal
69
+ (20, 222, 202), # aqua
70
+ (7, 148, 134), # aqua_dark
71
+ (47, 168, 237), # blue_light
72
+ (14, 88, 222), # blue
73
+ (0, 40, 171), # blue_dark
74
+ (132, 0, 255), # blue_violet
75
+ (170, 59, 255), # violet
76
+ (198, 140, 243), # violet_light
77
+ (230, 15, 222), # magenta
78
+ (170, 0, 188), # magenta_dark
79
+ (222, 17, 92), # magenta_red
80
+ (173, 173, 173), # grey_light
81
+ (92, 92, 92), # grey_dark
82
+ (48, 48, 48), # black
83
+ (255, 255, 255), # white
60
84
  )
61
85
 
62
86
 
63
- def _normalize_color(value: object) -> Color | None:
64
- """Normalize color inputs to the nearest Lexicon color name.
87
+ def _parse_color_rgb(value: object) -> tuple[int, int, int] | None:
88
+ """Parse a color input into an RGB tuple.
65
89
 
66
90
  Parameters
67
91
  ----------
68
92
  value
69
93
  Color input. Supported forms:
70
- - ``None`` or the string ``"None"`` (case-insensitive)
71
- - Lexicon color names (see ``COLORS``)
72
- - Various RGB/RGBA forms (alpha is dropped)
73
- - Hex strings (``#RGB``, ``#RGBA``, ``#RRGGBB``, ``#RRGGBBAA``)
74
- - RGB/RGBA tuples or lists (ints 0-255 or floats 0-1)
75
- - Packed RGB integer (``0xRRGGBB``, ``0xAARRGGBB``)
94
+ - ``None`` or the string ``"None"`` (case-insensitive) -> returns None
95
+ - Lexicon color names (see ``COLORS``) -> looked up from COLOR_RGBS
96
+ - Hex strings (``#RGB``, ``#RGBA``, ``#RRGGBB``, ``#RRGGBBAA``)
97
+ - RGB/RGBA tuples or lists (ints 0-255 or floats 0-1)
98
+ - Packed RGB integer (``0xRRGGBB``, ``0xAARRGGBB``)
76
99
 
77
100
  Returns
78
101
  -------
79
- Color or None
80
- The nearest Lexicon color name, or ``None`` when the input is ``None``.
102
+ tuple[int, int, int] or None
103
+ RGB tuple (0-255), or ``None`` when the input is ``None``.
81
104
 
82
105
  Raises
83
106
  ------
@@ -90,20 +113,23 @@ def _normalize_color(value: object) -> Color | None:
90
113
  if value.strip().lower() == "none":
91
114
  return None
92
115
  if value in COLORS:
93
- return value
94
- hex_match = re.match(r"^\s*#?([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})\s*$", value, flags=re.IGNORECASE)
116
+ return COLOR_RGBS[COLORS.index(value)]
117
+ hex_match = re.match(
118
+ r"^\s*#?([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})\s*$",
119
+ value,
120
+ flags=re.IGNORECASE,
121
+ )
95
122
  if hex_match:
96
123
  hex_value = hex_match.group(1)
97
124
  if len(hex_value) in (3, 4):
98
125
  hex_value = "".join(ch * 2 for ch in hex_value)
99
126
  if len(hex_value) == 8:
100
127
  hex_value = hex_value[:6]
101
- rgb = (
128
+ return (
102
129
  int(hex_value[0:2], 16),
103
130
  int(hex_value[2:4], 16),
104
131
  int(hex_value[4:6], 16),
105
132
  )
106
- return _nearest_color(rgb)
107
133
  raise ValueError(f"Unsupported string input {value!r}")
108
134
 
109
135
  if isinstance(value, int):
@@ -111,8 +137,7 @@ def _normalize_color(value: object) -> Color | None:
111
137
  raise ValueError(f"Negative packed int {value!r}")
112
138
  if value > 0xFFFFFF:
113
139
  value = value & 0xFFFFFF
114
- rgb = ((value >> 16) & 0xFF, (value >> 8) & 0xFF, value & 0xFF)
115
- return _nearest_color(rgb)
140
+ return ((value >> 16) & 0xFF, (value >> 8) & 0xFF, value & 0xFF)
116
141
 
117
142
  if isinstance(value, (tuple, list)) and len(value) in (3, 4):
118
143
  rgb_values = value[:3]
@@ -123,12 +148,34 @@ def _normalize_color(value: object) -> Color | None:
123
148
  if channel_value <= 1:
124
149
  channel_value *= 255
125
150
  rgb.append(int(max(0, min(255, round(channel_value)))))
126
- return _nearest_color((rgb[0], rgb[1], rgb[2]))
151
+ return (rgb[0], rgb[1], rgb[2])
127
152
  raise ValueError(f"Invalid RGB tuple values {value!r}")
128
153
 
129
154
  raise ValueError(f"Unsupported input type {type(value)}")
130
155
 
131
156
 
157
+ def _normalize_color(value: object) -> Color | None:
158
+ """Normalize color inputs to the nearest Lexicon color name.
159
+
160
+ Used for tracks and cuepoints which use a fixed color swatch.
161
+ """
162
+ rgb = _parse_color_rgb(value)
163
+ if rgb is None:
164
+ return None
165
+ return _nearest_color(rgb)
166
+
167
+
168
+ def _normalize_color_hex(value: object) -> str | None:
169
+ """Normalize color inputs to a hex string (#RRGGBB).
170
+
171
+ Used for tag categories which support a full color picker.
172
+ """
173
+ rgb = _parse_color_rgb(value)
174
+ if rgb is None:
175
+ return None
176
+ return f"#{rgb[0]:02x}{rgb[1]:02x}{rgb[2]:02x}"
177
+
178
+
132
179
  def _nearest_color(rgb: tuple[int, int, int]) -> Color:
133
180
  """Find the nearest Lexicon color to the given RGB values."""
134
181
  best_index = 0
@@ -147,19 +194,19 @@ def _nearest_color(rgb: tuple[int, int, int]) -> Color:
147
194
  # --- ID Sequence Normalization --- #
148
195
  def _normalize_id_sequence(ids: int | Sequence[int] | object) -> list[int] | None:
149
196
  """Normalize single ID or sequence of IDs to a deduplicated list.
150
-
197
+
151
198
  Parameters
152
199
  ----------
153
200
  ids
154
201
  Single integer ID or sequence of integer IDs.
155
-
202
+
156
203
  Returns
157
204
  -------
158
205
  list[int] | None
159
206
  Deduplicated list of valid IDs (>= 1), or None if:
160
207
  - Input is not int or sequence (or is str/bytes)
161
208
  - No valid IDs found (all < 1)
162
-
209
+
163
210
  Notes
164
211
  -----
165
212
  Used for normalizing track IDs, tag IDs, and similar integer sequences.
@@ -172,7 +219,9 @@ def _normalize_id_sequence(ids: int | Sequence[int] | object) -> list[int] | Non
172
219
  else:
173
220
  return None
174
221
 
175
- valid_ids = [id_val for id_val in id_list if isinstance(id_val, int) and id_val >= 1]
222
+ valid_ids = [
223
+ id_val for id_val in id_list if isinstance(id_val, int) and id_val >= 1
224
+ ]
176
225
  if not valid_ids:
177
226
  return None
178
227
 
@@ -27,7 +27,9 @@ class Resource:
27
27
  json: Optional[dict[str, Any]] = None,
28
28
  timeout: Optional[int] = None,
29
29
  ) -> Optional[dict[str, Any] | list[Any]]:
30
- return self._client.request(method, path, params=params, json=json, timeout=timeout)
30
+ return self._client.request(
31
+ method, path, params=params, json=json, timeout=timeout
32
+ )
31
33
 
32
34
  def _get(
33
35
  self,