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 +5 -2
- lexicon/client.py +147 -0
- lexicon/resources/__init__.py +15 -0
- lexicon/resources/_common_types.py +179 -0
- lexicon/resources/base.py +67 -0
- lexicon/resources/playlist_tracks.py +298 -0
- lexicon/resources/playlist_tracks_types.py +5 -0
- lexicon/resources/playlists.py +511 -0
- lexicon/resources/playlists_types.py +109 -0
- lexicon/resources/tag_categories.py +246 -0
- lexicon/resources/tag_categories_types.py +18 -0
- lexicon/resources/tags.py +240 -0
- lexicon/resources/tags_types.py +20 -0
- lexicon/resources/tracks.py +610 -0
- lexicon/resources/tracks_types.py +642 -0
- lexicon/tools/__init__.py +3 -0
- lexicon/tools/playlists.py +159 -0
- lexicon/utils.py +16 -0
- lexicon_python-0.2.0.dist-info/METADATA +329 -0
- lexicon_python-0.2.0.dist-info/RECORD +23 -0
- {lexicon_python-0.1.2.dist-info → lexicon_python-0.2.0.dist-info}/WHEEL +1 -1
- lexicon/lexicon.py +0 -766
- lexicon_python-0.1.2.dist-info/METADATA +0 -95
- lexicon_python-0.1.2.dist-info/RECORD +0 -7
- {lexicon_python-0.1.2.dist-info → lexicon_python-0.2.0.dist-info}/licenses/LICENSE +0 -0
- {lexicon_python-0.1.2.dist-info → lexicon_python-0.2.0.dist-info}/top_level.txt +0 -0
lexicon/__init__.py
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
"""Public package surface for the lexicon Python client."""
|
|
2
2
|
|
|
3
|
-
from .
|
|
3
|
+
from .client import DEFAULT_HOST, LEXICON_PORT, Lexicon
|
|
4
|
+
from .resources.tracks_types import *
|
|
4
5
|
|
|
5
|
-
|
|
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)
|