Skip to content

Commit d917ecf

Browse files
bensynapseclaude
andcommitted
feat(bundles): add Live Tennis API bundle with four free-tier components
Adds a livetennisapi provider to the lfx-bundles metapackage wrapping the Live Tennis API REST surface (https://docs.livetennisapi.com), scoped to the free tier so it can be tested without a paid plan: - Live Matches: live/upcoming matches with current score, tour filter - Fixtures: upcoming scheduled fixtures, earliest first - Player Search: player lookup by name with ranking and bio fields - Match Score: current score snapshot for one match id Auth is a SecretStr API key sent as X-API-Key. No new dependencies (httpx only), so the livetennisapi extra is empty. Includes frontend icon + sidebar wiring per the contributing-bundles guide and unit tests with mocked HTTP (importorskip-guarded, so they run in the bundles-installed CI job). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 7ed4de4 commit d917ecf

19 files changed

Lines changed: 920 additions & 4 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ Documentation = "https://docs.langflow.org"
169169

170170
[project.optional-dependencies]
171171
bundles = [
172-
"lfx-bundles[all-no-torch]>=1.1.12,<2.0",
172+
"lfx-bundles[all-no-torch]>=1.1.13,<2.0",
173173
"lfx-arxiv>=0.1.0,<1.0.0",
174174
"lfx-duckduckgo>=0.1.0,<1.0.0",
175175
"lfx-empiriolabs>=0.1.0,<1.0.0",

src/backend/tests/unit/components/bundles/livetennisapi/__init__.py

Whitespace-only changes.
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
from unittest.mock import MagicMock, patch
2+
3+
import httpx
4+
import pytest
5+
6+
pytest.importorskip("lfx_bundles")
7+
8+
from lfx_bundles.livetennisapi.fixtures import LiveTennisFixturesComponent
9+
10+
from tests.base import ComponentTestBaseWithoutClient
11+
12+
13+
class TestLiveTennisFixturesComponent(ComponentTestBaseWithoutClient):
14+
@pytest.fixture
15+
def component_class(self):
16+
return LiveTennisFixturesComponent
17+
18+
@pytest.fixture
19+
def default_kwargs(self):
20+
return {
21+
"api_key": "test-key",
22+
"tour": "all",
23+
"limit": 50,
24+
}
25+
26+
@pytest.fixture
27+
def file_names_mapping(self):
28+
# New component, no version history yet.
29+
return []
30+
31+
@pytest.fixture(autouse=True)
32+
def mock_httpx_client(self):
33+
"""Keep every test offline, including the inherited test_latest_version."""
34+
with patch("lfx_bundles.livetennisapi.fixtures.httpx.Client") as mock_client:
35+
mock_response = MagicMock()
36+
mock_response.json.return_value = {"data": [], "meta": {"count": 0}}
37+
mock_client.return_value.__enter__.return_value.get.return_value = mock_response
38+
yield mock_client
39+
40+
def test_frontend_node(self, component_class, default_kwargs):
41+
component = component_class(**default_kwargs)
42+
43+
frontend_node = component.to_frontend_node()
44+
45+
node_data = frontend_node["data"]["node"]
46+
assert node_data["display_name"] == "Fixtures"
47+
assert node_data["icon"] == "LiveTennisAPI"
48+
assert node_data["template"]["api_key"]["password"] is True
49+
50+
def test_fetch_fixtures_success(self, component_class, default_kwargs, mock_httpx_client):
51+
component = component_class(**default_kwargs)
52+
mock_get = mock_httpx_client.return_value.__enter__.return_value.get
53+
mock_get.return_value.json.return_value = {
54+
"data": [
55+
{
56+
"id": 456,
57+
"event_date": "2026-08-17",
58+
"start_time": "2026-08-17T11:00:00Z",
59+
"tournament": "US Open",
60+
"round": "1st Round",
61+
"round_code": "R128",
62+
"tour": "atp",
63+
"surface": "hard",
64+
"player1_name": "Player One",
65+
"player1_id": 1,
66+
"player2_name": "Player Two",
67+
"player2_id": None,
68+
"status": "upcoming",
69+
}
70+
],
71+
"meta": {"count": 1},
72+
}
73+
74+
results = component.fetch_fixtures()
75+
76+
assert len(results) == 1
77+
row = results[0].data
78+
assert row["id"] == 456
79+
assert row["start_time"] == "2026-08-17T11:00:00Z"
80+
assert row["player2_id"] is None
81+
assert results[0].text == "Player One vs Player Two — US Open"
82+
83+
def test_timeout_returns_error_data(self, component_class, default_kwargs, mock_httpx_client):
84+
component = component_class(**default_kwargs)
85+
mock_httpx_client.return_value.__enter__.return_value.get.side_effect = httpx.TimeoutException("timeout")
86+
87+
results = component.fetch_fixtures()
88+
89+
assert len(results) == 1
90+
assert "error" in results[0].data
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
from unittest.mock import MagicMock, patch
2+
3+
import httpx
4+
import pytest
5+
6+
pytest.importorskip("lfx_bundles")
7+
8+
from lfx_bundles.livetennisapi.live_matches import LiveTennisMatchesComponent
9+
10+
from tests.base import ComponentTestBaseWithoutClient
11+
12+
13+
class TestLiveTennisMatchesComponent(ComponentTestBaseWithoutClient):
14+
@pytest.fixture
15+
def component_class(self):
16+
return LiveTennisMatchesComponent
17+
18+
@pytest.fixture
19+
def default_kwargs(self):
20+
return {
21+
"api_key": "test-key",
22+
"match_status": "live",
23+
"tour": "all",
24+
"limit": 50,
25+
}
26+
27+
@pytest.fixture
28+
def file_names_mapping(self):
29+
# New component, no version history yet.
30+
return []
31+
32+
@pytest.fixture(autouse=True)
33+
def mock_httpx_client(self):
34+
"""Keep every test offline, including the inherited test_latest_version."""
35+
with patch("lfx_bundles.livetennisapi.live_matches.httpx.Client") as mock_client:
36+
mock_response = MagicMock()
37+
mock_response.json.return_value = {"data": [], "meta": {"count": 0}}
38+
mock_client.return_value.__enter__.return_value.get.return_value = mock_response
39+
yield mock_client
40+
41+
def test_frontend_node(self, component_class, default_kwargs):
42+
component = component_class(**default_kwargs)
43+
44+
frontend_node = component.to_frontend_node()
45+
46+
node_data = frontend_node["data"]["node"]
47+
assert node_data["display_name"] == "Live Matches"
48+
assert node_data["icon"] == "LiveTennisAPI"
49+
assert "api_key" in node_data["template"]
50+
assert node_data["template"]["api_key"]["password"] is True
51+
assert node_data["template"]["match_status"]["options"] == ["live", "upcoming"]
52+
53+
def test_fetch_matches_success(self, component_class, default_kwargs, mock_httpx_client):
54+
component = component_class(**default_kwargs)
55+
mock_get = mock_httpx_client.return_value.__enter__.return_value.get
56+
mock_get.return_value.json.return_value = {
57+
"data": [
58+
{
59+
"id": 123,
60+
"status": "live",
61+
"tour": "atp",
62+
"tournament": "Cincinnati Open",
63+
"round": "Quarterfinal",
64+
"surface": "hard",
65+
"players": {
66+
"p1": {"id": 1, "name": "Player One", "country": "sui", "ranking": 3},
67+
"p2": {"id": 2, "name": "Player Two", "country": "esp", "ranking": 1},
68+
},
69+
"score": {
70+
"sets": [1, 0],
71+
"games": [[6, 3], [4, 2]],
72+
"points": ["30", "15"],
73+
"server": 1,
74+
},
75+
}
76+
],
77+
"meta": {"count": 1},
78+
}
79+
80+
results = component.fetch_matches()
81+
82+
assert len(results) == 1
83+
row = results[0].data
84+
assert row["id"] == 123
85+
assert row["player1"] == "Player One"
86+
assert row["player2"] == "Player Two"
87+
assert row["sets"] == "1-0"
88+
assert row["games"] == "6-4 3-2"
89+
assert row["points"] == "30-15"
90+
91+
_, kwargs = mock_get.call_args
92+
assert kwargs["params"]["status"] == "live"
93+
assert "tour" not in kwargs["params"]
94+
assert kwargs["headers"]["X-API-Key"] == "test-key"
95+
96+
def test_tour_filter_is_sent(self, component_class, default_kwargs, mock_httpx_client):
97+
component = component_class(**{**default_kwargs, "tour": "wta"})
98+
99+
component.fetch_matches()
100+
101+
_, kwargs = mock_httpx_client.return_value.__enter__.return_value.get.call_args
102+
assert kwargs["params"]["tour"] == "wta"
103+
104+
def test_http_error_returns_error_data(self, component_class, default_kwargs, mock_httpx_client):
105+
component = component_class(**default_kwargs)
106+
mock_response = mock_httpx_client.return_value.__enter__.return_value.get.return_value
107+
mock_response.status_code = 401
108+
mock_response.text = '{"error":"unauthorized"}'
109+
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
110+
"401", request=MagicMock(), response=mock_response
111+
)
112+
113+
results = component.fetch_matches()
114+
115+
assert len(results) == 1
116+
assert "error" in results[0].data
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
from unittest.mock import MagicMock, patch
2+
3+
import httpx
4+
import pytest
5+
6+
pytest.importorskip("lfx_bundles")
7+
8+
from lfx_bundles.livetennisapi.match_score import LiveTennisMatchScoreComponent
9+
10+
from tests.base import ComponentTestBaseWithoutClient
11+
12+
13+
class TestLiveTennisMatchScoreComponent(ComponentTestBaseWithoutClient):
14+
@pytest.fixture
15+
def component_class(self):
16+
return LiveTennisMatchScoreComponent
17+
18+
@pytest.fixture
19+
def default_kwargs(self):
20+
return {
21+
"api_key": "test-key",
22+
"match_id": "123",
23+
}
24+
25+
@pytest.fixture
26+
def file_names_mapping(self):
27+
# New component, no version history yet.
28+
return []
29+
30+
@pytest.fixture(autouse=True)
31+
def mock_httpx_client(self):
32+
"""Keep every test offline, including the inherited test_latest_version."""
33+
with patch("lfx_bundles.livetennisapi.match_score.httpx.Client") as mock_client:
34+
mock_response = MagicMock()
35+
mock_response.json.return_value = {}
36+
mock_client.return_value.__enter__.return_value.get.return_value = mock_response
37+
yield mock_client
38+
39+
def test_frontend_node(self, component_class, default_kwargs):
40+
component = component_class(**default_kwargs)
41+
42+
frontend_node = component.to_frontend_node()
43+
44+
node_data = frontend_node["data"]["node"]
45+
assert node_data["display_name"] == "Match Score"
46+
assert node_data["icon"] == "LiveTennisAPI"
47+
assert node_data["template"]["match_id"]["value"] == "123"
48+
49+
def test_fetch_score_success(self, component_class, default_kwargs, mock_httpx_client):
50+
component = component_class(**default_kwargs)
51+
mock_get = mock_httpx_client.return_value.__enter__.return_value.get
52+
mock_get.return_value.json.return_value = {
53+
"sets": [1, 0],
54+
"games": [[6, 3], [2, 1]],
55+
"points": ["40", "30"],
56+
"server": 2,
57+
"is_tiebreak": False,
58+
"timestamp": "2026-08-16T12:00:00Z",
59+
}
60+
61+
result = component.fetch_score()
62+
63+
assert result.data["sets"] == [1, 0]
64+
assert result.data["server"] == 2
65+
assert "sets 1-0" in result.text
66+
assert "games 6-2 3-1" in result.text
67+
68+
args, kwargs = mock_get.call_args
69+
assert args[0].endswith("/matches/123/score")
70+
assert kwargs["headers"]["X-API-Key"] == "test-key"
71+
72+
def test_non_integer_match_id_returns_error(self, component_class, default_kwargs):
73+
component = component_class(**{**default_kwargs, "match_id": "not-a-number"})
74+
75+
result = component.fetch_score()
76+
77+
assert "error" in result.data
78+
79+
def test_not_found_returns_friendly_error(self, component_class, default_kwargs, mock_httpx_client):
80+
component = component_class(**default_kwargs)
81+
mock_response = mock_httpx_client.return_value.__enter__.return_value.get.return_value
82+
mock_response.status_code = 404
83+
mock_response.text = '{"error":"not_found"}'
84+
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
85+
"404", request=MagicMock(), response=mock_response
86+
)
87+
88+
result = component.fetch_score()
89+
90+
assert "error" in result.data
91+
assert "not found" in result.text
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
from unittest.mock import MagicMock, patch
2+
3+
import pytest
4+
5+
pytest.importorskip("lfx_bundles")
6+
7+
from lfx_bundles.livetennisapi.player_search import LiveTennisPlayerSearchComponent
8+
9+
from tests.base import ComponentTestBaseWithoutClient
10+
11+
12+
class TestLiveTennisPlayerSearchComponent(ComponentTestBaseWithoutClient):
13+
@pytest.fixture
14+
def component_class(self):
15+
return LiveTennisPlayerSearchComponent
16+
17+
@pytest.fixture
18+
def default_kwargs(self):
19+
return {
20+
"api_key": "test-key",
21+
"search": "alcaraz",
22+
"limit": 50,
23+
}
24+
25+
@pytest.fixture
26+
def file_names_mapping(self):
27+
# New component, no version history yet.
28+
return []
29+
30+
@pytest.fixture(autouse=True)
31+
def mock_httpx_client(self):
32+
"""Keep every test offline, including the inherited test_latest_version."""
33+
with patch("lfx_bundles.livetennisapi.player_search.httpx.Client") as mock_client:
34+
mock_response = MagicMock()
35+
mock_response.json.return_value = {"data": [], "meta": {"count": 0}}
36+
mock_client.return_value.__enter__.return_value.get.return_value = mock_response
37+
yield mock_client
38+
39+
def test_frontend_node(self, component_class, default_kwargs):
40+
component = component_class(**default_kwargs)
41+
42+
frontend_node = component.to_frontend_node()
43+
44+
node_data = frontend_node["data"]["node"]
45+
assert node_data["display_name"] == "Player Search"
46+
assert node_data["icon"] == "LiveTennisAPI"
47+
assert node_data["template"]["search"]["value"] == "alcaraz"
48+
49+
def test_fetch_players_success(self, component_class, default_kwargs, mock_httpx_client):
50+
component = component_class(**default_kwargs)
51+
mock_get = mock_httpx_client.return_value.__enter__.return_value.get
52+
mock_get.return_value.json.return_value = {
53+
"data": [
54+
{
55+
"id": 7,
56+
"name": "Carlos Alcaraz",
57+
"tour": "atp",
58+
"country": "esp",
59+
"ranking": 1,
60+
"ranking_points": 9000,
61+
"ranking_movement": "same",
62+
"hand": "R",
63+
"birthday": "2003-05-05",
64+
"is_doubles_team": False,
65+
}
66+
],
67+
"meta": {"count": 1},
68+
}
69+
70+
results = component.fetch_players()
71+
72+
assert len(results) == 1
73+
row = results[0].data
74+
assert row["name"] == "Carlos Alcaraz"
75+
assert row["ranking"] == 1
76+
assert row["is_doubles_team"] is False
77+
78+
_, kwargs = mock_get.call_args
79+
assert kwargs["params"]["search"] == "alcaraz"
80+
81+
def test_empty_search_omits_param(self, component_class, default_kwargs, mock_httpx_client):
82+
component = component_class(**{**default_kwargs, "search": ""})
83+
84+
component.fetch_players()
85+
86+
_, kwargs = mock_httpx_client.return_value.__enter__.return_value.get.call_args
87+
assert "search" not in kwargs["params"]

0 commit comments

Comments
 (0)