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.
@@ -0,0 +1,6 @@
1
+ from .match_fetcher import LiveTennisMatchFetcher
2
+ from .player_search import LiveTennisPlayerSearch
3
+
4
+ __version__ = "0.1.0"
5
+
6
+ __all__ = ["LiveTennisMatchFetcher", "LiveTennisPlayerSearch"]
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.