lexicon-python 0.1.2__py3-none-any.whl → 0.2.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,8 @@
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.tracks_types import *
4
5
 
5
- __all__ = ["LexiconClient"]
6
+
7
+
8
+ __all__ = ["DEFAULT_HOST", "LEXICON_PORT", "Lexicon"]
lexicon/client.py ADDED
@@ -0,0 +1,147 @@
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
+ DEFAULT_HOST = os.environ.get("LEXICON_HOST", "localhost")
18
+ LEXICON_PORT = int(os.environ.get("LEXICON_PORT", "48624"))
19
+
20
+
21
+ class Lexicon:
22
+ """Resource-grouped client for the Lexicon Local API."""
23
+
24
+ tracks: Tracks
25
+ playlists: Playlists
26
+ tags: Tags
27
+ tools: Any
28
+
29
+ def __init__(
30
+ self,
31
+ *,
32
+ host: Optional[str] = None,
33
+ port: Optional[int | str] = None,
34
+ default_timeout: int = 20,
35
+ session: Optional[requests.Session] = None,
36
+ raise_on_error: bool = False,
37
+ ) -> None:
38
+ """Create a Lexicon client bound to an API instance.
39
+
40
+ Parameters
41
+ ----------
42
+ host
43
+ Hostname or IP for the Lexicon API.
44
+ port
45
+ API port number.
46
+ default_timeout
47
+ Default request timeout in seconds.
48
+ session
49
+ Optional requests session to reuse connections.
50
+ raise_on_error
51
+ If True, raise HTTP errors instead of returning None.
52
+ """
53
+ self.host = host or DEFAULT_HOST
54
+ self.port = int(port or LEXICON_PORT)
55
+ self.default_timeout = default_timeout
56
+ self.raise_on_error = raise_on_error
57
+ self._logger = logging.getLogger(__name__)
58
+ self._session = session
59
+
60
+ self.tracks: Tracks = Tracks(self)
61
+ self.playlists: Playlists = Playlists(self)
62
+ self.playlists.tracks = PlaylistTracks(self, tracks=self.tracks, playlists=self.playlists)
63
+ self.tags: Tags = Tags(self)
64
+ self.tags.categories = TagCategories(self)
65
+ self.tools = type("Tools", (), {})()
66
+ self.tools.playlists = playlist_tools
67
+
68
+ def request(
69
+ self,
70
+ method: str,
71
+ path: str,
72
+ *,
73
+ params: Optional[dict[str, Any] | list[tuple[str, Any]]] = None,
74
+ json: Optional[dict[str, Any]] = None,
75
+ timeout: Optional[int] = None,
76
+ ) -> Optional[dict[str, Any] | list[Any]]:
77
+ """Send a raw request to the Lexicon API.
78
+
79
+ Parameters
80
+ ----------
81
+ method
82
+ HTTP method (GET, POST, PATCH, DELETE).
83
+ path
84
+ Endpoint path, with or without a leading `/v1`.
85
+ params
86
+ Query parameters for the request.
87
+ json
88
+ JSON payload for the request.
89
+ timeout
90
+ Timeout in seconds for this request.
91
+
92
+ Returns
93
+ -------
94
+ dict | list | None
95
+ Parsed JSON payload, or None if the response is empty or non-JSON.
96
+ """
97
+ if not path.startswith("/"):
98
+ path = "/" + path
99
+ if not path.startswith("/v1/"):
100
+ path = "/v1" + path
101
+ url = f"http://{self.host}:{self.port}{path}"
102
+
103
+ requester = self._session or requests
104
+ try:
105
+ response = requester.request(
106
+ method,
107
+ url,
108
+ params=params,
109
+ json=json,
110
+ timeout=timeout or self.default_timeout,
111
+ )
112
+ response.raise_for_status()
113
+ except requests.HTTPError as exc:
114
+ if self.raise_on_error:
115
+ raise
116
+ # Extract error message from response body if available
117
+ error_msg = str(exc)
118
+ try:
119
+ error_body = response.json()
120
+ if isinstance(error_body, dict):
121
+ # Try common error message fields
122
+ if "message" in error_body:
123
+ error_msg = f"{exc}\nServer message: {error_body['message']}"
124
+ elif "error" in error_body:
125
+ error_msg = f"{exc}\nServer error: {error_body['error']}"
126
+ elif "detail" in error_body:
127
+ error_msg = f"{exc}\nDetails: {error_body['detail']}"
128
+ except (ValueError, AttributeError, KeyError):
129
+ pass # Response wasn't JSON or didn't have expected fields
130
+ self._logger.warning("Request failed for %s %s: %s", method, url, error_msg)
131
+ return None
132
+ except Exception as exc: # noqa: BLE001 - surface request failures
133
+ if self.raise_on_error:
134
+ raise
135
+ self._logger.warning("Request failed for %s %s: %s", method, url, exc)
136
+ return None
137
+
138
+ if not response.content:
139
+ return None
140
+ try:
141
+ payload = response.json()
142
+ except ValueError: # noqa: PERF203 - only attempt JSON when present
143
+ self._logger.warning("Response from %s %s was not JSON", method, url)
144
+ return None
145
+ if isinstance(payload, (dict, list)):
146
+ return payload
147
+ 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,179 @@
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", "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",
29
+ ]
30
+ COLORS: tuple[Color, ...] = get_args(Color)
31
+ 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
60
+ )
61
+
62
+
63
+ def _normalize_color(value: object) -> Color | None:
64
+ """Normalize color inputs to the nearest Lexicon color name.
65
+
66
+ Parameters
67
+ ----------
68
+ value
69
+ 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``)
76
+
77
+ Returns
78
+ -------
79
+ Color or None
80
+ The nearest Lexicon color name, or ``None`` when the input is ``None``.
81
+
82
+ Raises
83
+ ------
84
+ ValueError
85
+ If the input cannot be parsed as a color value.
86
+ """
87
+ if value is None:
88
+ return None
89
+ if isinstance(value, str):
90
+ if value.strip().lower() == "none":
91
+ return None
92
+ 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)
95
+ if hex_match:
96
+ hex_value = hex_match.group(1)
97
+ if len(hex_value) in (3, 4):
98
+ hex_value = "".join(ch * 2 for ch in hex_value)
99
+ if len(hex_value) == 8:
100
+ hex_value = hex_value[:6]
101
+ rgb = (
102
+ int(hex_value[0:2], 16),
103
+ int(hex_value[2:4], 16),
104
+ int(hex_value[4:6], 16),
105
+ )
106
+ return _nearest_color(rgb)
107
+ raise ValueError(f"Unsupported string input {value!r}")
108
+
109
+ if isinstance(value, int):
110
+ if value < 0:
111
+ raise ValueError(f"Negative packed int {value!r}")
112
+ if value > 0xFFFFFF:
113
+ value = value & 0xFFFFFF
114
+ rgb = ((value >> 16) & 0xFF, (value >> 8) & 0xFF, value & 0xFF)
115
+ return _nearest_color(rgb)
116
+
117
+ if isinstance(value, (tuple, list)) and len(value) in (3, 4):
118
+ rgb_values = value[:3]
119
+ if all(isinstance(channel, (int, float)) for channel in rgb_values):
120
+ rgb = []
121
+ for channel in rgb_values:
122
+ channel_value = float(channel)
123
+ if channel_value <= 1:
124
+ channel_value *= 255
125
+ rgb.append(int(max(0, min(255, round(channel_value)))))
126
+ return _nearest_color((rgb[0], rgb[1], rgb[2]))
127
+ raise ValueError(f"Invalid RGB tuple values {value!r}")
128
+
129
+ raise ValueError(f"Unsupported input type {type(value)}")
130
+
131
+
132
+ def _nearest_color(rgb: tuple[int, int, int]) -> Color:
133
+ """Find the nearest Lexicon color to the given RGB values."""
134
+ best_index = 0
135
+ best_distance = float("inf")
136
+ for index, candidate in enumerate(COLOR_RGBS):
137
+ dr = rgb[0] - candidate[0]
138
+ dg = rgb[1] - candidate[1]
139
+ db = rgb[2] - candidate[2]
140
+ distance = dr * dr + dg * dg + db * db
141
+ if distance < best_distance:
142
+ best_distance = distance
143
+ best_index = index
144
+ return COLORS[best_index]
145
+
146
+
147
+ # --- ID Sequence Normalization --- #
148
+ def _normalize_id_sequence(ids: int | Sequence[int] | object) -> list[int] | None:
149
+ """Normalize single ID or sequence of IDs to a deduplicated list.
150
+
151
+ Parameters
152
+ ----------
153
+ ids
154
+ Single integer ID or sequence of integer IDs.
155
+
156
+ Returns
157
+ -------
158
+ list[int] | None
159
+ Deduplicated list of valid IDs (>= 1), or None if:
160
+ - Input is not int or sequence (or is str/bytes)
161
+ - No valid IDs found (all < 1)
162
+
163
+ Notes
164
+ -----
165
+ Used for normalizing track IDs, tag IDs, and similar integer sequences.
166
+ Filters out any non-integer elements and values < 1, then deduplicates.
167
+ """
168
+ if isinstance(ids, int):
169
+ id_list = [ids]
170
+ elif isinstance(ids, Sequence) and not isinstance(ids, (str, bytes)):
171
+ id_list = list(ids)
172
+ else:
173
+ return None
174
+
175
+ valid_ids = [id_val for id_val in id_list if isinstance(id_val, int) and id_val >= 1]
176
+ if not valid_ids:
177
+ return None
178
+
179
+ return unique_in_order(valid_ids)
@@ -0,0 +1,67 @@
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(method, path, params=params, json=json, timeout=timeout)
31
+
32
+ def _get(
33
+ self,
34
+ path: str,
35
+ *,
36
+ params: Optional[dict[str, Any] | list[tuple[str, Any]]] = None,
37
+ timeout: Optional[int] = None,
38
+ ) -> Optional[dict[str, Any] | list[Any]]:
39
+ return self._request("GET", path, params=params, timeout=timeout)
40
+
41
+ def _post(
42
+ self,
43
+ path: str,
44
+ *,
45
+ json: Optional[dict[str, Any]] = None,
46
+ timeout: Optional[int] = None,
47
+ ) -> Optional[dict[str, Any] | list[Any]]:
48
+ return self._request("POST", path, json=json, timeout=timeout)
49
+
50
+ def _patch(
51
+ self,
52
+ path: str,
53
+ *,
54
+ json: Optional[dict[str, Any]] = None,
55
+ timeout: Optional[int] = None,
56
+ ) -> Optional[dict[str, Any] | list[Any]]:
57
+ return self._request("PATCH", path, json=json, timeout=timeout)
58
+
59
+ def _delete(
60
+ self,
61
+ path: str,
62
+ *,
63
+ params: Optional[dict[str, Any] | list[tuple[str, Any]]] = None,
64
+ json: Optional[dict[str, Any]] = None,
65
+ timeout: Optional[int] = None,
66
+ ) -> Optional[dict[str, Any] | list[Any]]:
67
+ return self._request("DELETE", path, params=params, json=json, timeout=timeout)