Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
python-version: ["3.11", "3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@v5
- name: Install Poetry
Expand Down
4 changes: 2 additions & 2 deletions HISTORY.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ History

v0.9.0
------
* (BREAKING!) Drop support for python3.9 and older, python 3.10 or newer is now required - tehkillerbee_
* (BREAKING!) Drop support for python3.10 and older, python 3.11 or newer is now required - tehkillerbee_
* Added support for python 3.14 - tehkillerbee_
* Removed unused runtime dependency `pyaes` - tehkillerbee_
* Removed runtime dependency `typing-extensions`, no longer needed with python 3.10+ - tehkillerbee_
* Removed runtime dependency `typing-extensions`, no longer needed with python 3.11+ - tehkillerbee_
* Bugfix: Keep the rotated refresh token returned by TIDAL in `token_refresh`, previously users were logged out after a few days - tehkillerbee_
* Bugfix: Serialize concurrent access token refreshes, so parallel requests no longer refresh with an already invalidated refresh token - tehkillerbee_
* Bugfix: Refresh the access token shortly before it expires, instead of relying on the wording of the expired-token error - tehkillerbee_
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ develop:
poetry install

shell:
poetry shell
. "$$(poetry env info --path)/bin/activate" && exec "$${SHELL:-bash}"

install:
rm -rf dist
Expand Down
2 changes: 1 addition & 1 deletion README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ tidalapi

Unofficial Python API for TIDAL music streaming service.

Requires Python 3.10 or higher.
Requires Python 3.11 or higher.

Installation
------------
Expand Down
784 changes: 291 additions & 493 deletions poetry.lock

Large diffs are not rendered by default.

8 changes: 5 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,11 @@ maintainers = [
]
license = "LGPL-3.0-or-later"
license-files = ["LICENSE"]
requires-python = ">=3.10,<4.0"
requires-python = ">=3.11,<4.0"
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
Expand Down Expand Up @@ -48,7 +47,7 @@ ffmpeg-python = "^0.2.0"
black = "^26.10.1"
isort = "^9.0.2"
ruff = "^0.17.0"
docformatter = { extras = ["tomli"], version = "^1.7.3" }
docformatter = "^1.7.3"
pytest-mock = "^3.11.1"

[build-system]
Expand All @@ -71,3 +70,6 @@ line-length = 88

[tool.ruff.lint]
select = ["E4", "E7", "E9", "F", "UP"]
ignore = [
"UP042", # Codec has list members, which StrEnum rejects
]
24 changes: 12 additions & 12 deletions tests/test_token_refresh.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,11 +66,11 @@ def test_token_refresh_sets_timezone_aware_expiry_time():
"token_type": "Bearer",
}
)
before = datetime.datetime.now(datetime.timezone.utc)
before = datetime.datetime.now(datetime.UTC)

assert session.token_refresh("old-refresh")

assert session.expiry_time.tzinfo is datetime.timezone.utc
assert session.expiry_time.tzinfo is datetime.UTC
expected = before + datetime.timedelta(seconds=3600)
assert expected <= session.expiry_time <= expected + datetime.timedelta(seconds=5)

Expand Down Expand Up @@ -157,7 +157,7 @@ def _refreshing_session(expiry_time):

def test_request_refreshes_expired_token_before_sending():
session = _refreshing_session(
datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=1)
datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=1)
)

assert session.request.basic_request("GET", "tracks/1").ok
Expand All @@ -169,7 +169,7 @@ def test_request_refreshes_expired_token_before_sending():

def test_request_refreshes_token_expiring_within_margin():
session = _refreshing_session(
datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(seconds=10)
datetime.datetime.now(datetime.UTC) + datetime.timedelta(seconds=10)
)

session.request.basic_request("GET", "tracks/1")
Expand All @@ -179,7 +179,7 @@ def test_request_refreshes_token_expiring_within_margin():

def test_request_does_not_refresh_valid_token():
session = _refreshing_session(
datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=1)
datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=1)
)

session.request.basic_request("GET", "tracks/1")
Expand All @@ -196,7 +196,7 @@ def test_request_without_expiry_time_does_not_refresh_up_front():


def test_request_handles_timezone_aware_expiry_time():
expired = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(1)
expired = datetime.datetime.now(datetime.UTC) - datetime.timedelta(1)
session = _refreshing_session(expired)

session.request.basic_request("GET", "tracks/1")
Expand All @@ -206,7 +206,7 @@ def test_request_handles_timezone_aware_expiry_time():

def test_failed_proactive_refresh_does_not_fail_the_request():
session = _refreshing_session(
datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(seconds=10)
datetime.datetime.now(datetime.UTC) + datetime.timedelta(seconds=10)
)
session.request_session.post.return_value = fake_response(
400, {"error": "invalid_grant", "error_description": "dead"}
Expand All @@ -229,28 +229,28 @@ def _load_oauth_session(expiry_time):

def test_load_oauth_session_accepts_expiry_time_string():
# A naive string, as stored by applications using a previous tidalapi version
now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
now = datetime.datetime.now(datetime.UTC).replace(tzinfo=None)
expired = now - datetime.timedelta(days=1)

session = _load_oauth_session(str(expired))

# The expired token is refreshed up front, which sets a new expiry time
session.request_session.post.assert_called_once()
assert session.access_token == "new-access"
assert session.expiry_time > datetime.datetime.now(datetime.timezone.utc)
assert session.expiry_time > datetime.datetime.now(datetime.UTC)


def test_load_oauth_session_accepts_iso_format_expiry_time():
expiry_time = datetime.datetime(2030, 1, 2, 3, 4, 5)

session = _load_oauth_session(expiry_time.isoformat())

assert session.expiry_time == expiry_time.replace(tzinfo=datetime.timezone.utc)
assert session.expiry_time == expiry_time.replace(tzinfo=datetime.UTC)
session.request_session.post.assert_not_called()


def test_load_oauth_session_accepts_timezone_aware_expiry_time_string():
expiry_time = datetime.datetime(2030, 1, 2, 3, 4, 5, tzinfo=datetime.timezone.utc)
expiry_time = datetime.datetime(2030, 1, 2, 3, 4, 5, tzinfo=datetime.UTC)

session = _load_oauth_session(str(expiry_time))

Expand All @@ -263,7 +263,7 @@ def test_load_oauth_session_treats_naive_datetime_as_utc():

session = _load_oauth_session(expiry_time)

assert session.expiry_time == expiry_time.replace(tzinfo=datetime.timezone.utc)
assert session.expiry_time == expiry_time.replace(tzinfo=datetime.UTC)
session.request_session.post.assert_not_called()


Expand Down
4 changes: 2 additions & 2 deletions tidalapi/request.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,9 +156,9 @@ def _access_token_expired(self) -> bool:
if not expiry_time or not self.session.refresh_token:
return False
if expiry_time.tzinfo is None:
now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
now = datetime.datetime.now(datetime.UTC).replace(tzinfo=None)
else:
now = datetime.datetime.now(datetime.timezone.utc)
now = datetime.datetime.now(datetime.UTC)
return expiry_time - now <= TOKEN_EXPIRY_MARGIN

def _send(
Expand Down
14 changes: 7 additions & 7 deletions tidalapi/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -410,7 +410,7 @@ def load_oauth_session(

if expiry_time is not None and expiry_time.tzinfo is None:
# Treat naive datetimes (e.g. from legacy utcnow()) as UTC
expiry_time = expiry_time.replace(tzinfo=datetime.timezone.utc)
expiry_time = expiry_time.replace(tzinfo=datetime.UTC)

self.token_type = token_type
self.access_token = access_token
Expand Down Expand Up @@ -713,9 +713,9 @@ def process_auth_token(
:rtype: bool
"""
self.access_token = json["access_token"]
self.expiry_time = datetime.datetime.now(
datetime.timezone.utc
) + datetime.timedelta(seconds=json["expires_in"])
self.expiry_time = datetime.datetime.now(datetime.UTC) + datetime.timedelta(
seconds=json["expires_in"]
)
self.refresh_token = json["refresh_token"]
self.token_type = json["token_type"]
session = self.request.request("GET", "sessions")
Expand Down Expand Up @@ -805,9 +805,9 @@ def token_refresh(self, refresh_token: str) -> bool:
log.warning("The refresh token has expired, a new login is required.")
return False
self.access_token = json["access_token"]
self.expiry_time = datetime.datetime.now(
datetime.timezone.utc
) + datetime.timedelta(seconds=json["expires_in"])
self.expiry_time = datetime.datetime.now(datetime.UTC) + datetime.timedelta(
seconds=json["expires_in"]
)
self.token_type = json["token_type"]
# TIDAL rotates refresh tokens: the response may carry a new one and the
# old one is then invalidated. Keep it, otherwise a persisted session ends
Expand Down
2 changes: 1 addition & 1 deletion tox.ini
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[tox]
envlist = py310,py311,py312,py313,py314
envlist = py311,py312,py313,py314
#pypy,pypy3 pypy currently doesn't work with opencv

[testenv]
Expand Down
Loading