Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 14 additions & 21 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
#
# ============================================================================

.PHONY: format lint lint-pylint lint-flake8 test test-unit test-cov check all install clean help
.PHONY: format lint lint-fix test test-unit test-cov check all install clean help

# Default paths (can be overridden with FILES=path/to/file.py)
SRC_DIR = src/nba_api
Expand All @@ -46,11 +46,9 @@ FILES ?= $(SRC_DIR) $(TEST_DIR)
help:
@echo "NBA API Makefile Commands:"
@echo ""
@echo " make install - Install dependencies with poetry"
@echo " make format - Format code with black and isort"
@echo " make lint - Run all linters (flake8 + pylint)"
@echo " make lint-flake8 - Run only flake8"
@echo " make lint-pylint - Run only pylint"
@echo " make install - Install dependencies with poetry"
@echo " make format - Format code with ruff"
@echo " make lint - Run ruff linter"
@echo " make test - Run all unit tests"
@echo " make test-unit - Run unit tests (same as test)"
@echo " make test-cov - Run tests with coverage report"
Expand All @@ -60,31 +58,26 @@ help:
@echo ""
@echo "Examples with specific files:"
@echo " make lint FILES=src/nba_api/stats/endpoints/dunkscoreleaders.py"
@echo " make lint-pylint FILES='src/nba_api/stats/endpoints/dunkscoreleaders.py tests/unit/stats/endpoints/test_dunkscoreleaders.py'"
@echo " make format FILES=src/nba_api/stats/endpoints/dunkscoreleaders.py"

# Install dependencies
install:
poetry install --sync

# Format code with isort and black (isort first to take precedence)
# Format code with ruff
format:
@echo "Formatting: $(FILES)"
poetry run isort $(FILES)
poetry run black $(FILES)
poetry run ruff format $(FILES)

# Run flake8 linter
lint-flake8:
@echo "Running flake8 on: $(FILES)"
poetry run flake8 $(FILES)
# Run ruff linter
lint:
@echo "Linting: $(FILES)"
poetry run ruff check $(FILES)

# Run pylint
lint-pylint:
@echo "Running pylint on: $(FILES)"
poetry run pylint $(FILES) || true

# Run all linters
lint: lint-flake8 lint-pylint
# Run ruff linter with autofix
lint-fix:
@echo "Lint-fixing: $(FILES)"
poetry run ruff check $(FILES) --fix

# Run all unit tests
test-unit:
Expand Down
83 changes: 51 additions & 32 deletions src/nba_api/library/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,77 +30,88 @@


class NBAResponse:
def __init__(self, response, status_code, url):
def __init__(self, response: str, status_code: int | None, url: str | None) -> None:
self._response = response
self._status_code = status_code
self._url = url
self._dict_cache: dict | None = None
self._json_cache: str | None = None

def get_response(self):
def get_response(self) -> str:
return self._response

def get_dict(self):
return json.loads(self._response)
def get_dict(self) -> dict:
if self._dict_cache is None:
self._dict_cache = json.loads(self._response)
return self._dict_cache

def get_json(self):
return json.dumps(self.get_dict())
def get_json(self) -> str:
if self._json_cache is None:
self._json_cache = json.dumps(self.get_dict())
return self._json_cache

def valid_json(self):
def valid_json(self) -> bool:
try:
self.get_dict()
except ValueError:
return False
return True

def get_url(self):
def get_url(self) -> str | None:
return self._url

def get_status_code(self) -> int | None:
return self._status_code


class NBAHTTP:
nba_response = NBAResponse
nba_response: type[NBAResponse] = NBAResponse

base_url = None
base_url: str | None = None

parameters = None
parameters: tuple | None = None

headers = None
headers: dict[str, str] | None = None

_session = None
_session: requests.Session | None = None

@classmethod
def get_session(cls):
def get_session(cls) -> requests.Session:
session = cls._session
if session is None:
session = requests.Session()
cls._session = session
return session

@classmethod
def set_session(cls, session) -> None:
def set_session(cls, session: requests.Session) -> None:
cls._session = session

def clean_contents(self, contents):
def clean_contents(self, contents: str) -> str:
return contents

def send_api_request(
self,
endpoint,
parameters,
referer=None,
proxy=None,
headers=None,
timeout=None,
raise_exception_on_error=False,
):
endpoint: str,
parameters: dict[str, str | None],
referer: str | None = None,
proxy: str | list[str] | None = None,
headers: dict[str, str] | None = None,
timeout: int | None = None,
raise_exception_on_error: bool = False,
) -> NBAResponse:
if not self.base_url:
raise Exception("Cannot use send_api_request from _HTTP class.")
base_url = self.base_url.format(endpoint=endpoint)
endpoint = endpoint.lower()
self.parameters = parameters

request_headers = self.headers if headers is None else headers

if referer:
request_headers["Referer"] = referer
if headers is not None:
request_headers = headers
elif referer:
request_headers = {**self.headers, "Referer": referer}
else:
request_headers = self.headers

if proxy is None:
request_proxy = PROXY
Expand All @@ -126,8 +137,8 @@ def send_api_request(
contents = None
file_path = None

# Sort parameters by key... for some reason this matters for some requests...
parameters = sorted(parameters.items(), key=lambda kv: kv[0])
# tuples are faster to handle and iterate
parameters = tuple(sorted(parameters.items(), key=lambda kv: kv[0]))

if DEBUG and DEBUG_STORAGE:
print(endpoint, parameters)
Expand All @@ -151,6 +162,7 @@ def send_api_request(
if os.path.isfile(file_path):
with open(file_path) as f:
contents = f.read()
status_code = 200
print("loading from file...")

if not contents:
Expand All @@ -173,7 +185,14 @@ def send_api_request(

data = self.nba_response(response=contents, status_code=status_code, url=url)

if raise_exception_on_error and not data.valid_json():
raise Exception("InvalidResponse: Response is not in a valid JSON format.")
if raise_exception_on_error:
if status_code is not None and status_code >= 400:
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why the additional breakout of the exception?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code now checks the HTTP status code first (any >= 400) raising an Exception with the status code, before
validating JSON. This ensures HTTP errors are properly caught.

raise Exception(
f"HTTPError: Request failed with status code {status_code}."
)
if not data.valid_json():
raise Exception(
"InvalidResponse: Response is not in a valid JSON format."
)

return data
24 changes: 15 additions & 9 deletions src/nba_api/live/nba/endpoints/_base.py
Original file line number Diff line number Diff line change
@@ -1,30 +1,36 @@
import json
from typing import Any


class Endpoint:
class DataSet:
key = None
data = {}
key: str | None = None

def __init__(self, data=None):
def __init__(self, data: dict[str, Any] | list | None = None) -> None:
if data is None:
data = {}
self.data = data

def get_json(self):
def get_json(self) -> str:
return json.dumps(self.data)

def get_dict(self):
def get_dict(self) -> dict[str, Any] | list:
return self.data

def get_request_url(self):
nba_response: Any = None
data_sets: list[DataSet] | None = None

def get_request_url(self) -> str:
return self.nba_response.get_url()

def get_response(self):
def get_response(self) -> str:
return self.nba_response.get_response()

def get_dict(self):
def get_status_code(self) -> int:
return self.nba_response.get_status_code()

def get_dict(self) -> dict[str, Any]:
return self.nba_response.get_dict()

def get_json(self):
def get_json(self) -> str:
return self.nba_response.get_json()
8 changes: 4 additions & 4 deletions src/nba_api/live/nba/library/http.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
from nba_api.library import http

try:
from nba_api.library.debug.debug import STATS_HEADERS
from nba_api.library.debug.debug import LIVE_HEADERS
except ImportError:
STATS_HEADERS = {
LIVE_HEADERS = {
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9",
"Accept-Encoding": "gzip, deflate, br",
"Accept-Language": "en-US,en;q=0.9",
Expand All @@ -17,9 +17,9 @@
class NBALiveHTTP(http.NBAHTTP):
nba_response = http.NBAResponse
base_url = "https://cdn.nba.com/static/json/liveData/{endpoint}"
headers = STATS_HEADERS
headers = LIVE_HEADERS

def clean_contents(self, contents):
def clean_contents(self, contents: str) -> str:
if '{"Message":"An error has occurred."}' in contents:
return "<Error><Message>An error has occurred.</Message></Error>"
return contents
24 changes: 11 additions & 13 deletions src/nba_api/stats/endpoints/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
class Endpoint:
class DataSet:
key: str | None = None
data: dict[str, Any] = {}

def __init__(self, data: dict[str, Any]) -> None:
self.data = data
Expand All @@ -36,8 +35,9 @@ def get_data_frame(self) -> DataFrame:
Exception: If pandas is not installed.
"""
if not PANDAS:
raise Exception(
"Import Missing - Failed to import DataFrame from pandas."
raise ImportError(
"Failed to import DataFrame from pandas. "
"Install pandas: pip install pandas"
)

if "headers" not in self.data or not self.data["headers"]:
Expand All @@ -53,14 +53,8 @@ def get_data_frame(self) -> DataFrame:
len(self.data["headers"])
): # Extend column names for level to full length
level = self.data["headers"][i]
level_names.append(
level["name"] if "name" in level else "LEVEL_" + str(i)
)
column_names = (
[""] * level["columnsToSkip"]
if "columnsToSkip" in level
else []
)
level_names.append(level.get("name", f"LEVEL_{i}"))
column_names = [""] * level.get("columnsToSkip", 0)
column_names += list(
np.repeat(
np.array(level["columnNames"]),
Expand All @@ -74,20 +68,24 @@ def get_data_frame(self) -> DataFrame:
return DataFrame(self.data["data"], columns=midx)

nba_response: Any = None
data_sets: list[DataSet] = []
data_sets: list[DataSet] | None = None

def get_request_url(self) -> str:
"""Return the URL of the request."""
return self.nba_response.get_url()

def get_available_data(self) -> list[str]:
"""Return the keys of the available data sets."""
return self.get_normalized_dict().keys()
return list(self.get_normalized_dict().keys())

def get_response(self) -> str:
"""Return the raw response string."""
return self.nba_response.get_response()

def get_status_code(self) -> int:
"""Return the HTTP status code of the response."""
return self.nba_response.get_status_code()

def get_dict(self) -> dict[str, Any]:
"""Return the response as a dictionary."""
return self.nba_response.get_dict()
Expand Down
10 changes: 5 additions & 5 deletions src/nba_api/stats/endpoints/_parsers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,10 +86,10 @@ def get_parser_for_endpoint(endpoint, nba_dict):
nba_dict (dict): The raw API response dictionary.

Returns:
Parser instance configured with the provided data.

Raises:
KeyError: If the endpoint doesn't have a registered parser.
Parser instance configured with the provided data, or None if
no parser is registered for this endpoint.
"""
parser_class = _PARSER_REGISTRY[endpoint]
parser_class = _PARSER_REGISTRY.get(endpoint)
if parser_class is None:
return None
return parser_class(nba_dict)
Loading