lexicon-python 0.3.0__py3-none-any.whl → 0.5.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,23 +1,50 @@
1
1
  """Public package surface for the lexicon Python client."""
2
2
 
3
- from .client import DEFAULT_HOST, LEXICON_PORT, Lexicon
3
+ from .client import (
4
+ DEFAULT_APP_NAME,
5
+ DEFAULT_HOST,
6
+ LEXICON_PORT,
7
+ Lexicon,
8
+ LexiconAuthError,
9
+ LexiconConnectionError,
10
+ )
11
+ from .colors import Color, color_rgb
12
+ from .resources.control_types import CONTROL_ACTIONS, ControlAction
13
+ from .resources.player_types import PlayingResponse, RemoteTrackResponse
4
14
  from .resources.playlists_types import PlaylistResponse
5
15
  from .resources.tag_categories_types import TagCategoryResponse
6
16
  from .resources.tags_types import TagResponse
7
17
  from .resources.tracks_types import (
8
18
  CuePointResponse,
19
+ CuePointUpdate,
20
+ TEMPO_RANGES,
9
21
  TempoMarkerResponse,
22
+ TempoMarkerUpdate,
23
+ TempoRange,
10
24
  TrackResponse,
11
25
  )
12
26
 
13
27
  __all__ = [
28
+ "Color",
29
+ "color_rgb",
30
+ "DEFAULT_APP_NAME",
14
31
  "DEFAULT_HOST",
15
32
  "LEXICON_PORT",
16
33
  "Lexicon",
34
+ "LexiconAuthError",
35
+ "LexiconConnectionError",
36
+ "ControlAction",
37
+ "CONTROL_ACTIONS",
17
38
  "TrackResponse",
39
+ "PlayingResponse",
40
+ "RemoteTrackResponse",
18
41
  "PlaylistResponse",
19
42
  "TagResponse",
20
43
  "TagCategoryResponse",
21
44
  "CuePointResponse",
45
+ "CuePointUpdate",
22
46
  "TempoMarkerResponse",
47
+ "TempoMarkerUpdate",
48
+ "TempoRange",
49
+ "TEMPO_RANGES",
23
50
  ]
lexicon/client.py CHANGED
@@ -8,6 +8,9 @@ from typing import Any, Optional
8
8
 
9
9
  import requests
10
10
 
11
+ from .resources._common_types import ValidationMode
12
+ from .resources.control_types import CONTROL_ACTIONS, ControlAction
13
+ from .resources.player import Player
11
14
  from .resources.playlist_tracks import PlaylistTracks
12
15
  from .resources.playlists import Playlists
13
16
  from .resources.tag_categories import TagCategories
@@ -18,6 +21,76 @@ from .tools import tracks as track_tools
18
21
 
19
22
  DEFAULT_HOST = os.environ.get("LEXICON_HOST", "localhost")
20
23
  LEXICON_PORT = int(os.environ.get("LEXICON_PORT", "48624"))
24
+ DEFAULT_APP_NAME = os.environ.get("LEXICON_APP_NAME", "lexicon-python")
25
+
26
+ # Starting in Lexicon v1.12, a request from an app the user has not answered for
27
+ # brings up a popup and waits up to a minute for the answer, so the connection
28
+ # probe has to outlast that wait.
29
+ APPROVAL_TIMEOUT = 70
30
+ # POST /v1/auth/pair waits up to two minutes for the user.
31
+ PAIR_TIMEOUT = 130
32
+
33
+ # Error codes Lexicon v1.12 uses to refuse an app, with what the caller can do.
34
+ _AUTH_ERRORS: dict[int, str] = {
35
+ 6: (
36
+ "The user blocked app {app!r} in Lexicon. Unblock it in the Lexicon "
37
+ "settings under Local API."
38
+ ),
39
+ 9: (
40
+ "Lexicon refused the token, or the request did not name its app. Check the "
41
+ "token (token= or LEXICON_TOKEN); a wrong token counts as no token."
42
+ ),
43
+ 10: (
44
+ "App {app!r} needs approval. Lexicon showed a popup and got no answer in "
45
+ "time, or it was closed without choosing. Allow the app in Lexicon and try "
46
+ "again; Lexicon waits a minute before asking about the same app again."
47
+ ),
48
+ 11: (
49
+ "App {app!r} is already allowed, and this request did not send its token. "
50
+ "Pass the token (token= or LEXICON_TOKEN), or remove the app in the Lexicon "
51
+ "settings under Local API so it can ask again."
52
+ ),
53
+ }
54
+
55
+
56
+ class LexiconConnectionError(ConnectionError):
57
+ """Raised when the client cannot reach the Lexicon Local API on construction."""
58
+
59
+
60
+ class LexiconAuthError(LexiconConnectionError):
61
+ """Raised when Lexicon refuses the app: not approved, blocked, or a bad token.
62
+
63
+ Lexicon v1.12 and newer ask the user before an app can use the Local API.
64
+ ``error_code`` holds Lexicon's code: ``6`` blocked, ``9`` bad or missing
65
+ token, ``10`` not approved yet, ``11`` already allowed but no token sent.
66
+ """
67
+
68
+ def __init__(
69
+ self,
70
+ message: str,
71
+ *,
72
+ error_code: Optional[int] = None,
73
+ status_code: Optional[int] = None,
74
+ ) -> None:
75
+ super().__init__(message)
76
+ self.error_code = error_code
77
+ self.status_code = status_code
78
+
79
+
80
+ def _error_body(response: Any) -> tuple[Optional[int], Optional[str]]:
81
+ """Return the ``errorCode`` and ``message`` of a Lexicon error response."""
82
+ try:
83
+ body = response.json()
84
+ except (ValueError, AttributeError):
85
+ return None, None
86
+ if not isinstance(body, dict):
87
+ return None, None
88
+ code = body.get("errorCode")
89
+ message = body.get("message")
90
+ return (
91
+ code if isinstance(code, int) and not isinstance(code, bool) else None,
92
+ message if isinstance(message, str) else None,
93
+ )
21
94
 
22
95
 
23
96
  class Tools:
@@ -33,6 +106,7 @@ class Lexicon:
33
106
  tracks: Tracks
34
107
  playlists: Playlists
35
108
  tags: Tags
109
+ player: Player
36
110
  tools: Tools
37
111
 
38
112
  def __init__(
@@ -44,6 +118,9 @@ class Lexicon:
44
118
  session: Optional[requests.Session] = None,
45
119
  raise_on_error: bool = False,
46
120
  raw_enums: bool = True,
121
+ verify_connection: bool = True,
122
+ app_name: Optional[str] = None,
123
+ token: Optional[str] = None,
47
124
  ) -> None:
48
125
  """Create a Lexicon client bound to an API instance.
49
126
 
@@ -52,7 +129,8 @@ class Lexicon:
52
129
  host
53
130
  Hostname or IP for the Lexicon API.
54
131
  port
55
- API port number.
132
+ API port number. Starting in Lexicon v1.12 the user can change it in
133
+ the Local API settings.
56
134
  default_timeout
57
135
  Default request timeout in seconds.
58
136
  session
@@ -62,12 +140,36 @@ class Lexicon:
62
140
  raw_enums
63
141
  If True (default), return API enum values as raw codes (e.g. ``"1"``).
64
142
  If False, convert to human-readable names (e.g. ``"normal"``).
143
+ verify_connection
144
+ If True (default), probe the Lexicon API on construction and raise
145
+ :class:`LexiconConnectionError` if it isn't reachable. Set to False
146
+ to construct without a running Lexicon (e.g. for tests).
147
+
148
+ Starting in Lexicon v1.12, the first request from an app Lexicon has
149
+ not seen brings up a popup asking the user to allow it. The probe
150
+ waits up to ``APPROVAL_TIMEOUT`` seconds for the answer and raises
151
+ :class:`LexiconAuthError` when the app is refused.
152
+ app_name
153
+ The name Lexicon shows the user in the approval popup and in its Local
154
+ API settings, sent as the ``X-Lexicon-App`` header. Defaults to the
155
+ ``LEXICON_APP_NAME`` environment variable, then ``"lexicon-python"``.
156
+ Lexicon asks once per name, so give each tool its own.
157
+ token
158
+ The app's API token, sent as ``Authorization: Bearer <token>``.
159
+ Defaults to the ``LEXICON_TOKEN`` environment variable. Get one from
160
+ :meth:`pair`, from ``lex.token`` after the user allows the app, or
161
+ copy the API token from the Lexicon settings under Local API. On the
162
+ computer running Lexicon an allowed app is let in by name alone; from
163
+ another device the token is required. Lexicon v1.11 and older ignore
164
+ both headers.
65
165
  """
66
166
  self.host = host or DEFAULT_HOST
67
167
  self.port = int(port or LEXICON_PORT)
68
168
  self.default_timeout = default_timeout
69
169
  self.raise_on_error = raise_on_error
70
170
  self.raw_enums = raw_enums
171
+ self.app_name = app_name or DEFAULT_APP_NAME
172
+ self.token: Optional[str] = token or os.environ.get("LEXICON_TOKEN") or None
71
173
  self._logger = logging.getLogger(__name__)
72
174
  self._session = session
73
175
 
@@ -78,8 +180,231 @@ class Lexicon:
78
180
  )
79
181
  self.tags: Tags = Tags(self)
80
182
  self.tags.categories = TagCategories(self)
183
+ self.player: Player = Player(self, tracks=self.tracks)
81
184
  self.tools = Tools()
82
185
 
186
+ if verify_connection:
187
+ self._verify_connection()
188
+
189
+ def _headers(self) -> dict[str, str]:
190
+ """Return the headers that identify this app to Lexicon."""
191
+ headers = {"X-Lexicon-App": self.app_name}
192
+ if self.token:
193
+ headers["Authorization"] = f"Bearer {self.token}"
194
+ return headers
195
+
196
+ def _remember_token(self, response: Any) -> None:
197
+ """Keep the token Lexicon sends when the user allows this app.
198
+
199
+ Lexicon v1.12 returns it in the ``X-Lexicon-Token`` header of the request
200
+ that brought up the approval popup. It is never shown again, so the caller
201
+ should store ``lex.token``.
202
+ """
203
+ headers = getattr(response, "headers", None)
204
+ if not headers:
205
+ return
206
+ new_token = headers.get("X-Lexicon-Token")
207
+ if isinstance(new_token, str) and new_token and new_token != self.token:
208
+ self.token = new_token
209
+ self._logger.info(
210
+ "Lexicon issued a token for app %r. Store lex.token and pass it as "
211
+ "token= (or LEXICON_TOKEN) next time; Lexicon cannot show it again.",
212
+ self.app_name,
213
+ )
214
+
215
+ def _auth_error(
216
+ self, response: Any, *, app_name: Optional[str] = None
217
+ ) -> Optional[LexiconAuthError]:
218
+ """Build a :class:`LexiconAuthError` when Lexicon refused the app."""
219
+ if response is None:
220
+ return None
221
+ code, message = _error_body(response)
222
+ if code not in _AUTH_ERRORS:
223
+ return None
224
+ hint = _AUTH_ERRORS[code].format(app=app_name or self.app_name)
225
+ detail = f" Lexicon said: {message}" if message else ""
226
+ return LexiconAuthError(
227
+ f"{hint}{detail}",
228
+ error_code=code,
229
+ status_code=getattr(response, "status_code", None),
230
+ )
231
+
232
+ def _verify_connection(self) -> None:
233
+ """Probe the Lexicon API to confirm it's reachable and lets this app in.
234
+
235
+ Sends a single ``GET /v1/playing`` request, bypassing the swallow-and-warn
236
+ behavior of :meth:`request`. Logs at INFO on success; raises
237
+ :class:`LexiconAuthError` when Lexicon refuses the app and
238
+ :class:`LexiconConnectionError` on any other failure.
239
+ """
240
+ url = f"http://{self.host}:{self.port}/v1/playing"
241
+ requester = self._session or requests
242
+ response = None
243
+ try:
244
+ response = requester.request(
245
+ "GET",
246
+ url,
247
+ headers=self._headers(),
248
+ timeout=max(self.default_timeout, APPROVAL_TIMEOUT),
249
+ )
250
+ response.raise_for_status()
251
+ except requests.HTTPError as exc:
252
+ # An error Response is falsy, so compare with None rather than using `or`.
253
+ auth_error = self._auth_error(
254
+ exc.response if exc.response is not None else response
255
+ )
256
+ if auth_error is not None:
257
+ raise auth_error from exc
258
+ raise LexiconConnectionError(
259
+ f"Lexicon at {self.host}:{self.port} answered with an error "
260
+ f"({exc.__class__.__name__}: {exc})."
261
+ ) from exc
262
+ except (requests.ConnectionError, requests.Timeout) as exc:
263
+ raise LexiconConnectionError(
264
+ f"Could not reach Lexicon at {self.host}:{self.port} "
265
+ f"({exc.__class__.__name__}: {exc}). "
266
+ "Is Lexicon running with the Local API enabled?"
267
+ ) from exc
268
+ self._remember_token(response)
269
+ self._logger.info("Connected to Lexicon at %s:%s", self.host, self.port)
270
+
271
+ def pair(self, name: Optional[str] = None, *, timeout: Optional[int] = None) -> str:
272
+ """Ask the user to allow this app, and return its token.
273
+
274
+ Lexicon v1.12 and newer show the user a popup and this call waits for the
275
+ answer, up to two minutes. The token is also kept in ``lex.token``. Store
276
+ it: Lexicon only stores a hash, cannot show it again, and refuses a second
277
+ pairing for a name it already allowed.
278
+
279
+ Parameters
280
+ ----------
281
+ name
282
+ The app name to pair. Defaults to ``app_name``, and becomes the new
283
+ ``app_name`` so later requests match the token.
284
+ timeout
285
+ Timeout in seconds. Defaults to ``PAIR_TIMEOUT``.
286
+
287
+ Returns
288
+ -------
289
+ str
290
+ The token for this app.
291
+
292
+ Raises
293
+ ------
294
+ LexiconAuthError
295
+ When the user blocks the app (code ``6``), does not answer or closes
296
+ the popup (``10``), or the name is already allowed (``11``; the user
297
+ has to remove the app in the Lexicon settings to pair again).
298
+ LexiconConnectionError
299
+ When Lexicon cannot be reached or answers without a token, for example
300
+ a Lexicon older than v1.12.
301
+ """
302
+ app_name = name or self.app_name
303
+ url = f"http://{self.host}:{self.port}/v1/auth/pair"
304
+ requester = self._session or requests
305
+ headers = {"X-Lexicon-App": app_name}
306
+ response = None
307
+ try:
308
+ response = requester.request(
309
+ "POST",
310
+ url,
311
+ headers=headers,
312
+ json={"name": app_name},
313
+ timeout=timeout or PAIR_TIMEOUT,
314
+ )
315
+ response.raise_for_status()
316
+ except requests.HTTPError as exc:
317
+ auth_error = self._auth_error(
318
+ exc.response if exc.response is not None else response,
319
+ app_name=app_name,
320
+ )
321
+ if auth_error is not None:
322
+ raise auth_error from exc
323
+ raise LexiconConnectionError(
324
+ f"Pairing with Lexicon at {self.host}:{self.port} failed ({exc}). "
325
+ "Lexicon v1.11 and older have no pairing and need no token."
326
+ ) from exc
327
+ except (requests.ConnectionError, requests.Timeout) as exc:
328
+ raise LexiconConnectionError(
329
+ f"Could not reach Lexicon at {self.host}:{self.port} "
330
+ f"({exc.__class__.__name__}: {exc})."
331
+ ) from exc
332
+
333
+ token: Optional[str] = None
334
+ try:
335
+ body = response.json()
336
+ except ValueError:
337
+ body = None
338
+ data = body.get("data") if isinstance(body, dict) else None
339
+ if isinstance(data, dict) and isinstance(data.get("token"), str):
340
+ token = data["token"]
341
+ if not token:
342
+ header_token = response.headers.get("X-Lexicon-Token")
343
+ token = header_token if isinstance(header_token, str) else None
344
+ if not token:
345
+ raise LexiconConnectionError(
346
+ "Lexicon accepted the pairing request but sent no token."
347
+ )
348
+ self.app_name = app_name
349
+ self.token = token
350
+ return token
351
+
352
+ def control(
353
+ self,
354
+ action: ControlAction,
355
+ /,
356
+ *,
357
+ validation: ValidationMode = "warn",
358
+ timeout: Optional[int] = None,
359
+ **parameters: Any,
360
+ ) -> bool:
361
+ """Run a Lexicon command, the same ones that can be bound to a hotkey.
362
+
363
+ Parameters
364
+ ----------
365
+ action
366
+ Action name, such as ``"MusicPlayer_Eject"``.
367
+ validation
368
+ Validation mode: ``"off"`` sends any action name, ``"warn"`` logs a
369
+ name missing from ``ControlAction`` and sends nothing, and ``"strict"``
370
+ raises on one. Use ``"off"`` for
371
+ ``PluginRunAction_<pluginId>:<actionId>``, which is not in the list.
372
+ timeout
373
+ Timeout in seconds for this request.
374
+ **parameters
375
+ The action's parameters under their API names, such as
376
+ ``lex.control("MusicPlayer_Load", id=123)``. See the Control Actions
377
+ schema in ``docs/lexicon-api-docs.yaml``.
378
+
379
+ Returns
380
+ -------
381
+ bool
382
+ True if Lexicon accepted the request, otherwise False.
383
+
384
+ Notes
385
+ -----
386
+ True does not mean the action did anything. Lexicon returns before the
387
+ action finishes, has no completion signal, and accepts an action whose
388
+ target is missing, such as a hotkey for the selected tracks with nothing
389
+ selected, or ``MusicPlayer_Load`` without ``id``.
390
+
391
+ ``ControlAction`` follows the newest spec, so it can name actions that the
392
+ running Lexicon rejects. See ``docs/api-issues.md``.
393
+ """
394
+ if validation != "off" and action not in CONTROL_ACTIONS:
395
+ if validation == "strict":
396
+ raise ValueError(f"Invalid action for control: {action}")
397
+ self._logger.warning("Invalid action for control: %s", action)
398
+ return False
399
+
400
+ payload: dict[str, Any] = {"action": action}
401
+ if parameters:
402
+ payload["parameters"] = parameters
403
+ response = self.request("POST", "/control", json=payload, timeout=timeout)
404
+ # Lexicon 1.11.15 answers HTTP 200 with only a "message" when a handler throws
405
+ # an error that has no code. See docs/api-issues.md.
406
+ return isinstance(response, dict) and "message" not in response
407
+
83
408
  def request(
84
409
  self,
85
410
  method: str,
@@ -122,6 +447,7 @@ class Lexicon:
122
447
  url,
123
448
  params=params,
124
449
  json=json,
450
+ headers=self._headers(),
125
451
  timeout=timeout or self.default_timeout,
126
452
  )
127
453
  response.raise_for_status()
@@ -142,6 +468,9 @@ class Lexicon:
142
468
  error_msg = f"{exc}\nDetails: {error_body['detail']}"
143
469
  except (ValueError, AttributeError, KeyError):
144
470
  pass # Response wasn't JSON or didn't have expected fields
471
+ auth_error = self._auth_error(response)
472
+ if auth_error is not None:
473
+ error_msg = f"{error_msg}\n{auth_error}"
145
474
  self._logger.warning("Request failed for %s %s: %s", method, url, error_msg)
146
475
  return None
147
476
  except Exception as exc: # noqa: BLE001 - surface request failures
@@ -150,6 +479,7 @@ class Lexicon:
150
479
  self._logger.warning("Request failed for %s %s: %s", method, url, exc)
151
480
  return None
152
481
 
482
+ self._remember_token(response)
153
483
  if not response.content:
154
484
  return None
155
485
  try:
lexicon/colors.py ADDED
@@ -0,0 +1,29 @@
1
+ """Public color helpers — Lexicon color name ⇄ RGB.
2
+
3
+ Lexicon stores colors (on cuepoints, tags, etc.) as Lexicon-specific name
4
+ strings declared by the :data:`Color` literal type. This module exposes the
5
+ small surface needed to do anything with those values besides comparing them
6
+ to other Lexicon names — primarily, converting them to RGB for rendering or
7
+ for mapping into another color system.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from .resources._common_types import COLORS, COLOR_RGBS, Color
13
+
14
+ __all__ = ["Color", "color_rgb"]
15
+
16
+
17
+ def color_rgb(name: Color) -> tuple[int, int, int]:
18
+ """Return the RGB tuple ``(r, g, b)`` (0–255) for a Lexicon color name.
19
+
20
+ Raises:
21
+ ValueError: If ``name`` is not a recognized Lexicon color.
22
+ """
23
+ try:
24
+ index = COLORS.index(name)
25
+ except ValueError:
26
+ raise ValueError(
27
+ f"Unknown Lexicon color: {name!r} (valid: {', '.join(COLORS)})"
28
+ ) from None
29
+ return COLOR_RGBS[index]
@@ -1,5 +1,6 @@
1
1
  """Resource module exports."""
2
2
 
3
+ from .player import Player
3
4
  from .playlist_tracks import PlaylistTracks
4
5
  from .playlists import Playlists
5
6
  from .tag_categories import TagCategories
@@ -7,6 +8,7 @@ from .tags import Tags
7
8
  from .tracks import Tracks
8
9
 
9
10
  __all__ = [
11
+ "Player",
10
12
  "PlaylistTracks",
11
13
  "Playlists",
12
14
  "TagCategories",