lexicon-python 0.1.2__py3-none-any.whl → 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
lexicon/__init__.py CHANGED
@@ -1,5 +1,23 @@
1
1
  """Public package surface for the lexicon Python client."""
2
2
 
3
- from .lexicon import LexiconClient
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
+ )
4
12
 
5
- __all__ = ["LexiconClient"]
13
+ __all__ = [
14
+ "DEFAULT_HOST",
15
+ "LEXICON_PORT",
16
+ "Lexicon",
17
+ "TrackResponse",
18
+ "PlaylistResponse",
19
+ "TagResponse",
20
+ "TagCategoryResponse",
21
+ "CuePointResponse",
22
+ "TempoMarkerResponse",
23
+ ]
lexicon/client.py ADDED
@@ -0,0 +1,162 @@
1
+ """Core Lexicon client with a raw-request escape hatch."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ from typing import Any, Optional
8
+
9
+ import requests
10
+
11
+ from .resources.playlist_tracks import PlaylistTracks
12
+ from .resources.playlists import Playlists
13
+ from .resources.tag_categories import TagCategories
14
+ from .resources.tags import Tags
15
+ from .resources.tracks import Tracks
16
+ from .tools import playlists as playlist_tools
17
+ from .tools import tracks as track_tools
18
+
19
+ DEFAULT_HOST = os.environ.get("LEXICON_HOST", "localhost")
20
+ LEXICON_PORT = int(os.environ.get("LEXICON_PORT", "48624"))
21
+
22
+
23
+ class Tools:
24
+ """Namespace for helper tools."""
25
+
26
+ playlists = playlist_tools
27
+ tracks = track_tools
28
+
29
+
30
+ class Lexicon:
31
+ """Resource-grouped client for the Lexicon Local API."""
32
+
33
+ tracks: Tracks
34
+ playlists: Playlists
35
+ tags: Tags
36
+ tools: Tools
37
+
38
+ def __init__(
39
+ self,
40
+ *,
41
+ host: Optional[str] = None,
42
+ port: Optional[int | str] = None,
43
+ default_timeout: int = 20,
44
+ session: Optional[requests.Session] = None,
45
+ raise_on_error: bool = False,
46
+ raw_enums: bool = True,
47
+ ) -> None:
48
+ """Create a Lexicon client bound to an API instance.
49
+
50
+ Parameters
51
+ ----------
52
+ host
53
+ Hostname or IP for the Lexicon API.
54
+ port
55
+ API port number.
56
+ default_timeout
57
+ Default request timeout in seconds.
58
+ session
59
+ Optional requests session to reuse connections.
60
+ raise_on_error
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"``).
65
+ """
66
+ self.host = host or DEFAULT_HOST
67
+ self.port = int(port or LEXICON_PORT)
68
+ self.default_timeout = default_timeout
69
+ self.raise_on_error = raise_on_error
70
+ self.raw_enums = raw_enums
71
+ self._logger = logging.getLogger(__name__)
72
+ self._session = session
73
+
74
+ self.tracks: Tracks = Tracks(self)
75
+ self.playlists: Playlists = Playlists(self)
76
+ self.playlists.tracks = PlaylistTracks(
77
+ self, tracks=self.tracks, playlists=self.playlists
78
+ )
79
+ self.tags: Tags = Tags(self)
80
+ self.tags.categories = TagCategories(self)
81
+ self.tools = Tools()
82
+
83
+ def request(
84
+ self,
85
+ method: str,
86
+ path: str,
87
+ *,
88
+ params: Optional[dict[str, Any] | list[tuple[str, Any]]] = None,
89
+ json: Optional[dict[str, Any]] = None,
90
+ timeout: Optional[int] = None,
91
+ ) -> Optional[dict[str, Any] | list[Any]]:
92
+ """Send a raw request to the Lexicon API.
93
+
94
+ Parameters
95
+ ----------
96
+ method
97
+ HTTP method (GET, POST, PATCH, DELETE).
98
+ path
99
+ Endpoint path, with or without a leading `/v1`.
100
+ params
101
+ Query parameters for the request.
102
+ json
103
+ JSON payload for the request.
104
+ timeout
105
+ Timeout in seconds for this request.
106
+
107
+ Returns
108
+ -------
109
+ dict | list | None
110
+ Parsed JSON payload, or None if the response is empty or non-JSON.
111
+ """
112
+ if not path.startswith("/"):
113
+ path = "/" + path
114
+ if not path.startswith("/v1/"):
115
+ path = "/v1" + path
116
+ url = f"http://{self.host}:{self.port}{path}"
117
+
118
+ requester = self._session or requests
119
+ try:
120
+ response = requester.request(
121
+ method,
122
+ url,
123
+ params=params,
124
+ json=json,
125
+ timeout=timeout or self.default_timeout,
126
+ )
127
+ response.raise_for_status()
128
+ except requests.HTTPError as exc:
129
+ if self.raise_on_error:
130
+ raise
131
+ # Extract error message from response body if available
132
+ error_msg = str(exc)
133
+ try:
134
+ error_body = response.json()
135
+ if isinstance(error_body, dict):
136
+ # Try common error message fields
137
+ if "message" in error_body:
138
+ error_msg = f"{exc}\nServer message: {error_body['message']}"
139
+ elif "error" in error_body:
140
+ error_msg = f"{exc}\nServer error: {error_body['error']}"
141
+ elif "detail" in error_body:
142
+ error_msg = f"{exc}\nDetails: {error_body['detail']}"
143
+ except (ValueError, AttributeError, KeyError):
144
+ pass # Response wasn't JSON or didn't have expected fields
145
+ self._logger.warning("Request failed for %s %s: %s", method, url, error_msg)
146
+ return None
147
+ except Exception as exc: # noqa: BLE001 - surface request failures
148
+ if self.raise_on_error:
149
+ raise
150
+ self._logger.warning("Request failed for %s %s: %s", method, url, exc)
151
+ return None
152
+
153
+ if not response.content:
154
+ return None
155
+ try:
156
+ payload = response.json()
157
+ except ValueError: # noqa: PERF203 - only attempt JSON when present
158
+ self._logger.warning("Response from %s %s was not JSON", method, url)
159
+ return None
160
+ if isinstance(payload, (dict, list)):
161
+ return payload
162
+ return None
@@ -0,0 +1,15 @@
1
+ """Resource module exports."""
2
+
3
+ from .playlist_tracks import PlaylistTracks
4
+ from .playlists import Playlists
5
+ from .tag_categories import TagCategories
6
+ from .tags import Tags
7
+ from .tracks import Tracks
8
+
9
+ __all__ = [
10
+ "PlaylistTracks",
11
+ "Playlists",
12
+ "TagCategories",
13
+ "Tags",
14
+ "Tracks",
15
+ ]
@@ -0,0 +1,228 @@
1
+ """Shared types and validation helpers for resources.
2
+
3
+ This module contains:
4
+ - Validation mode type (shared across all resources)
5
+ - Color type definitions and normalization (used by tag_categories)
6
+ - Common validation normalizers (ID sequences, etc.)
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ import re
13
+ from typing import Literal, Sequence, get_args
14
+
15
+ from ..utils import unique_in_order
16
+
17
+ _logger = logging.getLogger(__name__)
18
+
19
+ # --- Shared Validation Mode --- #
20
+ ValidationMode = Literal["off", "warn", "strict"]
21
+
22
+
23
+ # --- Color Types and Normalization --- #
24
+ Color = Literal[
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",
53
+ ]
54
+ COLORS: tuple[Color, ...] = get_args(Color)
55
+ COLOR_RGBS: tuple[tuple[int, int, int], ...] = (
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
84
+ )
85
+
86
+
87
+ def _parse_color_rgb(value: object) -> tuple[int, int, int] | None:
88
+ """Parse a color input into an RGB tuple.
89
+
90
+ Parameters
91
+ ----------
92
+ value
93
+ Color input. Supported forms:
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``)
99
+
100
+ Returns
101
+ -------
102
+ tuple[int, int, int] or None
103
+ RGB tuple (0-255), or ``None`` when the input is ``None``.
104
+
105
+ Raises
106
+ ------
107
+ ValueError
108
+ If the input cannot be parsed as a color value.
109
+ """
110
+ if value is None:
111
+ return None
112
+ if isinstance(value, str):
113
+ if value.strip().lower() == "none":
114
+ return None
115
+ if value in COLORS:
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
+ )
122
+ if hex_match:
123
+ hex_value = hex_match.group(1)
124
+ if len(hex_value) in (3, 4):
125
+ hex_value = "".join(ch * 2 for ch in hex_value)
126
+ if len(hex_value) == 8:
127
+ hex_value = hex_value[:6]
128
+ return (
129
+ int(hex_value[0:2], 16),
130
+ int(hex_value[2:4], 16),
131
+ int(hex_value[4:6], 16),
132
+ )
133
+ raise ValueError(f"Unsupported string input {value!r}")
134
+
135
+ if isinstance(value, int):
136
+ if value < 0:
137
+ raise ValueError(f"Negative packed int {value!r}")
138
+ if value > 0xFFFFFF:
139
+ value = value & 0xFFFFFF
140
+ return ((value >> 16) & 0xFF, (value >> 8) & 0xFF, value & 0xFF)
141
+
142
+ if isinstance(value, (tuple, list)) and len(value) in (3, 4):
143
+ rgb_values = value[:3]
144
+ if all(isinstance(channel, (int, float)) for channel in rgb_values):
145
+ rgb = []
146
+ for channel in rgb_values:
147
+ channel_value = float(channel)
148
+ if channel_value <= 1:
149
+ channel_value *= 255
150
+ rgb.append(int(max(0, min(255, round(channel_value)))))
151
+ return (rgb[0], rgb[1], rgb[2])
152
+ raise ValueError(f"Invalid RGB tuple values {value!r}")
153
+
154
+ raise ValueError(f"Unsupported input type {type(value)}")
155
+
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
+
179
+ def _nearest_color(rgb: tuple[int, int, int]) -> Color:
180
+ """Find the nearest Lexicon color to the given RGB values."""
181
+ best_index = 0
182
+ best_distance = float("inf")
183
+ for index, candidate in enumerate(COLOR_RGBS):
184
+ dr = rgb[0] - candidate[0]
185
+ dg = rgb[1] - candidate[1]
186
+ db = rgb[2] - candidate[2]
187
+ distance = dr * dr + dg * dg + db * db
188
+ if distance < best_distance:
189
+ best_distance = distance
190
+ best_index = index
191
+ return COLORS[best_index]
192
+
193
+
194
+ # --- ID Sequence Normalization --- #
195
+ def _normalize_id_sequence(ids: int | Sequence[int] | object) -> list[int] | None:
196
+ """Normalize single ID or sequence of IDs to a deduplicated list.
197
+
198
+ Parameters
199
+ ----------
200
+ ids
201
+ Single integer ID or sequence of integer IDs.
202
+
203
+ Returns
204
+ -------
205
+ list[int] | None
206
+ Deduplicated list of valid IDs (>= 1), or None if:
207
+ - Input is not int or sequence (or is str/bytes)
208
+ - No valid IDs found (all < 1)
209
+
210
+ Notes
211
+ -----
212
+ Used for normalizing track IDs, tag IDs, and similar integer sequences.
213
+ Filters out any non-integer elements and values < 1, then deduplicates.
214
+ """
215
+ if isinstance(ids, int):
216
+ id_list = [ids]
217
+ elif isinstance(ids, Sequence) and not isinstance(ids, (str, bytes)):
218
+ id_list = list(ids)
219
+ else:
220
+ return None
221
+
222
+ valid_ids = [
223
+ id_val for id_val in id_list if isinstance(id_val, int) and id_val >= 1
224
+ ]
225
+ if not valid_ids:
226
+ return None
227
+
228
+ return unique_in_order(valid_ids)
@@ -0,0 +1,69 @@
1
+ """Base resource helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Optional, TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING: # pragma: no cover
8
+ from ..client import Lexicon
9
+
10
+
11
+ class Resource:
12
+ """Shared helpers for resource classes."""
13
+
14
+ def __init__(self, client: "Lexicon") -> None:
15
+ self._client = client
16
+
17
+ @property
18
+ def _logger(self):
19
+ return self._client._logger
20
+
21
+ def _request(
22
+ self,
23
+ method: str,
24
+ path: str,
25
+ *,
26
+ params: Optional[dict[str, Any] | list[tuple[str, Any]]] = None,
27
+ json: Optional[dict[str, Any]] = None,
28
+ timeout: Optional[int] = None,
29
+ ) -> Optional[dict[str, Any] | list[Any]]:
30
+ return self._client.request(
31
+ method, path, params=params, json=json, timeout=timeout
32
+ )
33
+
34
+ def _get(
35
+ self,
36
+ path: str,
37
+ *,
38
+ params: Optional[dict[str, Any] | list[tuple[str, Any]]] = None,
39
+ timeout: Optional[int] = None,
40
+ ) -> Optional[dict[str, Any] | list[Any]]:
41
+ return self._request("GET", path, params=params, timeout=timeout)
42
+
43
+ def _post(
44
+ self,
45
+ path: str,
46
+ *,
47
+ json: Optional[dict[str, Any]] = None,
48
+ timeout: Optional[int] = None,
49
+ ) -> Optional[dict[str, Any] | list[Any]]:
50
+ return self._request("POST", path, json=json, timeout=timeout)
51
+
52
+ def _patch(
53
+ self,
54
+ path: str,
55
+ *,
56
+ json: Optional[dict[str, Any]] = None,
57
+ timeout: Optional[int] = None,
58
+ ) -> Optional[dict[str, Any] | list[Any]]:
59
+ return self._request("PATCH", path, json=json, timeout=timeout)
60
+
61
+ def _delete(
62
+ self,
63
+ path: str,
64
+ *,
65
+ params: Optional[dict[str, Any] | list[tuple[str, Any]]] = None,
66
+ json: Optional[dict[str, Any]] = None,
67
+ timeout: Optional[int] = None,
68
+ ) -> Optional[dict[str, Any] | list[Any]]:
69
+ return self._request("DELETE", path, params=params, json=json, timeout=timeout)