livetennisapi-haystack 0.1.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.
- livetennisapi_haystack/__init__.py +6 -0
- livetennisapi_haystack/_util.py +56 -0
- livetennisapi_haystack/match_fetcher.py +336 -0
- livetennisapi_haystack/player_search.py +183 -0
- livetennisapi_haystack/py.typed +0 -0
- livetennisapi_haystack-0.1.0.dist-info/METADATA +162 -0
- livetennisapi_haystack-0.1.0.dist-info/RECORD +9 -0
- livetennisapi_haystack-0.1.0.dist-info/WHEEL +4 -0
- livetennisapi_haystack-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Helpers shared by the Live Tennis API components.
|
|
2
|
+
|
|
3
|
+
Kept private: only the components are public API.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from haystack import Document
|
|
9
|
+
from livetennisapi.errors import UpgradeRequired
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def make_upgrade_document(exc: UpgradeRequired) -> Document:
|
|
13
|
+
"""Turn a 403 tier wall into a readable Document instead of a crash.
|
|
14
|
+
|
|
15
|
+
The Live Tennis API returns 403 when the key is valid but the plan does not
|
|
16
|
+
unlock the endpoint. In a pipeline that should surface as information the
|
|
17
|
+
downstream LLM (or user) can act on, not as a traceback. The Document is
|
|
18
|
+
tagged with ``meta["error"] = "upgrade_required"`` so RAG pipelines that
|
|
19
|
+
only want match data can filter it out.
|
|
20
|
+
"""
|
|
21
|
+
return Document(
|
|
22
|
+
content=f"Live Tennis API access notice: {exc}",
|
|
23
|
+
meta={
|
|
24
|
+
"source": "livetennisapi",
|
|
25
|
+
"error": "upgrade_required",
|
|
26
|
+
"status_code": exc.status_code,
|
|
27
|
+
"required_tier": exc.required_tier,
|
|
28
|
+
},
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def player_label(player: Any) -> str:
|
|
33
|
+
"""One human-readable label for a player or doubles team.
|
|
34
|
+
|
|
35
|
+
Tolerates sparse records: doubles teams have no ranking and lower-tour
|
|
36
|
+
players may miss country. Only what exists is shown.
|
|
37
|
+
"""
|
|
38
|
+
if player is None:
|
|
39
|
+
return "unknown"
|
|
40
|
+
name = getattr(player, "name", None) or "unknown"
|
|
41
|
+
bits = []
|
|
42
|
+
country = getattr(player, "country", None)
|
|
43
|
+
if country:
|
|
44
|
+
bits.append(str(country))
|
|
45
|
+
ranking = getattr(player, "ranking", None)
|
|
46
|
+
if ranking is not None:
|
|
47
|
+
bits.append(f"#{ranking}")
|
|
48
|
+
return f"{name} ({', '.join(bits)})" if bits else name
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def iso_or_none(value: Any) -> Any:
|
|
52
|
+
"""Datetime/date -> ISO string for JSON-safe Document meta; else pass through."""
|
|
53
|
+
if value is None:
|
|
54
|
+
return None
|
|
55
|
+
iso = getattr(value, "isoformat", None)
|
|
56
|
+
return iso() if callable(iso) else value
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from haystack import Document, component, default_from_dict, default_to_dict, logging
|
|
4
|
+
from haystack.utils import Secret, deserialize_secrets_inplace
|
|
5
|
+
from livetennisapi import LiveTennisAPI
|
|
6
|
+
from livetennisapi.errors import NotFound, UpgradeRequired
|
|
7
|
+
from livetennisapi.models import Match
|
|
8
|
+
|
|
9
|
+
from ._util import iso_or_none, make_upgrade_document, player_label
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
_STATUSES = ("live", "upcoming", "completed")
|
|
14
|
+
_TOURS = ("atp", "wta", "challenger", "itf", "juniors")
|
|
15
|
+
_MAX_LIMIT = 200
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@component
|
|
19
|
+
class LiveTennisMatchFetcher:
|
|
20
|
+
"""
|
|
21
|
+
Fetches tennis matches from the [Live Tennis API](https://livetennisapi.com) as Haystack Documents.
|
|
22
|
+
|
|
23
|
+
Each Document's ``content`` is a clean human-readable match summary (players, tournament,
|
|
24
|
+
live score, server, winner) and its ``meta`` carries the structured fields (ids, players,
|
|
25
|
+
sets/games/points), so the component is directly usable in RAG and agent pipelines.
|
|
26
|
+
|
|
27
|
+
Requires a Live Tennis API key, read from the ``LIVETENNISAPI_KEY`` environment variable
|
|
28
|
+
by default.
|
|
29
|
+
|
|
30
|
+
### Usage example
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from livetennisapi_haystack import LiveTennisMatchFetcher
|
|
34
|
+
|
|
35
|
+
fetcher = LiveTennisMatchFetcher() # key from LIVETENNISAPI_KEY
|
|
36
|
+
result = fetcher.run(status="live")
|
|
37
|
+
for doc in result["documents"]:
|
|
38
|
+
print(doc.content)
|
|
39
|
+
```
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
api_key: Secret = Secret.from_env_var("LIVETENNISAPI_KEY"),
|
|
45
|
+
status: str = "live",
|
|
46
|
+
tour: str | None = None,
|
|
47
|
+
limit: int = 10,
|
|
48
|
+
base_url: str | None = None,
|
|
49
|
+
timeout: float = 30.0,
|
|
50
|
+
) -> None:
|
|
51
|
+
"""
|
|
52
|
+
Initialize the LiveTennisMatchFetcher component.
|
|
53
|
+
|
|
54
|
+
:param api_key:
|
|
55
|
+
Live Tennis API key. Defaults to the ``LIVETENNISAPI_KEY`` environment variable.
|
|
56
|
+
:param status:
|
|
57
|
+
Default match lifecycle status to fetch: ``"live"``, ``"upcoming"`` or ``"completed"``.
|
|
58
|
+
:param tour:
|
|
59
|
+
Optional default tour filter: ``"atp"``, ``"wta"``, ``"challenger"``, ``"itf"`` or
|
|
60
|
+
``"juniors"``. Each value covers its singles and doubles draws. ``None`` fetches all tours.
|
|
61
|
+
:param limit:
|
|
62
|
+
Default maximum number of matches to return (1-200).
|
|
63
|
+
:param base_url:
|
|
64
|
+
Optional API base URL override, mainly for testing.
|
|
65
|
+
:param timeout:
|
|
66
|
+
HTTP timeout in seconds.
|
|
67
|
+
"""
|
|
68
|
+
_validate_status(status)
|
|
69
|
+
_validate_tour(tour)
|
|
70
|
+
self.api_key = api_key
|
|
71
|
+
self.status = status
|
|
72
|
+
self.tour = tour
|
|
73
|
+
self.limit = limit
|
|
74
|
+
self.base_url = base_url
|
|
75
|
+
self.timeout = timeout
|
|
76
|
+
self._client: LiveTennisAPI | None = None
|
|
77
|
+
|
|
78
|
+
def warm_up(self) -> None:
|
|
79
|
+
"""
|
|
80
|
+
Initialize the Live Tennis API client.
|
|
81
|
+
|
|
82
|
+
Called automatically on first use. Can be called explicitly to avoid cold-start latency.
|
|
83
|
+
"""
|
|
84
|
+
if self._client is None:
|
|
85
|
+
self._client = LiveTennisAPI(
|
|
86
|
+
api_key=self.api_key.resolve_value(),
|
|
87
|
+
base_url=self.base_url,
|
|
88
|
+
timeout=self.timeout,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
def to_dict(self) -> dict[str, Any]:
|
|
92
|
+
"""
|
|
93
|
+
Serializes the component to a dictionary.
|
|
94
|
+
|
|
95
|
+
:returns: Dictionary with serialized data. The API key is stored as a Secret reference,
|
|
96
|
+
never as a plain string.
|
|
97
|
+
"""
|
|
98
|
+
return default_to_dict(
|
|
99
|
+
self,
|
|
100
|
+
api_key=self.api_key.to_dict(),
|
|
101
|
+
status=self.status,
|
|
102
|
+
tour=self.tour,
|
|
103
|
+
limit=self.limit,
|
|
104
|
+
base_url=self.base_url,
|
|
105
|
+
timeout=self.timeout,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
@classmethod
|
|
109
|
+
def from_dict(cls, data: dict[str, Any]) -> "LiveTennisMatchFetcher":
|
|
110
|
+
"""
|
|
111
|
+
Deserializes the component from a dictionary.
|
|
112
|
+
|
|
113
|
+
:param data: Dictionary to deserialize from.
|
|
114
|
+
:returns: Deserialized component.
|
|
115
|
+
"""
|
|
116
|
+
deserialize_secrets_inplace(data["init_parameters"], keys=["api_key"])
|
|
117
|
+
return default_from_dict(cls, data)
|
|
118
|
+
|
|
119
|
+
@component.output_types(documents=list[Document])
|
|
120
|
+
def run(
|
|
121
|
+
self,
|
|
122
|
+
status: str | None = None,
|
|
123
|
+
tour: str | None = None,
|
|
124
|
+
limit: int | None = None,
|
|
125
|
+
match_id: int | None = None,
|
|
126
|
+
) -> dict[str, Any]:
|
|
127
|
+
"""
|
|
128
|
+
Fetch matches and return them as Documents.
|
|
129
|
+
|
|
130
|
+
:param status:
|
|
131
|
+
Optional per-run override of the lifecycle status (``"live"``, ``"upcoming"``,
|
|
132
|
+
``"completed"``). Ignored when ``match_id`` is given.
|
|
133
|
+
:param tour:
|
|
134
|
+
Optional per-run override of the tour filter. Ignored when ``match_id`` is given.
|
|
135
|
+
:param limit:
|
|
136
|
+
Optional per-run override of the maximum number of matches (1-200).
|
|
137
|
+
:param match_id:
|
|
138
|
+
Fetch one specific match by id instead of a listing. An unknown id yields an
|
|
139
|
+
empty list, not an error.
|
|
140
|
+
:returns: A dictionary with:
|
|
141
|
+
- ``documents``: one Document per match, ``content`` holding the readable summary
|
|
142
|
+
and ``meta`` the structured fields. If the API answers 403 (tier wall), a single
|
|
143
|
+
Document tagged ``meta["error"] = "upgrade_required"`` carries the readable
|
|
144
|
+
message instead.
|
|
145
|
+
"""
|
|
146
|
+
if self._client is None:
|
|
147
|
+
self.warm_up()
|
|
148
|
+
if self._client is None:
|
|
149
|
+
msg = "LiveTennisMatchFetcher client failed to initialize."
|
|
150
|
+
raise RuntimeError(msg)
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
if match_id is not None:
|
|
154
|
+
match = self._client.get_match(match_id)
|
|
155
|
+
matches = [match] if match is not None else []
|
|
156
|
+
else:
|
|
157
|
+
matches = self._list_matches(status, tour, limit)
|
|
158
|
+
except NotFound:
|
|
159
|
+
logger.warning("Live Tennis API: match {match_id} not found", match_id=match_id)
|
|
160
|
+
return {"documents": []}
|
|
161
|
+
except UpgradeRequired as exc:
|
|
162
|
+
logger.warning("Live Tennis API tier wall: {exc}", exc=exc)
|
|
163
|
+
return {"documents": [make_upgrade_document(exc)]}
|
|
164
|
+
|
|
165
|
+
return {"documents": [match_to_document(m) for m in matches if m is not None]}
|
|
166
|
+
|
|
167
|
+
def _list_matches(self, status: str | None, tour: str | None, limit: int | None) -> list[Match]:
|
|
168
|
+
effective_status = status if status is not None else self.status
|
|
169
|
+
effective_tour = tour if tour is not None else self.tour
|
|
170
|
+
effective_limit = max(1, min(int(limit if limit is not None else self.limit), _MAX_LIMIT))
|
|
171
|
+
_validate_status(effective_status)
|
|
172
|
+
_validate_tour(effective_tour)
|
|
173
|
+
assert self._client is not None # noqa: S101 - checked by run()
|
|
174
|
+
|
|
175
|
+
if effective_tour is None:
|
|
176
|
+
return list(self._client.list_matches(status=effective_status, limit=effective_limit))
|
|
177
|
+
|
|
178
|
+
# The public /matches endpoint documents a `tour` query parameter
|
|
179
|
+
# (openapi.yaml, parameters.tour), but livetennisapi 1.0.2 does not yet
|
|
180
|
+
# expose it on list_matches(). Deliberate deviation: go through the
|
|
181
|
+
# official client's transport layer (retries, error mapping, auth)
|
|
182
|
+
# rather than hand-rolling HTTP. Switch to
|
|
183
|
+
# `client.list_matches(status=..., tour=...)` once the client grows it.
|
|
184
|
+
raw = self._client._request(
|
|
185
|
+
"/matches",
|
|
186
|
+
self._client._params(
|
|
187
|
+
{"status": effective_status, "tour": effective_tour, "limit": effective_limit, "offset": 0}
|
|
188
|
+
),
|
|
189
|
+
)
|
|
190
|
+
items = (raw.get("data") or []) if isinstance(raw, dict) else (raw or [])
|
|
191
|
+
return [m for m in (Match.from_dict(i) for i in items) if m is not None]
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _validate_status(status: str) -> None:
|
|
195
|
+
if status not in _STATUSES:
|
|
196
|
+
msg = f"status must be one of {_STATUSES}, got {status!r}"
|
|
197
|
+
raise ValueError(msg)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _validate_tour(tour: str | None) -> None:
|
|
201
|
+
if tour is not None and tour not in _TOURS:
|
|
202
|
+
msg = f"tour must be one of {_TOURS} or None, got {tour!r}"
|
|
203
|
+
raise ValueError(msg)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def match_to_document(match: Match) -> Document:
|
|
207
|
+
"""
|
|
208
|
+
Convert one ``livetennisapi`` Match into a Haystack Document.
|
|
209
|
+
|
|
210
|
+
``content`` is a human-readable summary; ``meta`` carries JSON-safe structured fields.
|
|
211
|
+
Tolerates every documented sparsity: null ``score.server``, doubles teams without
|
|
212
|
+
rankings, string points, absent scores on upcoming matches.
|
|
213
|
+
"""
|
|
214
|
+
p1, p2 = match.p1, match.p2
|
|
215
|
+
p1_label, p2_label = player_label(p1), player_label(p2)
|
|
216
|
+
|
|
217
|
+
where = [str(match.tournament)] if match.tournament else []
|
|
218
|
+
if match.surface:
|
|
219
|
+
where.append(f"{match.surface} court" + (" (indoor)" if match.indoor else ""))
|
|
220
|
+
if match.round:
|
|
221
|
+
where.append(f"round {match.round}")
|
|
222
|
+
if match.format:
|
|
223
|
+
where.append({"BO5": "best of 5", "BO3": "best of 3"}.get(match.format, str(match.format)))
|
|
224
|
+
kind = "doubles " if match.is_doubles else ""
|
|
225
|
+
|
|
226
|
+
head = f"{p1_label} vs {p2_label} — {kind}match"
|
|
227
|
+
if where:
|
|
228
|
+
head += f" at {where[0]}" if len(where) == 1 else f" at {', '.join(where)}"
|
|
229
|
+
|
|
230
|
+
lines = [head + "."]
|
|
231
|
+
lines.extend(_status_lines(match, p1_label, p2_label))
|
|
232
|
+
content = " ".join(lines)
|
|
233
|
+
|
|
234
|
+
meta = {
|
|
235
|
+
"source": "livetennisapi",
|
|
236
|
+
"match_id": match.id,
|
|
237
|
+
"status": match.status,
|
|
238
|
+
"event_status": match.event_status,
|
|
239
|
+
"tournament": match.tournament,
|
|
240
|
+
"surface": match.surface,
|
|
241
|
+
"indoor": match.indoor,
|
|
242
|
+
"format": match.format,
|
|
243
|
+
"round": match.round,
|
|
244
|
+
"is_doubles": match.is_doubles,
|
|
245
|
+
"scheduled_time": iso_or_none(match.scheduled_time),
|
|
246
|
+
"p1_id": getattr(p1, "id", None),
|
|
247
|
+
"p1_name": getattr(p1, "name", None),
|
|
248
|
+
"p1_country": getattr(p1, "country", None),
|
|
249
|
+
"p1_ranking": getattr(p1, "ranking", None),
|
|
250
|
+
"p2_id": getattr(p2, "id", None),
|
|
251
|
+
"p2_name": getattr(p2, "name", None),
|
|
252
|
+
"p2_country": getattr(p2, "country", None),
|
|
253
|
+
"p2_ranking": getattr(p2, "ranking", None),
|
|
254
|
+
"winner": match.winner,
|
|
255
|
+
}
|
|
256
|
+
score = match.score
|
|
257
|
+
if score is not None:
|
|
258
|
+
meta.update(
|
|
259
|
+
{
|
|
260
|
+
"sets": score.sets,
|
|
261
|
+
"games": score.games,
|
|
262
|
+
"points": score.points,
|
|
263
|
+
"server": score.server,
|
|
264
|
+
"is_tiebreak": score.is_tiebreak,
|
|
265
|
+
"score_timestamp": iso_or_none(score.timestamp),
|
|
266
|
+
}
|
|
267
|
+
)
|
|
268
|
+
return Document(content=content, meta=meta)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _status_lines(match: Match, p1_label: str, p2_label: str) -> list[str]:
|
|
272
|
+
"""Readable status/score sentences for one match."""
|
|
273
|
+
lines: list[str] = []
|
|
274
|
+
score = match.score
|
|
275
|
+
names = {1: p1_label, 2: p2_label}
|
|
276
|
+
|
|
277
|
+
if match.status == "upcoming":
|
|
278
|
+
when = iso_or_none(match.scheduled_time)
|
|
279
|
+
lines.append(f"Scheduled for {when}." if when else "Upcoming; not yet scheduled.")
|
|
280
|
+
return lines
|
|
281
|
+
|
|
282
|
+
if match.status == "completed":
|
|
283
|
+
winner = names.get(match.winner) if match.winner in (1, 2) else None
|
|
284
|
+
lines.append(f"Completed; winner: {winner}." if winner else "Completed.")
|
|
285
|
+
elif match.status == "live":
|
|
286
|
+
lines.append("Live now.")
|
|
287
|
+
elif match.status:
|
|
288
|
+
lines.append(f"Status: {match.status}.")
|
|
289
|
+
|
|
290
|
+
if score is None:
|
|
291
|
+
return lines
|
|
292
|
+
|
|
293
|
+
sentence = _score_sentence(score)
|
|
294
|
+
if sentence:
|
|
295
|
+
lines.append(sentence)
|
|
296
|
+
# score.server is nullable by contract: between points/matches the feed may
|
|
297
|
+
# not know who serves next. Only say so when it does.
|
|
298
|
+
server = names.get(score.server) if score.server in (1, 2) else None
|
|
299
|
+
if server and match.status == "live":
|
|
300
|
+
lines.append(f"{server} is serving.")
|
|
301
|
+
return lines
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _score_sentence(score: Any) -> str | None:
|
|
305
|
+
"""One ``"Score: sets 1-1, games 6-4, 3-6, points 30-15."`` sentence, or None."""
|
|
306
|
+
parts: list[str] = []
|
|
307
|
+
if score.sets and len(score.sets) >= 2:
|
|
308
|
+
parts.append(f"sets {score.sets[0]}-{score.sets[1]}")
|
|
309
|
+
games = _games_summary(score.games)
|
|
310
|
+
if games:
|
|
311
|
+
parts.append(f"games {games}")
|
|
312
|
+
# Points are strings by contract ("0", "15", "30", "40", "AD") — but live
|
|
313
|
+
# data shows completed matches can carry [None, None], so only render
|
|
314
|
+
# points when both sides actually exist.
|
|
315
|
+
if score.points and len(score.points) >= 2 and score.points[0] is not None and score.points[1] is not None:
|
|
316
|
+
parts.append(f"points {score.points[0]}-{score.points[1]}")
|
|
317
|
+
if score.is_tiebreak:
|
|
318
|
+
parts.append("in a tiebreak")
|
|
319
|
+
return f"Score: {', '.join(parts)}." if parts else None
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _games_summary(games: Any) -> str | None:
|
|
323
|
+
"""Per-set games as ``"6-4, 3-6, 2-1"``.
|
|
324
|
+
|
|
325
|
+
The wire format is player-major — ``[games_p1, games_p2]``, each a per-set
|
|
326
|
+
list — so a set's score is a zip across the two sides, not one row.
|
|
327
|
+
"""
|
|
328
|
+
if not isinstance(games, list) or len(games) < 2:
|
|
329
|
+
return None
|
|
330
|
+
p1, p2 = games[0], games[1]
|
|
331
|
+
if not isinstance(p1, list) or not isinstance(p2, list):
|
|
332
|
+
return None
|
|
333
|
+
# strict=False on purpose: mid-update the two sides can briefly disagree in
|
|
334
|
+
# length; showing the sets both agree on beats crashing.
|
|
335
|
+
pairs = [f"{a}-{b}" for a, b in zip(p1, p2, strict=False)]
|
|
336
|
+
return ", ".join(pairs) if pairs else None
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from haystack import Document, component, default_from_dict, default_to_dict, logging
|
|
4
|
+
from haystack.utils import Secret, deserialize_secrets_inplace
|
|
5
|
+
from livetennisapi import LiveTennisAPI
|
|
6
|
+
from livetennisapi.errors import UpgradeRequired
|
|
7
|
+
from livetennisapi.models import Player
|
|
8
|
+
|
|
9
|
+
from ._util import iso_or_none, make_upgrade_document
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
_MAX_LIMIT = 200
|
|
14
|
+
|
|
15
|
+
_HANDS = {"R": "right-handed", "L": "left-handed"}
|
|
16
|
+
_BACKHANDS = {1: "one-handed backhand", 2: "two-handed backhand"}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@component
|
|
20
|
+
class LiveTennisPlayerSearch:
|
|
21
|
+
"""
|
|
22
|
+
Searches players on the [Live Tennis API](https://livetennisapi.com) and returns them as Haystack Documents.
|
|
23
|
+
|
|
24
|
+
Each Document's ``content`` is a readable player summary (name, country, ranking, plays)
|
|
25
|
+
and its ``meta`` carries the structured fields, ready for RAG and agent pipelines.
|
|
26
|
+
Ranked players come first in the API's ordering.
|
|
27
|
+
|
|
28
|
+
Requires a Live Tennis API key, read from the ``LIVETENNISAPI_KEY`` environment variable
|
|
29
|
+
by default.
|
|
30
|
+
|
|
31
|
+
### Usage example
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from livetennisapi_haystack import LiveTennisPlayerSearch
|
|
35
|
+
|
|
36
|
+
search = LiveTennisPlayerSearch() # key from LIVETENNISAPI_KEY
|
|
37
|
+
result = search.run(query="alcaraz")
|
|
38
|
+
for doc in result["documents"]:
|
|
39
|
+
print(doc.content)
|
|
40
|
+
```
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
api_key: Secret = Secret.from_env_var("LIVETENNISAPI_KEY"),
|
|
46
|
+
limit: int = 10,
|
|
47
|
+
base_url: str | None = None,
|
|
48
|
+
timeout: float = 30.0,
|
|
49
|
+
) -> None:
|
|
50
|
+
"""
|
|
51
|
+
Initialize the LiveTennisPlayerSearch component.
|
|
52
|
+
|
|
53
|
+
:param api_key:
|
|
54
|
+
Live Tennis API key. Defaults to the ``LIVETENNISAPI_KEY`` environment variable.
|
|
55
|
+
:param limit:
|
|
56
|
+
Default maximum number of players to return (1-200).
|
|
57
|
+
:param base_url:
|
|
58
|
+
Optional API base URL override, mainly for testing.
|
|
59
|
+
:param timeout:
|
|
60
|
+
HTTP timeout in seconds.
|
|
61
|
+
"""
|
|
62
|
+
self.api_key = api_key
|
|
63
|
+
self.limit = limit
|
|
64
|
+
self.base_url = base_url
|
|
65
|
+
self.timeout = timeout
|
|
66
|
+
self._client: LiveTennisAPI | None = None
|
|
67
|
+
|
|
68
|
+
def warm_up(self) -> None:
|
|
69
|
+
"""
|
|
70
|
+
Initialize the Live Tennis API client.
|
|
71
|
+
|
|
72
|
+
Called automatically on first use. Can be called explicitly to avoid cold-start latency.
|
|
73
|
+
"""
|
|
74
|
+
if self._client is None:
|
|
75
|
+
self._client = LiveTennisAPI(
|
|
76
|
+
api_key=self.api_key.resolve_value(),
|
|
77
|
+
base_url=self.base_url,
|
|
78
|
+
timeout=self.timeout,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
def to_dict(self) -> dict[str, Any]:
|
|
82
|
+
"""
|
|
83
|
+
Serializes the component to a dictionary.
|
|
84
|
+
|
|
85
|
+
:returns: Dictionary with serialized data. The API key is stored as a Secret reference,
|
|
86
|
+
never as a plain string.
|
|
87
|
+
"""
|
|
88
|
+
return default_to_dict(
|
|
89
|
+
self,
|
|
90
|
+
api_key=self.api_key.to_dict(),
|
|
91
|
+
limit=self.limit,
|
|
92
|
+
base_url=self.base_url,
|
|
93
|
+
timeout=self.timeout,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
@classmethod
|
|
97
|
+
def from_dict(cls, data: dict[str, Any]) -> "LiveTennisPlayerSearch":
|
|
98
|
+
"""
|
|
99
|
+
Deserializes the component from a dictionary.
|
|
100
|
+
|
|
101
|
+
:param data: Dictionary to deserialize from.
|
|
102
|
+
:returns: Deserialized component.
|
|
103
|
+
"""
|
|
104
|
+
deserialize_secrets_inplace(data["init_parameters"], keys=["api_key"])
|
|
105
|
+
return default_from_dict(cls, data)
|
|
106
|
+
|
|
107
|
+
@component.output_types(documents=list[Document])
|
|
108
|
+
def run(self, query: str, limit: int | None = None) -> dict[str, Any]:
|
|
109
|
+
"""
|
|
110
|
+
Search players by name and return them as Documents.
|
|
111
|
+
|
|
112
|
+
:param query: Player name (or part of it) to search for.
|
|
113
|
+
:param limit: Optional per-run override of the maximum number of players (1-200).
|
|
114
|
+
:returns: A dictionary with:
|
|
115
|
+
- ``documents``: one Document per player. If the API answers 403 (tier wall), a
|
|
116
|
+
single Document tagged ``meta["error"] = "upgrade_required"`` carries the
|
|
117
|
+
readable message instead.
|
|
118
|
+
"""
|
|
119
|
+
if self._client is None:
|
|
120
|
+
self.warm_up()
|
|
121
|
+
if self._client is None:
|
|
122
|
+
msg = "LiveTennisPlayerSearch client failed to initialize."
|
|
123
|
+
raise RuntimeError(msg)
|
|
124
|
+
|
|
125
|
+
effective_limit = max(1, min(int(limit if limit is not None else self.limit), _MAX_LIMIT))
|
|
126
|
+
try:
|
|
127
|
+
players = list(self._client.search_players(query, limit=effective_limit))
|
|
128
|
+
except UpgradeRequired as exc:
|
|
129
|
+
logger.warning("Live Tennis API tier wall: {exc}", exc=exc)
|
|
130
|
+
return {"documents": [make_upgrade_document(exc)]}
|
|
131
|
+
|
|
132
|
+
return {"documents": [player_to_document(p) for p in players if p is not None]}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def player_to_document(player: Player) -> Document:
|
|
136
|
+
"""
|
|
137
|
+
Convert one ``livetennisapi`` Player into a Haystack Document.
|
|
138
|
+
|
|
139
|
+
Tolerates sparse records: doubles teams and lower-tour players may miss country,
|
|
140
|
+
ranking, hand, backhand or birthday — only what exists is written.
|
|
141
|
+
"""
|
|
142
|
+
name = player.name or "unknown"
|
|
143
|
+
bits: list[str] = []
|
|
144
|
+
if player.is_doubles_team:
|
|
145
|
+
bits.append("doubles team")
|
|
146
|
+
if player.country:
|
|
147
|
+
bits.append(str(player.country))
|
|
148
|
+
if player.tour:
|
|
149
|
+
# The record's own tour label is opaque by contract (e.g. "atp", "ATP",
|
|
150
|
+
# "juniors_boys") — shown as-is, never parsed.
|
|
151
|
+
bits.append(f"tour {player.tour}")
|
|
152
|
+
if player.ranking is not None:
|
|
153
|
+
rank = f"ranked #{player.ranking}"
|
|
154
|
+
if player.ranking_points is not None:
|
|
155
|
+
rank += f" ({player.ranking_points} pts)"
|
|
156
|
+
if player.ranking_movement in ("up", "down"):
|
|
157
|
+
rank += f", moving {player.ranking_movement}"
|
|
158
|
+
bits.append(rank)
|
|
159
|
+
plays = _HANDS.get(player.hand)
|
|
160
|
+
if plays:
|
|
161
|
+
backhand = _BACKHANDS.get(player.backhand)
|
|
162
|
+
bits.append(f"plays {plays}" + (f" with a {backhand}" if backhand else ""))
|
|
163
|
+
birthday = iso_or_none(player.birthday)
|
|
164
|
+
if birthday:
|
|
165
|
+
bits.append(f"born {birthday}")
|
|
166
|
+
|
|
167
|
+
content = f"{name} — {'; '.join(bits)}." if bits else f"{name}."
|
|
168
|
+
|
|
169
|
+
meta = {
|
|
170
|
+
"source": "livetennisapi",
|
|
171
|
+
"player_id": player.id,
|
|
172
|
+
"name": player.name,
|
|
173
|
+
"tour": player.tour,
|
|
174
|
+
"country": player.country,
|
|
175
|
+
"ranking": player.ranking,
|
|
176
|
+
"ranking_points": player.ranking_points,
|
|
177
|
+
"ranking_movement": player.ranking_movement,
|
|
178
|
+
"hand": player.hand,
|
|
179
|
+
"backhand": player.backhand,
|
|
180
|
+
"birthday": iso_or_none(player.birthday),
|
|
181
|
+
"is_doubles_team": player.is_doubles_team,
|
|
182
|
+
}
|
|
183
|
+
return Document(content=content, meta=meta)
|
|
File without changes
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: livetennisapi-haystack
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Haystack 2.x integration for the Live Tennis API: live scores, matches and players as Documents
|
|
5
|
+
Project-URL: Documentation, https://github.com/livetennisapi/livetennisapi-haystack#readme
|
|
6
|
+
Project-URL: Issues, https://github.com/livetennisapi/livetennisapi-haystack/issues
|
|
7
|
+
Project-URL: Source, https://github.com/livetennisapi/livetennisapi-haystack
|
|
8
|
+
Author-email: Ben <ben@ben-is-a.dev>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: Agents,Haystack,Live Scores,RAG,Sports Data,Tennis
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Requires-Dist: haystack-ai>=2.24.1
|
|
22
|
+
Requires-Dist: livetennisapi>=1.0.2
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# livetennisapi-haystack
|
|
26
|
+
|
|
27
|
+
[Haystack](https://haystack.deepset.ai) 2.x integration for the
|
|
28
|
+
[Live Tennis API](https://livetennisapi.com): live scores, matches and players as Haystack
|
|
29
|
+
`Document`s for RAG and agent pipelines.
|
|
30
|
+
|
|
31
|
+
- **`LiveTennisMatchFetcher`** — live / upcoming / completed matches (optionally one match by
|
|
32
|
+
id, optionally filtered by tour) as `Document`s. `content` is a clean human-readable match
|
|
33
|
+
summary; `meta` carries the structured fields (ids, players, sets/games/points, server,
|
|
34
|
+
winner).
|
|
35
|
+
- **`LiveTennisPlayerSearch`** — player search by name, ranked players first, same
|
|
36
|
+
`Document` shape.
|
|
37
|
+
|
|
38
|
+
Built on the official [`livetennisapi`](https://pypi.org/project/livetennisapi/) Python
|
|
39
|
+
client (retries, error mapping, typed models) — no hand-rolled HTTP.
|
|
40
|
+
|
|
41
|
+
## Installation
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install livetennisapi-haystack
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
You need a Live Tennis API key (free tier: 1000 requests/day, 30/min). Export it as an
|
|
48
|
+
environment variable — the components read `LIVETENNISAPI_KEY` by default and never accept a
|
|
49
|
+
plain-string key:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
export LIVETENNISAPI_KEY="your-key"
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Usage
|
|
56
|
+
|
|
57
|
+
### Standalone
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from livetennisapi_haystack import LiveTennisMatchFetcher
|
|
61
|
+
|
|
62
|
+
fetcher = LiveTennisMatchFetcher() # key from LIVETENNISAPI_KEY
|
|
63
|
+
result = fetcher.run(status="live", limit=5)
|
|
64
|
+
for doc in result["documents"]:
|
|
65
|
+
print(doc.content)
|
|
66
|
+
# e.g. "Carlos Alcaraz (ESP, #2) vs Jannik Sinner (ITA, #1) — match at Wimbledon,
|
|
67
|
+
# grass court, round QF, best of 5. Live now. Score: sets 1-1, games 6-4, 3-6,
|
|
68
|
+
# 2-1, points 30-15. Carlos Alcaraz (ESP, #2) is serving."
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### In a pipeline (runnable with only `LIVETENNISAPI_KEY`)
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
from haystack import Pipeline
|
|
75
|
+
|
|
76
|
+
from livetennisapi_haystack import LiveTennisMatchFetcher, LiveTennisPlayerSearch
|
|
77
|
+
|
|
78
|
+
pipe = Pipeline()
|
|
79
|
+
pipe.add_component("matches", LiveTennisMatchFetcher(limit=5))
|
|
80
|
+
pipe.add_component("players", LiveTennisPlayerSearch(limit=3))
|
|
81
|
+
|
|
82
|
+
result = pipe.run({"matches": {"status": "live"}, "players": {"query": "alcaraz"}})
|
|
83
|
+
for doc in result["matches"]["documents"] + result["players"]["documents"]:
|
|
84
|
+
print("-", doc.content)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### RAG over live scores
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
from haystack import Pipeline
|
|
91
|
+
from haystack.components.builders.chat_prompt_builder import ChatPromptBuilder
|
|
92
|
+
from haystack.components.generators.chat import OpenAIChatGenerator
|
|
93
|
+
from haystack.dataclasses import ChatMessage
|
|
94
|
+
|
|
95
|
+
from livetennisapi_haystack import LiveTennisMatchFetcher
|
|
96
|
+
|
|
97
|
+
prompt_template = [
|
|
98
|
+
ChatMessage.from_system("You are a tennis commentator."),
|
|
99
|
+
ChatMessage.from_user(
|
|
100
|
+
"Current matches:\n"
|
|
101
|
+
"{% for document in documents %}{{ document.content }}\n{% endfor %}\n"
|
|
102
|
+
"Answer the following question: {{ query }}\nAnswer:"
|
|
103
|
+
),
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
pipe = Pipeline()
|
|
107
|
+
pipe.add_component("matches", LiveTennisMatchFetcher(limit=10))
|
|
108
|
+
pipe.add_component("prompt_builder", ChatPromptBuilder(template=prompt_template, required_variables={"query", "documents"}))
|
|
109
|
+
pipe.add_component("llm", OpenAIChatGenerator(model="gpt-4o-mini"))
|
|
110
|
+
pipe.connect("matches.documents", "prompt_builder.documents")
|
|
111
|
+
pipe.connect("prompt_builder.prompt", "llm.messages")
|
|
112
|
+
|
|
113
|
+
query = "Who is closest to winning right now?"
|
|
114
|
+
result = pipe.run({"matches": {"status": "live"}, "prompt_builder": {"query": query}})
|
|
115
|
+
print(result["llm"]["replies"][0].text)
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
A complete runnable script lives at [`examples/live_demo.py`](examples/live_demo.py).
|
|
119
|
+
|
|
120
|
+
## Behavior worth knowing
|
|
121
|
+
|
|
122
|
+
- **403 tier wall**: when your key is valid but the plan does not unlock an endpoint, the
|
|
123
|
+
component returns a single readable `Document` (tagged `meta["error"] = "upgrade_required"`)
|
|
124
|
+
instead of raising — an agent can tell the user; a RAG pipeline can filter it out. All
|
|
125
|
+
other errors (bad key, network down, rate limit) still raise the official client's typed
|
|
126
|
+
exceptions.
|
|
127
|
+
- **Sparse data is normal**: `score.server` is nullable (between points the feed may not
|
|
128
|
+
know who serves next — the summary simply omits the serving sentence), doubles teams have
|
|
129
|
+
no individual rankings and a null `data_completeness`, and points are strings
|
|
130
|
+
(`"0"`, `"15"`, `"30"`, `"40"`, `"AD"`). The components tolerate all of it.
|
|
131
|
+
- **Serialization**: both components implement `to_dict`/`from_dict`; the API key is stored
|
|
132
|
+
as a `Secret` environment-variable reference, never as a value, so pipelines serialize
|
|
133
|
+
safely to YAML. Note that Haystack 3.0 refuses to deserialize third-party components
|
|
134
|
+
unless their module is allow-listed, so reload pipelines with
|
|
135
|
+
`Pipeline.loads(yaml_str, allowed_modules=["livetennisapi_haystack.match_fetcher", "livetennisapi_haystack.player_search"])`
|
|
136
|
+
(or `haystack.core.serialization.allow_deserialization_module(...)`).
|
|
137
|
+
- **`tour` filter**: the API's documented `tour` query parameter is not yet exposed by
|
|
138
|
+
`livetennisapi` 1.0.2's `list_matches()`, so the component routes that one call through
|
|
139
|
+
the official client's transport layer (same auth/retries/error mapping).
|
|
140
|
+
- **Sync only** for now: `run()` — no `run_async` yet, although the official client has an
|
|
141
|
+
async twin. Planned.
|
|
142
|
+
|
|
143
|
+
## Parameters
|
|
144
|
+
|
|
145
|
+
`LiveTennisMatchFetcher(api_key, status="live", tour=None, limit=10, base_url=None, timeout=30.0)`
|
|
146
|
+
— `status`/`tour`/`limit` can be overridden per `run()`, and `run(match_id=...)` fetches a
|
|
147
|
+
single match. `LiveTennisPlayerSearch(api_key, limit=10, base_url=None, timeout=30.0)` —
|
|
148
|
+
`run(query, limit=None)`.
|
|
149
|
+
|
|
150
|
+
## Development
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
pip install -e . pytest ruff
|
|
154
|
+
pytest # unit tests, fully mocked, no network
|
|
155
|
+
ruff check src tests examples
|
|
156
|
+
LIVETENNISAPI_KEY=... pytest -m integration # live tests, needs a key
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## License
|
|
160
|
+
|
|
161
|
+
`livetennisapi-haystack` is distributed under the terms of the
|
|
162
|
+
[MIT](https://spdx.org/licenses/MIT.html) license.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
livetennisapi_haystack/__init__.py,sha256=oJxbEF_ELKOTSE-kQmLywonD53kZ7zzWsHaEJi4ohrg,187
|
|
2
|
+
livetennisapi_haystack/_util.py,sha256=iUvwNLno6ih0egO7HF2rTlZ9ECq8lNlPj5c6UqvZbOI,1875
|
|
3
|
+
livetennisapi_haystack/match_fetcher.py,sha256=BKygXaRTth_hZX3SMADZ_AA0xFf-6S9sWGmVxGV9me0,13137
|
|
4
|
+
livetennisapi_haystack/player_search.py,sha256=eP4XFO1dX8VO74bZNnV0iYtV4Z0rpcLhPFxzRpRf-Lk,6604
|
|
5
|
+
livetennisapi_haystack/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
livetennisapi_haystack-0.1.0.dist-info/METADATA,sha256=4QPq-d5QG2yhDvkxFNile3XBvlQnrVUpveEUA8gAztQ,6821
|
|
7
|
+
livetennisapi_haystack-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
8
|
+
livetennisapi_haystack-0.1.0.dist-info/licenses/LICENSE,sha256=Cj5QnNVv51nLuivjAbWOYUHLKtgDh0bqxZMo8NIL6BU,1060
|
|
9
|
+
livetennisapi_haystack-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ben
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|